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