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