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; } }