Commit workspace changes

This commit is contained in:
2026-04-05 19:42:33 +02:00
parent 148a3bbe43
commit 08ad757bcb
312 changed files with 35149 additions and 399 deletions

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ArtworkAttributionService
{
public function __construct(
private readonly GroupMembershipService $groupMembers,
private readonly GroupService $groups,
) {
}
public function apply(Artwork $artwork, User $actor, array $attributes, bool $requireDirectPublish = true): Artwork
{
$previousGroupId = (int) ($artwork->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();
}
}

View File

@@ -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);

View File

@@ -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];

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupActivityItem;
use App\Models\GroupAsset;
use App\Models\GroupChallenge;
use App\Models\GroupEvent;
use App\Models\GroupPost;
use App\Models\GroupProject;
use App\Models\GroupRelease;
use App\Models\User;
use Illuminate\Support\Collection;
class GroupActivityService
{
public function record(
Group $group,
?User $actor,
string $type,
string $subjectType,
?int $subjectId,
string $headline,
?string $summary = null,
string $visibility = GroupActivityItem::VISIBILITY_PUBLIC,
): GroupActivityItem {
return GroupActivityItem::query()->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,
};
}
}

View File

@@ -0,0 +1,408 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\Group;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class GroupArtworkReviewService
{
public function __construct(
private readonly ArtworkAttributionService $attribution,
private readonly GroupHistoryService $history,
private readonly NotificationService $notifications,
private readonly GroupMembershipService $memberships,
) {
}
public function submit(Group $group, Artwork $artwork, User $actor, array $attributes): Artwork
{
if (! $group->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(),
]);
}
}
}

View File

