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