359 lines
12 KiB
PHP
359 lines
12 KiB
PHP
<?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;
|
|
}
|
|
} |