@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupAsset;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\StreamedResponse;
class GroupAssetService
{
private const STORAGE_DISK = 'local';
public function __construct(
private readonly GroupHistoryService $history,
private readonly GroupActivityService $activity,
) {
}
public function store(Group $group, User $actor, array $attributes): GroupAsset
{
$file = $attributes['file'];
if (! $file instanceof UploadedFile) {
throw ValidationException::withMessages([
'file' => '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;
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Support\Str;
class GroupCardService
{
public function __construct(
private readonly GroupRecruitmentService $recruitment,
private readonly GroupReputationService $reputation,
private readonly GroupFollowService $follows,
private readonly GroupMembershipService $memberships,
) {
}
public function mapGroupCard(Group $group, ?User $viewer = null): array
{
$owner = $group->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),
];
}
}

View File

@@ -0,0 +1,407 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupChallenge;
use App\Models\GroupChallengeArtwork;
use App\Models\User;
use App\Support\ThumbnailPresenter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class GroupChallengeService
{
public function __construct(
private readonly GroupHistoryService $history,
private readonly GroupActivityService $activity,
private readonly GroupMediaService $media,
private readonly NotificationService $notifications,
) {
}
public function create(Group $group, User $actor, array $attributes): GroupChallenge
{
$coverPath = null;
try {
$challenge = DB::transaction(function () use ($group, $actor, $attributes, &$coverPath): GroupChallenge {
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
$coverPath = $this->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;
}
}

View File

@@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\GroupChallenge;
use App\Models\Group;
use App\Models\GroupActivityItem;
use App\Models\GroupDiscoveryMetric;
use App\Models\GroupEvent;
use App\Models\GroupProject;
use App\Models\GroupRelease;
use App\Models\User;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class GroupDiscoveryService
{
public function __construct(
private readonly GroupCardService $cards,
) {
}
public function refresh(Group $group): GroupDiscoveryMetric
{
$publicReleaseCount = (int) $group->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,
};
}
}

View File

@@ -0,0 +1,345 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupEvent;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class GroupEventService
{
public function __construct(
private readonly GroupHistoryService $history,
private readonly GroupActivityService $activity,
private readonly GroupMediaService $media,
private readonly NotificationService $notifications,
) {
}
public function create(Group $group, User $actor, array $attributes): GroupEvent
{
$coverPath = null;
try {
$event = DB::transaction(function () use ($group, $actor, $attributes, &$coverPath): GroupEvent {
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
$coverPath = $this->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;
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupFollow;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class GroupFollowService
{
public function follow(Group $group, User $user): bool
{
if ($group->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();
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupHistory;
use App\Models\User;
class GroupHistoryService
{
public function record(
Group $group,
?User $actor,
string $actionType,
?string $summary = null,
?string $targetType = null,
?int $targetId = null,
?array $before = null,
?array $after = null,
): GroupHistory {
return GroupHistory::query()->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();
}
}

View File

@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupJoinRequest;
use App\Models\GroupMember;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class GroupJoinRequestService
{
public function __construct(
private readonly GroupHistoryService $history,
private readonly GroupMembershipService $memberships,
private readonly NotificationService $notifications,
) {
}
public function submit(Group $group, User $actor, array $attributes): GroupJoinRequest
{
if (! $group->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();
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class GroupMediaService
{
private const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
];
public function storeUploadedImage(Group $group, UploadedFile $file, string $variant): string
{
return $this->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',
};
}
}

View File

@@ -0,0 +1,762 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupInvitation;
use App\Models\GroupMember;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class GroupMembershipService
{
public function __construct(
private readonly NotificationService $notifications,
) {
}
public function ensureOwnerMembership(Group $group): void
{
GroupMember::query()
->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();
}
}

View File

@@ -0,0 +1,359 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupPost;
use App\Models\User;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class GroupPostService
{
public function __construct(
private readonly GroupHistoryService $history,
private readonly NotificationService $notifications,
) {
}
public function create(Group $group, User $actor, array $attributes): GroupPost
{
$post = GroupPost::query()->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;
}
}

View File

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

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupRecruitmentProfile;
use App\Models\User;
class GroupRecruitmentService
{
public function __construct(
private readonly GroupHistoryService $history,
private readonly NotificationService $notifications,
) {
}
public function upsert(Group $group, array $attributes, User $actor): GroupRecruitmentProfile
{
$profile = GroupRecruitmentProfile::query()->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();
}
}

View File

@@ -0,0 +1,817 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupRelease;
use App\Models\GroupReleaseArtwork;
use App\Models\GroupReleaseContributor;
use App\Models\GroupReleaseMilestone;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class GroupReleaseService
{
public function __construct(
private readonly GroupHistoryService $history,
private readonly GroupActivityService $activity,
private readonly GroupMediaService $media,
private readonly NotificationService $notifications,
private readonly GroupReputationService $reputation,
private readonly GroupDiscoveryService $discovery,
) {
}
public function create(Group $group, User $actor, array $attributes): GroupRelease
{
$coverPath = null;
try {
$release = DB::transaction(function () use ($group, $actor, $attributes, &$coverPath): GroupRelease {
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
$coverPath = $this->media->storeUploadedEntityImage($group, $attributes['cover_file'], 'releases');
}
return GroupRelease::query()->create([
'group_id' => (int) $group->id,
'title' => trim((string) $attributes['title']),
'slug' => $this->makeUniqueSlug((string) $attributes['title']),
'summary' => $this->nullableString($attributes['summary'] ?? null),
'description' => $this->nullableString($attributes['description'] ?? null),
'cover_path' => $coverPath ?: $this->nullableString($attributes['cover_path'] ?? null),
'status' => (string) ($attributes['status'] ?? GroupRelease::STATUS_PLANNED),
'current_stage' => (string) ($attributes['current_stage'] ?? GroupRelease::STAGE_CONCEPT),
'visibility' => (string) ($attributes['visibility'] ?? GroupRelease::VISIBILITY_PUBLIC),
'planned_release_at' => $attributes['planned_release_at'] ?? null,
'released_at' => null,
'lead_user_id' => $this->normalizeLeadUserId($group, $attributes['lead_user_id'] ?? null),
'linked_project_id' => $this->normalizeProjectId($group, $attributes['linked_project_id'] ?? null),
'linked_collection_id' => $this->normalizeCollectionId($group, $attributes['linked_collection_id'] ?? null),
'featured_artwork_id' => $this->normalizeArtworkId($group, $attributes['featured_artwork_id'] ?? null),
'release_notes' => $this->nullableString($attributes['release_notes'] ?? null),
'created_by_user_id' => (int) $actor->id,
'published_at' => null,
'is_featured' => (bool) ($attributes['is_featured'] ?? false),
]);
});
} catch (\Throwable $exception) {
$this->media->deleteIfManaged($coverPath);
throw $exception;
}
$this->history->record(
$group,
$actor,
'release_created',
sprintf('Created release "%s".', $release->title),
'group_release',
(int) $release->id,
null,
$release->only(['title', 'status', 'current_stage', 'visibility'])
);
$this->activity->record(
$group,
$actor,
'release_created',
'group_release',
(int) $release->id,
sprintf('%s opened a new release pipeline: %s', $actor->name ?: $actor->username ?: 'A member', $release->title),
$release->summary,
$release->visibility === GroupRelease::VISIBILITY_PUBLIC ? 'public' : 'internal',
);
$this->notifyReleaseScheduledIfNeeded($release, $actor, null, null);
$this->reputation->refreshGroup($group);
$this->discovery->refresh($group);
return $release->fresh($this->detailRelations());
}
public function update(GroupRelease $release, User $actor, array $attributes): GroupRelease
{
$coverPath = null;
$oldCoverPath = $release->cover_path;
$before = $release->only(['title', 'summary', 'description', 'status', 'current_stage', 'visibility', 'planned_release_at', 'lead_user_id', 'linked_project_id', 'linked_collection_id', 'featured_artwork_id', 'release_notes', 'is_featured']);
try {
DB::transaction(function () use ($release, $attributes, &$coverPath): void {
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
$coverPath = $this->media->storeUploadedEntityImage($release->group, $attributes['cover_file'], 'releases');
}
$title = trim((string) ($attributes['title'] ?? $release->title));
$release->fill([
'title' => $title,
'slug' => $title !== $release->title ? $this->makeUniqueSlug($title, (int) $release->id) : $release->slug,
'summary' => array_key_exists('summary', $attributes) ? $this->nullableString($attributes['summary']) : $release->summary,
'description' => array_key_exists('description', $attributes) ? $this->nullableString($attributes['description']) : $release->description,
'cover_path' => $coverPath ?: (array_key_exists('cover_path', $attributes) ? $this->nullableString($attributes['cover_path']) : $release->cover_path),
'status' => (string) ($attributes['status'] ?? $release->status),
'current_stage' => (string) ($attributes['current_stage'] ?? $release->current_stage),
'visibility' => (string) ($attributes['visibility'] ?? $release->visibility),
'planned_release_at' => $attributes['planned_release_at'] ?? $release->planned_release_at,
'lead_user_id' => array_key_exists('lead_user_id', $attributes) ? $this->normalizeLeadUserId($release->group, $attributes['lead_user_id']) : $release->lead_user_id,
'linked_project_id' => array_key_exists('linked_project_id', $attributes) ? $this->normalizeProjectId($release->group, $attributes['linked_project_id']) : $release->linked_project_id,
'linked_collection_id' => array_key_exists('linked_collection_id', $attributes) ? $this->normalizeCollectionId($release->group, $attributes['linked_collection_id']) : $release->linked_collection_id,
'featured_artwork_id' => array_key_exists('featured_artwork_id', $attributes) ? $this->normalizeArtworkId($release->group, $attributes['featured_artwork_id']) : $release->featured_artwork_id,
'release_notes' => array_key_exists('release_notes', $attributes) ? $this->nullableString($attributes['release_notes']) : $release->release_notes,
'is_featured' => (bool) ($attributes['is_featured'] ?? $release->is_featured),
])->save();
});
} catch (\Throwable $exception) {
$this->media->deleteIfManaged($coverPath);
throw $exception;
}
if ($coverPath !== null && $oldCoverPath !== $release->cover_path) {
$this->media->deleteIfManaged($oldCoverPath);
}
$this->history->record(
$release->group,
$actor,
'release_updated',
sprintf('Updated release "%s".', $release->title),
'group_release',
(int) $release->id,
$before,
$release->only(['title', 'summary', 'description', 'status', 'current_stage', 'visibility', 'planned_release_at', 'lead_user_id', 'linked_project_id', 'linked_collection_id', 'featured_artwork_id', 'release_notes', 'is_featured'])
);
$this->activity->record(
$release->group,
$actor,
'release_updated',
'group_release',
(int) $release->id,
sprintf('%s updated release %s', $actor->name ?: $actor->username ?: 'A member', $release->title),
$release->summary,
$release->visibility === GroupRelease::VISIBILITY_PUBLIC ? 'public' : 'internal',
);
if (! (bool) ($before['is_featured'] ?? false) && $release->is_featured && $release->visibility === GroupRelease::VISIBILITY_PUBLIC) {
foreach ($release->group->follows()->with('user.profile')->get() as $follow) {
if ($follow->user) {
$this->notifications->notifyFeaturedReleasePromoted($follow->user, $actor, $release->group, $release);
}
}
}
$this->notifyReleaseScheduledIfNeeded(
$release,
$actor,
(string) ($before['status'] ?? null),
$before['planned_release_at'] ? (string) $before['planned_release_at'] : null,
);
$this->reputation->refreshGroup($release->group);
$this->discovery->refresh($release->group);
return $release->fresh($this->detailRelations());
}
public function updateStage(GroupRelease $release, User $actor, string $stage): GroupRelease
{
$before = $release->only(['current_stage', 'status']);
$status = $release->status;
if ($stage === GroupRelease::STAGE_RELEASED) {
$status = GroupRelease::STATUS_RELEASED;
} elseif ($stage === GroupRelease::STAGE_APPROVAL && $release->status === GroupRelease::STATUS_PLANNED) {
$status = GroupRelease::STATUS_INTERNAL_REVIEW;
} elseif ($release->status === GroupRelease::STATUS_PLANNED) {
$status = GroupRelease::STATUS_IN_PROGRESS;
}
$release->forceFill([
'current_stage' => $stage,
'status' => $status,
])->save();
$this->history->record(
$release->group,
$actor,
'release_stage_updated',
sprintf('Moved release "%s" to %s.', $release->title, $stage),
'group_release',
(int) $release->id,
$before,
['current_stage' => $release->current_stage, 'status' => $release->status]
);
$this->activity->record(
$release->group,
$actor,
'release_stage_updated',
'group_release',
(int) $release->id,
sprintf('%s moved release %s to %s', $actor->name ?: $actor->username ?: 'A member', $release->title, $stage),
$release->summary,
$release->visibility === GroupRelease::VISIBILITY_PUBLIC ? 'public' : 'internal',
);
if ($release->visibility === GroupRelease::VISIBILITY_PUBLIC) {
foreach ($release->group->follows()->with('user.profile')->get() as $follow) {
if ($follow->user) {
$this->notifications->notifyGroupReleaseStageChanged($follow->user, $actor, $release->group, $release);
}
}
}
$this->reputation->refreshGroup($release->group);
$this->discovery->refresh($release->group);
return $release->fresh($this->detailRelations());
}
public function publish(GroupRelease $release, User $actor): GroupRelease
{
$this->guardPublishable($release);
$before = $release->only(['status', 'current_stage', 'released_at', 'published_at']);
$release->forceFill([
'status' => GroupRelease::STATUS_RELEASED,
'current_stage' => GroupRelease::STAGE_RELEASED,
'released_at' => now(),
'published_at' => now(),
])->save();
$this->history->record(
$release->group,
$actor,
'release_published',
sprintf('Published release "%s".', $release->title),
'group_release',
(int) $release->id,
$before,
[
'status' => $release->status,
'current_stage' => $release->current_stage,
'released_at' => $release->released_at?->toISOString(),
'published_at' => $release->published_at?->toISOString(),
]
);
$this->activity->record(
$release->group,
$actor,
'release_published',
'group_release',
(int) $release->id,
sprintf('%s released %s', $release->group->name, $release->title),
$release->summary,
$release->visibility === GroupRelease::VISIBILITY_PUBLIC ? 'public' : 'internal',
);
if ($release->visibility === GroupRelease::VISIBILITY_PUBLIC) {
foreach ($release->group->follows()->with('user.profile')->get() as $follow) {
if ($follow->user) {
$this->notifications->notifyGroupReleasePublished($follow->user, $actor, $release->group, $release);
}
}
}
$this->reputation->refreshGroup($release->group);
$this->discovery->refresh($release->group);
return $release->fresh($this->detailRelations());
}
public function attachArtwork(GroupRelease $release, Artwork $artwork, User $actor): GroupRelease
{
if ((int) $artwork->group_id !== (int) $release->group_id) {
throw ValidationException::withMessages([
'artwork' => 'Only artworks published under this group can be attached to a group release.',
]);
}
GroupReleaseArtwork::query()->updateOrCreate(
[
'group_release_id' => (int) $release->id,
'artwork_id' => (int) $artwork->id,
],
[
'sort_order' => (int) $release->artworkLinks()->count(),
]
);
$this->history->record(
$release->group,
$actor,
'release_artwork_attached',
sprintf('Attached artwork "%s" to release "%s".', $artwork->title, $release->title),
'group_release',
(int) $release->id,
null,
['artwork_id' => (int) $artwork->id]
);
$this->reputation->refreshGroup($release->group);
return $release->fresh($this->detailRelations());
}
public function attachContributor(GroupRelease $release, User $contributor, User $actor, ?string $roleLabel = null): GroupRelease
{
if (! $release->group->hasActiveMember($contributor) && ! $release->group->isOwnedBy($contributor)) {
throw ValidationException::withMessages([
'user' => 'Only active group members can be attached as release contributors.',
]);
}
GroupReleaseContributor::query()->updateOrCreate(
[
'group_release_id' => (int) $release->id,
'user_id' => (int) $contributor->id,
],
[
'role_label' => $this->nullableString($roleLabel),
'sort_order' => (int) $release->contributorLinks()->count(),
]
);
$this->history->record(
$release->group,
$actor,
'release_contributor_attached',
sprintf('Attached %s as a contributor to release "%s".', $contributor->name ?: $contributor->username ?: 'a member', $release->title),
'group_release',
(int) $release->id,
null,
['user_id' => (int) $contributor->id, 'role_label' => $this->nullableString($roleLabel)]
);
$this->notifications->notifyGroupReleaseContributorAdded($contributor, $actor, $release->group, $release, $this->nullableString($roleLabel));
$this->reputation->refreshGroup($release->group);
return $release->fresh($this->detailRelations());
}
public function createMilestone(GroupRelease $release, User $actor, array $attributes): GroupReleaseMilestone
{
$milestone = $release->milestones()->create([
'title' => trim((string) $attributes['title']),
'summary' => $this->nullableString($attributes['summary'] ?? null),
'status' => (string) ($attributes['status'] ?? GroupReleaseMilestone::STATUS_PENDING),
'due_date' => $attributes['due_date'] ?? null,
'owner_user_id' => $this->normalizeLeadUserId($release->group, $attributes['owner_user_id'] ?? null),
'sort_order' => (int) $release->milestones()->count(),
'notes' => $this->nullableString($attributes['notes'] ?? null),
]);
$this->history->record(
$release->group,
$actor,
'release_milestone_created',
sprintf('Created milestone "%s" for release "%s".', $milestone->title, $release->title),
'group_release',
(int) $release->id,
null,
['milestone_id' => (int) $milestone->id, 'status' => $milestone->status]
);
if ($milestone->owner) {
$this->notifications->notifyGroupMilestoneAssigned(
$milestone->owner,
$actor,
$release->group,
'release',
$release->title,
$milestone->title,
route('studio.groups.releases.show', ['group' => $release->group, 'release' => $release])
);
$this->notifyMilestoneDueSoonIfNeeded(
$milestone->owner,
$actor,
$release->group,
'release',
$release->title,
$milestone->title,
$milestone->due_date?->toDateString(),
route('studio.groups.releases.show', ['group' => $release->group, 'release' => $release])
);
}
$this->reputation->refreshGroup($release->group);
return $milestone->fresh('owner.profile');
}
public function updateMilestone(GroupReleaseMilestone $milestone, User $actor, array $attributes): GroupReleaseMilestone
{
$before = $milestone->only(['title', 'summary', 'status', 'due_date', 'owner_user_id', 'notes']);
$previousOwnerId = (int) ($milestone->owner_user_id ?? 0);
$milestone->fill([
'title' => trim((string) ($attributes['title'] ?? $milestone->title)),
'summary' => array_key_exists('summary', $attributes) ? $this->nullableString($attributes['summary']) : $milestone->summary,
'status' => (string) ($attributes['status'] ?? $milestone->status),
'due_date' => $attributes['due_date'] ?? $milestone->due_date,
'owner_user_id' => array_key_exists('owner_user_id', $attributes) ? $this->normalizeLeadUserId($milestone->release->group, $attributes['owner_user_id']) : $milestone->owner_user_id,
'notes' => array_key_exists('notes', $attributes) ? $this->nullableString($attributes['notes']) : $milestone->notes,
])->save();
$this->history->record(
$milestone->release->group,
$actor,
'release_milestone_updated',
sprintf('Updated milestone "%s" for release "%s".', $milestone->title, $milestone->release->title),
'group_release',
(int) $milestone->release_id,
$before,
$milestone->only(['title', 'summary', 'status', 'due_date', 'owner_user_id', 'notes'])
);
if ((int) ($milestone->owner_user_id ?? 0) > 0 && (int) $milestone->owner_user_id !== $previousOwnerId && $milestone->owner) {
$this->notifications->notifyGroupMilestoneAssigned(
$milestone->owner,
$actor,
$milestone->release->group,
'release',
$milestone->release->title,
$milestone->title,
route('studio.groups.releases.show', ['group' => $milestone->release->group, 'release' => $milestone->release])
);
}
if ($milestone->owner && $this->shouldNotifyDueSoon($before['due_date'] ?? null, $milestone->due_date, $before['status'] ?? null, $milestone->status, $previousOwnerId, (int) ($milestone->owner_user_id ?? 0))) {
$this->notifyMilestoneDueSoonIfNeeded(
$milestone->owner,
$actor,
$milestone->release->group,
'release',
$milestone->release->title,
$milestone->title,
$milestone->due_date?->toDateString(),
route('studio.groups.releases.show', ['group' => $milestone->release->group, 'release' => $milestone->release])
);
}
$this->reputation->refreshGroup($milestone->release->group);
return $milestone->fresh(['owner.profile', 'release']);
}
public function featuredRelease(Group $group, ?User $viewer = null): ?array
{
$release = $this->visibleQuery($group, $viewer)
->with(['lead.profile', 'linkedProject', 'linkedCollection', 'featuredArtwork', 'contributorLinks.user.profile'])
->where(function ($query): void {
$query->where('is_featured', true)
->orWhere('status', GroupRelease::STATUS_RELEASED);
})
->orderByDesc('is_featured')
->orderByDesc('released_at')
->latest('updated_at')
->first();
return $release ? $this->mapPublicRelease($release, $viewer) : null;
}
public function publicListing(Group $group, ?User $viewer = null, int $limit = 12): array
{
return $this->visibleQuery($group, $viewer)
->with(['lead.profile', 'linkedProject', 'linkedCollection', 'featuredArtwork'])
->orderByDesc('released_at')
->latest('updated_at')
->limit($limit)
->get()
->map(fn (GroupRelease $release): array => $this->mapPublicRelease($release, $viewer))
->values()
->all();
}
public function studioListing(Group $group, array $filters = []): array
{
$bucket = (string) ($filters['bucket'] ?? 'all');
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50);
$query = GroupRelease::query()
->with(['lead.profile', 'linkedProject', 'linkedCollection', 'featuredArtwork'])
->where('group_id', $group->id);
if ($bucket !== 'all') {
$query->where('status', $bucket);
}
$paginator = $query->orderByDesc('released_at')->latest('updated_at')->paginate($perPage, ['*'], 'page', $page);
return [
'items' => collect($paginator->items())->map(fn (GroupRelease $release): array => $this->mapStudioRelease($release))->values()->all(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
'filters' => ['bucket' => $bucket],
'bucket_options' => array_merge([
['value' => 'all', 'label' => 'All'],
], collect((array) config('groups.releases.statuses', []))
->map(fn (string $value): array => ['value' => $value, 'label' => str_replace('_', ' ', Str::headline($value))])
->values()
->all()),
];
}
public function detailPayload(GroupRelease $release, ?User $viewer = null): array
{
$release->loadMissing($this->detailRelations());
$payload = $this->mapPublicRelease($release, $viewer);
$payload['description'] = $release->description;
$payload['release_notes'] = $release->release_notes;
$payload['artworks'] = $release->artworks->take(18)->map(fn (Artwork $artwork): array => [
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'thumb' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'),
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: $artwork->id]),
'author' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username,
])->values()->all();
$payload['contributors'] = $release->contributorLinks->map(fn (GroupReleaseContributor $contributor): array => [
'id' => (int) $contributor->user_id,
'name' => $contributor->user?->name,
'username' => $contributor->user?->username,
'avatar_url' => $contributor->user ? AvatarUrl::forUser((int) $contributor->user->id, $contributor->user->profile?->avatar_hash, 72) : null,
'role_label' => $contributor->role_label,
])->values()->all();
$payload['milestones'] = $release->milestones->map(fn (GroupReleaseMilestone $milestone): array => $this->mapMilestone($milestone))->values()->all();
return $payload;
}
public function memberOptions(Group $group): array
{
return $group->members()
->with('user.profile')
->where('status', Group::STATUS_ACTIVE)
->get()
->map(fn ($member): array => [
'id' => (int) $member->user_id,
'name' => $member->user?->name,
'username' => $member->user?->username,
])
->prepend([
'id' => (int) $group->owner_user_id,
'name' => $group->owner?->name,
'username' => $group->owner?->username,
])
->unique('id')
->values()
->all();
}
public function mapPublicRelease(GroupRelease $release, ?User $viewer = null): array
{
return [
'id' => (int) $release->id,
'title' => (string) $release->title,
'slug' => (string) $release->slug,
'summary' => $release->summary,
'status' => (string) $release->status,
'current_stage' => (string) $release->current_stage,
'visibility' => (string) $release->visibility,
'cover_url' => $release->coverUrl(),
'planned_release_at' => $release->planned_release_at?->toISOString(),
'released_at' => $release->released_at?->toISOString(),
'published_at' => $release->published_at?->toISOString(),
'is_featured' => (bool) $release->is_featured,
'lead' => $release->lead ? [
'id' => (int) $release->lead->id,
'name' => $release->lead->name,
'username' => $release->lead->username,
] : null,
'linked_project' => $release->linkedProject ? [
'id' => (int) $release->linkedProject->id,
'title' => $release->linkedProject->title,
'url' => route('groups.projects.show', ['group' => $release->group, 'project' => $release->linkedProject]),
] : null,
'linked_collection' => $release->linkedCollection ? [
'id' => (int) $release->linkedCollection->id,
'title' => $release->linkedCollection->title,
'url' => route('profile.collections.show', ['username' => strtolower((string) $release->linkedCollection->user?->username), 'slug' => $release->linkedCollection->slug]),
] : null,
'featured_artwork' => $release->featuredArtwork ? [
'id' => (int) $release->featuredArtwork->id,
'title' => $release->featuredArtwork->title,
'thumb' => ThumbnailPresenter::present($release->featuredArtwork, 'md')['url'] ?? $release->featuredArtwork->thumbUrl('md'),
] : null,
'counts' => [
'artworks' => (int) $release->artworks()->count(),
'contributors' => (int) $release->contributorLinks()->count(),
'milestones' => (int) $release->milestones()->count(),
],
'url' => route('groups.releases.show', ['group' => $release->group, 'release' => $release]),
];
}
public function mapStudioRelease(GroupRelease $release): array
{
return array_merge($this->mapPublicRelease($release), [
'description' => $release->description,
'release_notes' => $release->release_notes,
'urls' => [
'public' => $release->visibility !== GroupRelease::VISIBILITY_PRIVATE ? route('groups.releases.show', ['group' => $release->group, 'release' => $release]) : null,
'edit' => route('studio.groups.releases.show', ['group' => $release->group, 'release' => $release]),
'stage' => route('studio.groups.releases.stage', ['group' => $release->group, 'release' => $release]),
'publish' => route('studio.groups.releases.publish', ['group' => $release->group, 'release' => $release]),
'attach_artwork' => route('studio.groups.releases.attach-artwork', ['group' => $release->group, 'release' => $release]),
'attach_contributor' => route('studio.groups.releases.attach-contributor', ['group' => $release->group, 'release' => $release]),
'store_milestone' => route('studio.groups.releases.milestones.store', ['group' => $release->group, 'release' => $release]),
'update_milestone_pattern' => route('studio.groups.releases.milestones.update', ['group' => $release->group, 'release' => $release, 'milestone' => '__MILESTONE__']),
],
]);
}
private function mapMilestone(GroupReleaseMilestone $milestone): array
{
return [
'id' => (int) $milestone->id,
'title' => (string) $milestone->title,
'summary' => $milestone->summary,
'status' => (string) $milestone->status,
'due_date' => $milestone->due_date?->toDateString(),
'notes' => $milestone->notes,
'owner' => $milestone->owner ? [
'id' => (int) $milestone->owner->id,
'name' => $milestone->owner->name,
'username' => $milestone->owner->username,
] : null,
];
}
private function visibleQuery(Group $group, ?User $viewer = null)
{
return GroupRelease::query()
->where('group_id', $group->id)
->when(! ($viewer && $group->canViewStudio($viewer)), function ($query): void {
$query->where('visibility', GroupRelease::VISIBILITY_PUBLIC)
->whereNotIn('status', [GroupRelease::STATUS_ARCHIVED, GroupRelease::STATUS_CANCELLED]);
});
}
private function guardPublishable(GroupRelease $release): void
{
if ($release->group->status !== Group::LIFECYCLE_ACTIVE) {
throw ValidationException::withMessages([
'group' => 'Archived or suspended groups cannot publish new releases.',
]);
}
if ($release->visibility === GroupRelease::VISIBILITY_PRIVATE) {
throw ValidationException::withMessages([
'visibility' => 'Private releases cannot be published publicly.',
]);
}
foreach ($release->artworks as $artwork) {
if ((int) $artwork->group_id !== (int) $release->group_id || ! (bool) $artwork->is_public || ! (bool) $artwork->is_approved) {
throw ValidationException::withMessages([
'artworks' => 'All release artworks must belong to the group and be approved for public visibility.',
]);
}
}
if ($release->linkedProject && (int) $release->linkedProject->group_id !== (int) $release->group_id) {
throw ValidationException::withMessages([
'linked_project_id' => 'Linked project must belong to the same group.',
]);
}
if ($release->linkedCollection && (int) ($release->linkedCollection->group_id ?? 0) !== (int) $release->group_id) {
throw ValidationException::withMessages([
'linked_collection_id' => 'Linked collection must belong to the same group.',
]);
}
}
private function detailRelations(): array
{
return [
'group',
'creator.profile',
'lead.profile',
'linkedProject',
'linkedCollection.user.profile',
'featuredArtwork.primaryAuthor.profile',
'artworks.primaryAuthor.profile',
'contributorLinks.user.profile',
'milestones.owner.profile',
];
}
private function makeUniqueSlug(string $source, ?int $ignoreReleaseId = null): string
{
$base = Str::slug(Str::limit($source, 150, '')) ?: 'release';
$slug = $base;
$suffix = 2;
while (GroupRelease::query()->where('slug', $slug)->when($ignoreReleaseId !== null, fn ($query) => $query->where('id', '!=', $ignoreReleaseId))->exists()) {
$slug = Str::limit($base, 180, '') . '-' . $suffix;
$suffix++;
}
return $slug;
}
private function normalizeLeadUserId(Group $group, mixed $leadUserId): ?int
{
$id = (int) $leadUserId;
if ($id <= 0) {
return null;
}
if ((int) $group->owner_user_id === $id) {
return $id;
}
return $group->members()->where('user_id', $id)->where('status', Group::STATUS_ACTIVE)->exists() ? $id : null;
}
private function normalizeProjectId(Group $group, mixed $projectId): ?int
{
$id = (int) $projectId;
return $id > 0 && $group->projects()->where('id', $id)->exists() ? $id : null;
}
private function normalizeCollectionId(Group $group, mixed $collectionId): ?int
{
$id = (int) $collectionId;
return $id > 0 && $group->collections()->where('id', $id)->exists() ? $id : null;
}
private function normalizeArtworkId(Group $group, mixed $artworkId): ?int
{
$id = (int) $artworkId;
return $id > 0 && $group->artworks()->where('id', $id)->whereNull('deleted_at')->exists() ? $id : null;
}
private function nullableString(mixed $value): ?string
{
$trimmed = trim((string) $value);
return $trimmed !== '' ? $trimmed : null;
}
private function notifyReleaseScheduledIfNeeded(GroupRelease $release, User $actor, ?string $previousStatus, ?string $previousPlannedReleaseAt): void
{
if ($release->visibility !== GroupRelease::VISIBILITY_PUBLIC || $release->status !== GroupRelease::STATUS_SCHEDULED || $release->planned_release_at === null) {
return;
}
$plannedReleaseAt = $release->planned_release_at->toISOString();
if ($previousStatus === GroupRelease::STATUS_SCHEDULED && $previousPlannedReleaseAt === $plannedReleaseAt) {
return;
}
foreach ($release->group->follows()->with('user.profile')->get() as $follow) {
if ($follow->user) {
$this->notifications->notifyGroupReleaseScheduled($follow->user, $actor, $release->group, $release);
}
}
}
private function notifyMilestoneDueSoonIfNeeded(User $recipient, User $actor, Group $group, string $contextType, string $contextTitle, string $milestoneTitle, ?string $dueDate, string $url): void
{
if ($dueDate === null) {
return;
}
$date = now()->parse($dueDate);
if (! $date->betweenIncluded(now()->startOfDay(), now()->copy()->addDays(3)->endOfDay())) {
return;
}
$this->notifications->notifyGroupMilestoneDueSoon($recipient, $actor, $group, $contextType, $contextTitle, $milestoneTitle, $date->toDateString(), $url);
}
private function shouldNotifyDueSoon(mixed $beforeDueDate, mixed $afterDueDate, mixed $beforeStatus, string $afterStatus, int $previousOwnerId, int $currentOwnerId): bool
{
if ($currentOwnerId <= 0 || ! in_array($afterStatus, [GroupReleaseMilestone::STATUS_PENDING, GroupReleaseMilestone::STATUS_ACTIVE], true) || $afterDueDate === null) {
return false;
}
$beforeNormalized = $beforeDueDate ? now()->parse((string) $beforeDueDate)->toDateString() : null;
$afterNormalized = now()->parse((string) $afterDueDate)->toDateString();
return $previousOwnerId !== $currentOwnerId || $beforeNormalized !== $afterNormalized || (string) $beforeStatus !== $afterStatus;
}
}

View File

@@ -0,0 +1,568 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupBadge;
use App\Models\GroupContributorStat;
use App\Models\GroupMember;
use App\Models\GroupMemberBadge;
use App\Models\GroupProject;
use App\Models\GroupRelease;
use App\Models\GroupReleaseContributor;
use App\Models\User;
use App\Support\AvatarUrl;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
class GroupReputationService
{
public function __construct(
private readonly NotificationService $notifications,
) {
}
public function refreshGroup(Group $group): void
{
$userIds = $this->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;
}
}

View File

@@ -0,0 +1,834 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\Group;
use App\Models\User;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\UploadedFile;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class GroupService
{
public function __construct(
private readonly GroupMembershipService $memberships,
private readonly GroupCardService $cards,
private readonly CollectionService $collections,
private readonly GroupMediaService $media,
private readonly GroupJoinRequestService $joinRequests,
private readonly GroupRecruitmentService $recruitment,
private readonly GroupPostService $posts,
private readonly GroupProjectService $projects,
private readonly GroupReleaseService $releases,
private readonly GroupChallengeService $challenges,
private readonly GroupEventService $events,
private readonly GroupAssetService $assets,
private readonly GroupActivityService $activity,
private readonly GroupHistoryService $history,
private readonly GroupReputationService $reputation,
) {
}
public function makeUniqueSlug(string $source, ?int $ignoreGroupId = null): string
{
$base = Str::slug(Str::limit($source, 90, ''));
$base = $base !== '' ? $base : 'group';
$slug = $base;
$suffix = 2;
while (Group::query()
->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;
}
}

View File

@@ -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', [

View File

@@ -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();
}
}

View File

@@ -0,0 +1,802 @@
<?php
declare(strict_types=1);
namespace App\Services\News;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\Group;
use App\Models\GroupChallenge;
use App\Models\GroupEvent;
use App\Models\GroupProject;
use App\Models\GroupRelease;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use cPad\Plugins\News\Models\NewsArticle;
use cPad\Plugins\News\Models\NewsArticleRelation;
use cPad\Plugins\News\Models\NewsCategory;
use cPad\Plugins\News\Models\NewsTag;
final class NewsService
{
public const RELATION_GROUP = 'group';
public const RELATION_ARTWORK = 'artwork';
public const RELATION_COLLECTION = 'collection';
public const RELATION_RELEASE = 'release';
public const RELATION_PROJECT = 'project';
public const RELATION_CHALLENGE = 'challenge';
public const RELATION_EVENT = 'event';
public const RELATION_USER = 'user';
public const RELATION_LABELS = [
self::RELATION_GROUP => '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' => [],
];
}
}

View File

@@ -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) {

View File

@@ -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());
}
}

View File

@@ -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'));