Add news article comments and reactions
This commit is contained in:
80
app/Services/News/NewsArticleCommentService.php
Normal file
80
app/Services/News/NewsArticleCommentService.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\News;
|
||||
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Models\NewsArticleComment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
|
||||
final class NewsArticleCommentService
|
||||
{
|
||||
public function create(NewsArticle $article, User $actor, string $body, ?NewsArticleComment $parent = null): NewsArticleComment
|
||||
{
|
||||
if (! $article->commentsAreEnabled()) {
|
||||
throw ValidationException::withMessages([
|
||||
'body' => 'Comments are disabled for this article.',
|
||||
]);
|
||||
}
|
||||
|
||||
$trimmedBody = trim($body);
|
||||
$errors = ContentSanitizer::validate($trimmedBody);
|
||||
|
||||
if ($errors !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'body' => $errors,
|
||||
]);
|
||||
}
|
||||
|
||||
$comment = NewsArticleComment::query()->create([
|
||||
'article_id' => (int) $article->id,
|
||||
'user_id' => (int) $actor->id,
|
||||
'parent_id' => $parent?->id,
|
||||
'author_name' => trim((string) ($actor->name ?: $actor->username)),
|
||||
'body' => $trimmedBody,
|
||||
'rendered_body' => ContentSanitizer::sanitizeRenderedHtml(
|
||||
ContentSanitizer::render($trimmedBody),
|
||||
$this->actorCanPublishLinks($actor)
|
||||
),
|
||||
'status' => 'visible',
|
||||
]);
|
||||
|
||||
return $comment->fresh(['user.profile', 'replies.user.profile']);
|
||||
}
|
||||
|
||||
public function delete(NewsArticleComment $comment, User $actor): void
|
||||
{
|
||||
$article = $comment->article()->with('author')->first();
|
||||
|
||||
if (! $article || ! $this->canDelete($comment, $article, $actor)) {
|
||||
throw ValidationException::withMessages([
|
||||
'comment' => 'You are not allowed to remove this comment.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($comment->trashed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$comment->delete();
|
||||
}
|
||||
|
||||
private function canDelete(NewsArticleComment $comment, NewsArticle $article, User $actor): bool
|
||||
{
|
||||
return (int) $comment->user_id === (int) $actor->id
|
||||
|| (int) $article->author_id === (int) $actor->id
|
||||
|| $actor->isAdmin()
|
||||
|| $actor->isModerator();
|
||||
}
|
||||
|
||||
private function actorCanPublishLinks(User $actor): bool
|
||||
{
|
||||
$level = (int) ($actor->level ?? 1);
|
||||
$rank = strtolower((string) ($actor->rank ?? 'Newbie'));
|
||||
|
||||
return $level > 1 && $rank !== 'newbie';
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use App\Support\AvatarUrl;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
use cPad\Plugins\News\Models\NewsArticleRelation;
|
||||
@@ -173,6 +174,7 @@ final class NewsService
|
||||
'published_at' => \optional($article->published_at)?->toIso8601String(),
|
||||
'is_featured' => (bool) $article->is_featured,
|
||||
'is_pinned' => (bool) ($article->is_pinned ?? false),
|
||||
'comments_enabled' => (bool) ($article->comments_enabled ?? false),
|
||||
'category_id' => $article->category_id ? (int) $article->category_id : null,
|
||||
'author_id' => (int) $article->author_id,
|
||||
'author' => $article->author ? $this->mapUserLookupResult($article->author) : null,
|
||||
@@ -209,6 +211,11 @@ final class NewsService
|
||||
return $this->persistArticle($article, $editor, $data);
|
||||
}
|
||||
|
||||
public function deleteArticle(NewsArticle $article): void
|
||||
{
|
||||
$article->delete();
|
||||
}
|
||||
|
||||
public function publish(NewsArticle $article): NewsArticle
|
||||
{
|
||||
$article->forceFill([
|
||||
@@ -313,6 +320,8 @@ final class NewsService
|
||||
$title = 'Untitled News Article';
|
||||
}
|
||||
|
||||
$previousCoverImage = trim((string) ($article->cover_image ?? ''));
|
||||
|
||||
$editorialStatus = $this->normalizeEditorialStatus((string) ($data['editorial_status'] ?? $article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT));
|
||||
$publishedAt = $this->normalizePublishedAt($editorialStatus, $data['published_at'] ?? $article->published_at);
|
||||
$authorId = (int) ($data['author_id'] ?? $article->author_id ?? $editor->id);
|
||||
@@ -331,6 +340,7 @@ final class NewsService
|
||||
'published_at' => $publishedAt,
|
||||
'is_featured' => (bool) ($data['is_featured'] ?? false),
|
||||
'is_pinned' => (bool) ($data['is_pinned'] ?? false),
|
||||
'comments_enabled' => (bool) ($data['comments_enabled'] ?? false),
|
||||
'meta_title' => $this->nullableText($data['meta_title'] ?? null),
|
||||
'meta_description' => $this->nullableText($data['meta_description'] ?? null),
|
||||
'meta_keywords' => $this->nullableText($data['meta_keywords'] ?? null),
|
||||
@@ -340,9 +350,16 @@ final class NewsService
|
||||
'og_image' => $this->nullableText($data['og_image'] ?? null),
|
||||
]);
|
||||
|
||||
$article->save();
|
||||
if (! $article->save()) {
|
||||
throw new \RuntimeException('Failed to save NewsArticle.');
|
||||
}
|
||||
|
||||
$article->tags()->sync(\collect($data['tag_ids'] ?? [])->map(fn (mixed $id): int => (int) $id)->filter()->all());
|
||||
$nextCoverImage = trim((string) ($article->cover_image ?? ''));
|
||||
if ($previousCoverImage !== '' && $previousCoverImage !== $nextCoverImage) {
|
||||
$this->deleteManagedCoverImage($previousCoverImage);
|
||||
}
|
||||
|
||||
$article->tags()->sync($this->resolveArticleTagIds($data));
|
||||
$this->syncRelations($article, $data['relations'] ?? []);
|
||||
|
||||
return $article->fresh(['author.profile', 'category', 'tags', 'relatedEntities']);
|
||||
@@ -365,6 +382,7 @@ final class NewsService
|
||||
'is_pinned' => (bool) ($article->is_pinned ?? false),
|
||||
'views' => (int) $article->views,
|
||||
'edit_url' => route('studio.news.edit', ['article' => $article->id]),
|
||||
'delete_url' => route('studio.news.destroy', ['article' => $article->id]),
|
||||
'preview_url' => route('studio.news.preview', ['article' => $article->id]),
|
||||
'public_url' => route('news.show', ['slug' => $article->slug]),
|
||||
];
|
||||
@@ -437,6 +455,45 @@ final class NewsService
|
||||
return $text === '' ? null : $text;
|
||||
}
|
||||
|
||||
private function resolveArticleTagIds(array $data): array
|
||||
{
|
||||
$existingIds = \collect($data['tag_ids'] ?? [])
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
$createdIds = \collect($data['new_tag_names'] ?? [])
|
||||
->map(fn (mixed $name): string => trim(preg_replace('/\s+/', ' ', (string) $name) ?? ''))
|
||||
->filter()
|
||||
->unique(fn (string $name): string => Str::lower($name))
|
||||
->map(function (string $name): ?int {
|
||||
if (Str::slug($name) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) NewsTag::findOrCreateByName($name)->id;
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
return $existingIds
|
||||
->merge($createdIds)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function deleteManagedCoverImage(string $path): void
|
||||
{
|
||||
$trimmed = ltrim(trim($path), '/');
|
||||
|
||||
if ($trimmed === '' || ! Str::startsWith($trimmed, 'news/covers/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk((string) config('uploads.object_storage.disk', 's3'))->delete($trimmed);
|
||||
}
|
||||
|
||||
private function searchGroups(string $query, ?User $viewer): array
|
||||
{
|
||||
return Group::query()
|
||||
|
||||
Reference in New Issue
Block a user