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