Files
SkinbaseNova/app/Services/GroupPostService.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;
}
}