379 lines
16 KiB
PHP
379 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Studio;
|
|
|
|
use App\Models\Artwork;
|
|
use App\Models\ArtworkComment;
|
|
use App\Models\Collection;
|
|
use App\Models\CollectionComment;
|
|
use App\Models\NovaCard;
|
|
use App\Models\NovaCardComment;
|
|
use App\Models\Report;
|
|
use App\Models\Story;
|
|
use App\Models\StoryComment;
|
|
use App\Models\User;
|
|
use App\Services\CollectionCommentService;
|
|
use App\Services\NovaCards\NovaCardCommentService;
|
|
use App\Services\SocialService;
|
|
use App\Support\AvatarUrl;
|
|
use App\Support\ContentSanitizer;
|
|
use App\Support\Moderation\ReportTargetResolver;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
final class CreatorStudioCommentService
|
|
{
|
|
public function __construct(
|
|
private readonly NovaCardCommentService $cardComments,
|
|
private readonly CollectionCommentService $collectionComments,
|
|
private readonly SocialService $social,
|
|
private readonly ReportTargetResolver $reports,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
*/
|
|
public function list(User $user, array $filters = []): array
|
|
{
|
|
$module = $this->normalizeModule((string) ($filters['module'] ?? 'all'));
|
|
$query = trim((string) ($filters['q'] ?? ''));
|
|
$page = max(1, (int) ($filters['page'] ?? 1));
|
|
$perPage = min(max((int) ($filters['per_page'] ?? 18), 12), 36);
|
|
|
|
$items = $this->artworkComments($user)
|
|
->concat($this->cardComments($user))
|
|
->concat($this->collectionComments($user))
|
|
->concat($this->storyComments($user))
|
|
->sortByDesc(fn (array $item): int => strtotime((string) ($item['created_at'] ?? '')) ?: 0)
|
|
->values();
|
|
|
|
if ($module !== 'all') {
|
|
$items = $items->where('module', $module)->values();
|
|
}
|
|
|
|
if ($query !== '') {
|
|
$needle = mb_strtolower($query);
|
|
$items = $items->filter(function (array $item) use ($needle): bool {
|
|
return collect([
|
|
$item['author_name'] ?? '',
|
|
$item['item_title'] ?? '',
|
|
$item['body'] ?? '',
|
|
$item['module_label'] ?? '',
|
|
])->contains(fn ($value): bool => is_string($value) && str_contains(mb_strtolower($value), $needle));
|
|
})->values();
|
|
}
|
|
|
|
$total = $items->count();
|
|
$lastPage = max(1, (int) ceil($total / $perPage));
|
|
$page = min($page, $lastPage);
|
|
|
|
return [
|
|
'items' => $items->forPage($page, $perPage)->values()->all(),
|
|
'meta' => [
|
|
'current_page' => $page,
|
|
'last_page' => $lastPage,
|
|
'per_page' => $perPage,
|
|
'total' => $total,
|
|
],
|
|
'filters' => [
|
|
'module' => $module,
|
|
'q' => $query,
|
|
],
|
|
'module_options' => [
|
|
['value' => 'all', 'label' => 'All comments'],
|
|
['value' => 'artworks', 'label' => 'Artworks'],
|
|
['value' => 'cards', 'label' => 'Cards'],
|
|
['value' => 'collections', 'label' => 'Collections'],
|
|
['value' => 'stories', 'label' => 'Stories'],
|
|
],
|
|
];
|
|
}
|
|
|
|
public function reply(User $user, string $module, int $commentId, string $content): void
|
|
{
|
|
$trimmed = trim($content);
|
|
abort_if($trimmed === '' || mb_strlen($trimmed) > 10000, 422, 'Reply content is invalid.');
|
|
|
|
match ($this->normalizeModule($module)) {
|
|
'artworks' => $this->replyToArtworkComment($user, $commentId, $trimmed),
|
|
'cards' => $this->replyToCardComment($user, $commentId, $trimmed),
|
|
'collections' => $this->replyToCollectionComment($user, $commentId, $trimmed),
|
|
'stories' => $this->replyToStoryComment($user, $commentId, $trimmed),
|
|
default => abort(404),
|
|
};
|
|
}
|
|
|
|
public function moderate(User $user, string $module, int $commentId): void
|
|
{
|
|
match ($this->normalizeModule($module)) {
|
|
'artworks' => $this->deleteArtworkComment($user, $commentId),
|
|
'cards' => $this->deleteCardComment($user, $commentId),
|
|
'collections' => $this->deleteCollectionComment($user, $commentId),
|
|
'stories' => $this->deleteStoryComment($user, $commentId),
|
|
default => abort(404),
|
|
};
|
|
}
|
|
|
|
public function report(User $user, string $module, int $commentId, string $reason, ?string $details = null): array
|
|
{
|
|
$targetType = match ($this->normalizeModule($module)) {
|
|
'artworks' => 'artwork_comment',
|
|
'cards' => 'nova_card_comment',
|
|
'collections' => 'collection_comment',
|
|
'stories' => 'story_comment',
|
|
default => abort(404),
|
|
};
|
|
|
|
$this->reports->validateForReporter($user, $targetType, $commentId);
|
|
|
|
$report = Report::query()->create([
|
|
'reporter_id' => $user->id,
|
|
'target_type' => $targetType,
|
|
'target_id' => $commentId,
|
|
'reason' => trim($reason),
|
|
'details' => $details ? trim($details) : null,
|
|
'status' => 'open',
|
|
]);
|
|
|
|
return [
|
|
'id' => (int) $report->id,
|
|
'status' => (string) $report->status,
|
|
];
|
|
}
|
|
|
|
private function artworkComments(User $user)
|
|
{
|
|
return ArtworkComment::query()
|
|
->with(['user.profile', 'artwork'])
|
|
->whereNull('deleted_at')
|
|
->whereHas('artwork', fn ($query) => $query->where('user_id', $user->id))
|
|
->latest('created_at')
|
|
->limit(120)
|
|
->get()
|
|
->map(fn (ArtworkComment $comment): array => [
|
|
'id' => 'artworks:' . $comment->id,
|
|
'comment_id' => (int) $comment->id,
|
|
'module' => 'artworks',
|
|
'module_label' => 'Artworks',
|
|
'target_type' => 'artwork_comment',
|
|
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
|
'author_username' => $comment->user?->username,
|
|
'author_avatar_url' => AvatarUrl::forUser((int) ($comment->user?->id ?? 0), $comment->user?->profile?->avatar_hash, 64),
|
|
'item_title' => $comment->artwork?->title,
|
|
'item_id' => (int) ($comment->artwork?->id ?? 0),
|
|
'body' => (string) ($comment->raw_content ?: $comment->content),
|
|
'created_at' => $comment->created_at?->toIso8601String(),
|
|
'time_ago' => $comment->created_at?->diffForHumans(),
|
|
'context_url' => $comment->artwork
|
|
? route('art.show', ['id' => $comment->artwork->id, 'slug' => $comment->artwork->slug]) . '#comment-' . $comment->id
|
|
: route('studio.comments'),
|
|
'preview_url' => $comment->artwork
|
|
? route('art.show', ['id' => $comment->artwork->id, 'slug' => $comment->artwork->slug])
|
|
: null,
|
|
'reply_supported' => true,
|
|
'moderate_supported' => true,
|
|
'report_supported' => true,
|
|
]);
|
|
}
|
|
|
|
private function cardComments(User $user)
|
|
{
|
|
return NovaCardComment::query()
|
|
->with(['user.profile', 'card'])
|
|
->whereNull('deleted_at')
|
|
->whereHas('card', fn ($query) => $query->where('user_id', $user->id))
|
|
->latest('created_at')
|
|
->limit(120)
|
|
->get()
|
|
->map(fn (NovaCardComment $comment): array => [
|
|
'id' => 'cards:' . $comment->id,
|
|
'comment_id' => (int) $comment->id,
|
|
'module' => 'cards',
|
|
'module_label' => 'Cards',
|
|
'target_type' => 'nova_card_comment',
|
|
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
|
'author_username' => $comment->user?->username,
|
|
'author_avatar_url' => AvatarUrl::forUser((int) ($comment->user?->id ?? 0), $comment->user?->profile?->avatar_hash, 64),
|
|
'item_title' => $comment->card?->title,
|
|
'item_id' => (int) ($comment->card?->id ?? 0),
|
|
'body' => (string) $comment->body,
|
|
'created_at' => $comment->created_at?->toIso8601String(),
|
|
'time_ago' => $comment->created_at?->diffForHumans(),
|
|
'context_url' => $comment->card ? $comment->card->publicUrl() . '#comment-' . $comment->id : route('studio.comments'),
|
|
'preview_url' => $comment->card ? route('studio.cards.preview', ['id' => $comment->card->id]) : null,
|
|
'reply_supported' => true,
|
|
'moderate_supported' => true,
|
|
'report_supported' => true,
|
|
]);
|
|
}
|
|
|
|
private function collectionComments(User $user)
|
|
{
|
|
return CollectionComment::query()
|
|
->with(['user.profile', 'collection'])
|
|
->whereNull('deleted_at')
|
|
->whereHas('collection', fn ($query) => $query->where('user_id', $user->id))
|
|
->latest('created_at')
|
|
->limit(120)
|
|
->get()
|
|
->map(fn (CollectionComment $comment): array => [
|
|
'id' => 'collections:' . $comment->id,
|
|
'comment_id' => (int) $comment->id,
|
|
'module' => 'collections',
|
|
'module_label' => 'Collections',
|
|
'target_type' => 'collection_comment',
|
|
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
|
'author_username' => $comment->user?->username,
|
|
'author_avatar_url' => AvatarUrl::forUser((int) ($comment->user?->id ?? 0), $comment->user?->profile?->avatar_hash, 64),
|
|
'item_title' => $comment->collection?->title,
|
|
'item_id' => (int) ($comment->collection?->id ?? 0),
|
|
'body' => (string) $comment->body,
|
|
'created_at' => $comment->created_at?->toIso8601String(),
|
|
'time_ago' => $comment->created_at?->diffForHumans(),
|
|
'context_url' => $comment->collection
|
|
? route('profile.collections.show', ['username' => strtolower((string) $user->username), 'slug' => $comment->collection->slug]) . '#comment-' . $comment->id
|
|
: route('studio.comments'),
|
|
'preview_url' => $comment->collection
|
|
? route('profile.collections.show', ['username' => strtolower((string) $user->username), 'slug' => $comment->collection->slug])
|
|
: null,
|
|
'reply_supported' => true,
|
|
'moderate_supported' => true,
|
|
'report_supported' => true,
|
|
]);
|
|
}
|
|
|
|
private function storyComments(User $user)
|
|
{
|
|
return StoryComment::query()
|
|
->with(['user.profile', 'story'])
|
|
->whereNull('deleted_at')
|
|
->whereHas('story', fn ($query) => $query->where('creator_id', $user->id))
|
|
->latest('created_at')
|
|
->limit(120)
|
|
->get()
|
|
->map(fn (StoryComment $comment): array => [
|
|
'id' => 'stories:' . $comment->id,
|
|
'comment_id' => (int) $comment->id,
|
|
'module' => 'stories',
|
|
'module_label' => 'Stories',
|
|
'target_type' => 'story_comment',
|
|
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
|
'author_username' => $comment->user?->username,
|
|
'author_avatar_url' => AvatarUrl::forUser((int) ($comment->user?->id ?? 0), $comment->user?->profile?->avatar_hash, 64),
|
|
'item_title' => $comment->story?->title,
|
|
'item_id' => (int) ($comment->story?->id ?? 0),
|
|
'body' => (string) ($comment->raw_content ?: $comment->content),
|
|
'created_at' => $comment->created_at?->toIso8601String(),
|
|
'time_ago' => $comment->created_at?->diffForHumans(),
|
|
'context_url' => $comment->story ? route('stories.show', ['slug' => $comment->story->slug]) . '#comment-' . $comment->id : route('studio.comments'),
|
|
'preview_url' => $comment->story ? route('creator.stories.preview', ['story' => $comment->story->id]) : null,
|
|
'reply_supported' => true,
|
|
'moderate_supported' => true,
|
|
'report_supported' => true,
|
|
]);
|
|
}
|
|
|
|
private function replyToArtworkComment(User $user, int $commentId, string $content): void
|
|
{
|
|
$comment = ArtworkComment::query()
|
|
->with('artwork')
|
|
->findOrFail($commentId);
|
|
|
|
abort_unless((int) ($comment->artwork?->user_id ?? 0) === (int) $user->id, 403);
|
|
|
|
$errors = ContentSanitizer::validate($content);
|
|
if ($errors !== []) {
|
|
abort(422, implode(' ', $errors));
|
|
}
|
|
|
|
ArtworkComment::query()->create([
|
|
'artwork_id' => $comment->artwork_id,
|
|
'user_id' => $user->id,
|
|
'parent_id' => $comment->id,
|
|
'content' => $content,
|
|
'raw_content' => $content,
|
|
'rendered_content' => ContentSanitizer::render($content),
|
|
'is_approved' => true,
|
|
]);
|
|
|
|
$this->syncArtworkCommentCount((int) $comment->artwork_id);
|
|
}
|
|
|
|
private function replyToCardComment(User $user, int $commentId, string $content): void
|
|
{
|
|
$comment = NovaCardComment::query()->with(['card.user'])->findOrFail($commentId);
|
|
abort_unless((int) ($comment->card?->user_id ?? 0) === (int) $user->id, 403);
|
|
$this->cardComments->create($comment->card->loadMissing('user'), $user, $content, $comment);
|
|
}
|
|
|
|
private function replyToCollectionComment(User $user, int $commentId, string $content): void
|
|
{
|
|
$comment = CollectionComment::query()->with(['collection.user'])->findOrFail($commentId);
|
|
abort_unless((int) ($comment->collection?->user_id ?? 0) === (int) $user->id, 403);
|
|
$this->collectionComments->create($comment->collection->loadMissing('user'), $user, $content, $comment);
|
|
}
|
|
|
|
private function replyToStoryComment(User $user, int $commentId, string $content): void
|
|
{
|
|
$comment = StoryComment::query()->with('story')->findOrFail($commentId);
|
|
abort_unless((int) ($comment->story?->creator_id ?? 0) === (int) $user->id, 403);
|
|
$this->social->addStoryComment($user, $comment->story, $content, $comment->id);
|
|
}
|
|
|
|
private function deleteArtworkComment(User $user, int $commentId): void
|
|
{
|
|
$comment = ArtworkComment::query()->with('artwork')->findOrFail($commentId);
|
|
abort_unless((int) ($comment->artwork?->user_id ?? 0) === (int) $user->id, 403);
|
|
|
|
if (! $comment->trashed()) {
|
|
$comment->delete();
|
|
}
|
|
|
|
$this->syncArtworkCommentCount((int) $comment->artwork_id);
|
|
}
|
|
|
|
private function deleteCardComment(User $user, int $commentId): void
|
|
{
|
|
$comment = NovaCardComment::query()->with('card')->findOrFail($commentId);
|
|
abort_unless((int) ($comment->card?->user_id ?? 0) === (int) $user->id, 403);
|
|
$this->cardComments->delete($comment, $user);
|
|
}
|
|
|
|
private function deleteCollectionComment(User $user, int $commentId): void
|
|
{
|
|
$comment = CollectionComment::query()->with('collection')->findOrFail($commentId);
|
|
abort_unless((int) ($comment->collection?->user_id ?? 0) === (int) $user->id, 403);
|
|
$this->collectionComments->delete($comment, $user);
|
|
}
|
|
|
|
private function deleteStoryComment(User $user, int $commentId): void
|
|
{
|
|
$comment = StoryComment::query()->with('story')->findOrFail($commentId);
|
|
abort_unless((int) ($comment->story?->creator_id ?? 0) === (int) $user->id, 403);
|
|
$this->social->deleteStoryComment($user, $comment);
|
|
}
|
|
|
|
private function syncArtworkCommentCount(int $artworkId): void
|
|
{
|
|
$count = ArtworkComment::query()
|
|
->where('artwork_id', $artworkId)
|
|
->whereNull('deleted_at')
|
|
->count();
|
|
|
|
if (DB::table('artwork_stats')->where('artwork_id', $artworkId)->exists()) {
|
|
DB::table('artwork_stats')
|
|
->where('artwork_id', $artworkId)
|
|
->update(['comments_count' => $count, 'updated_at' => now()]);
|
|
}
|
|
}
|
|
|
|
private function normalizeModule(string $module): string
|
|
{
|
|
return in_array($module, ['all', 'artworks', 'cards', 'collections', 'stories'], true)
|
|
? $module
|
|
: 'all';
|
|
}
|
|
} |