Save workspace changes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user