306 lines
13 KiB
PHP
306 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Services\Activity\UserActivityService;
|
|
use App\Models\Artwork;
|
|
use App\Models\ArtworkComment;
|
|
use App\Models\User;
|
|
use App\Models\UserMention;
|
|
use App\Notifications\ArtworkCommentedNotification;
|
|
use App\Notifications\ArtworkMentionedNotification;
|
|
use App\Services\ContentSanitizer;
|
|
use App\Support\AvatarUrl;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Gate;
|
|
|
|
/**
|
|
* Artwork comment CRUD.
|
|
*
|
|
* POST /api/artworks/{artworkId}/comments → store
|
|
* PUT /api/artworks/{artworkId}/comments/{id} → update (own comment)
|
|
* DELETE /api/artworks/{artworkId}/comments/{id} → delete (own or admin)
|
|
* GET /api/artworks/{artworkId}/comments → list (paginated)
|
|
*/
|
|
class ArtworkCommentController extends Controller
|
|
{
|
|
private const MAX_LENGTH = 10_000;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// List
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
public function index(Request $request, int $artworkId): JsonResponse
|
|
{
|
|
$artwork = Artwork::public()->published()->findOrFail($artworkId);
|
|
|
|
$page = max(1, (int) $request->query('page', 1));
|
|
$perPage = 20;
|
|
|
|
// Only fetch top-level comments (no parent). Replies are recursively eager-loaded.
|
|
$comments = ArtworkComment::with([
|
|
'user', 'user.profile',
|
|
'approvedReplies',
|
|
])
|
|
->where('artwork_id', $artwork->id)
|
|
->where('is_approved', true)
|
|
->whereNull('parent_id')
|
|
->orderByDesc('created_at')
|
|
->paginate($perPage, ['*'], 'page', $page);
|
|
|
|
$userId = $request->user()?->id;
|
|
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true));
|
|
|
|
return response()->json([
|
|
'data' => $items,
|
|
'meta' => [
|
|
'current_page' => $comments->currentPage(),
|
|
'last_page' => $comments->lastPage(),
|
|
'total' => $comments->total(),
|
|
'per_page' => $comments->perPage(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Store
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
public function store(Request $request, int $artworkId): JsonResponse
|
|
{
|
|
$artwork = Artwork::public()->published()->findOrFail($artworkId);
|
|
|
|
$request->validate([
|
|
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
|
|
'parent_id' => ['nullable', 'integer', 'exists:artwork_comments,id'],
|
|
]);
|
|
|
|
$raw = $request->input('content');
|
|
$parentId = $request->input('parent_id');
|
|
|
|
// If replying, validate parent belongs to same artwork and is approved
|
|
if ($parentId) {
|
|
$parent = ArtworkComment::where('artwork_id', $artwork->id)
|
|
->where('is_approved', true)
|
|
->find($parentId);
|
|
|
|
if (! $parent) {
|
|
return response()->json([
|
|
'errors' => ['parent_id' => ['The comment you are replying to is no longer available.']],
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
// Validate markdown-lite content
|
|
$errors = ContentSanitizer::validate($raw);
|
|
if ($errors) {
|
|
return response()->json(['errors' => ['content' => $errors]], 422);
|
|
}
|
|
|
|
$rendered = ContentSanitizer::render($raw);
|
|
|
|
$comment = ArtworkComment::create([
|
|
'artwork_id' => $artwork->id,
|
|
'user_id' => $request->user()->id,
|
|
'parent_id' => $parentId,
|
|
'content' => $raw, // legacy column (plain text fallback)
|
|
'raw_content' => $raw,
|
|
'rendered_content' => $rendered,
|
|
'is_approved' => true, // auto-approve; extend with moderation as needed
|
|
]);
|
|
|
|
// Bust the comments cache for this user's 'all' feed
|
|
Cache::forget('comments.latest.all.page1');
|
|
|
|
$comment->load(['user', 'user.profile']);
|
|
$this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null);
|
|
|
|
// Record activity event (fire-and-forget; never break the response)
|
|
try {
|
|
\App\Models\ActivityEvent::record(
|
|
actorId: $request->user()->id,
|
|
type: \App\Models\ActivityEvent::TYPE_COMMENT,
|
|
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
|
targetId: $artwork->id,
|
|
);
|
|
} catch (\Throwable) {}
|
|
|
|
try {
|
|
app(UserActivityService::class)->logComment(
|
|
(int) $request->user()->id,
|
|
(int) $comment->id,
|
|
$parentId !== null,
|
|
['artwork_id' => (int) $artwork->id],
|
|
);
|
|
} catch (\Throwable) {}
|
|
|
|
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)], 201);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Update
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
public function update(Request $request, int $artworkId, int $commentId): JsonResponse
|
|
{
|
|
$comment = ArtworkComment::where('artwork_id', $artworkId)
|
|
->findOrFail($commentId);
|
|
|
|
Gate::authorize('update', $comment);
|
|
|
|
$request->validate([
|
|
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
|
|
]);
|
|
|
|
$raw = $request->input('content');
|
|
$errors = ContentSanitizer::validate($raw);
|
|
if ($errors) {
|
|
return response()->json(['errors' => ['content' => $errors]], 422);
|
|
}
|
|
|
|
$rendered = ContentSanitizer::render($raw);
|
|
|
|
$comment->update([
|
|
'content' => $raw,
|
|
'raw_content' => $raw,
|
|
'rendered_content' => $rendered,
|
|
]);
|
|
|
|
Cache::forget('comments.latest.all.page1');
|
|
|
|
$comment->load(['user', 'user.profile']);
|
|
|
|
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)]);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Delete
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
public function destroy(Request $request, int $artworkId, int $commentId): JsonResponse
|
|
{
|
|
$comment = ArtworkComment::where('artwork_id', $artworkId)->findOrFail($commentId);
|
|
|
|
Gate::authorize('delete', $comment);
|
|
|
|
$comment->delete();
|
|
Cache::forget('comments.latest.all.page1');
|
|
|
|
return response()->json(['message' => 'Comment deleted.'], 200);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false): array
|
|
{
|
|
$user = $c->user;
|
|
$userId = (int) ($c->user_id ?? 0);
|
|
$avatarHash = $user?->profile?->avatar_hash ?? null;
|
|
|
|
$data = [
|
|
'id' => $c->id,
|
|
'parent_id' => $c->parent_id,
|
|
'raw_content' => $c->raw_content ?? $c->content,
|
|
'rendered_content' => $this->renderCommentContent($c),
|
|
'created_at' => $c->created_at?->toIso8601String(),
|
|
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
|
'can_edit' => $currentUserId === $userId,
|
|
'can_delete' => $currentUserId === $userId,
|
|
'user' => [
|
|
'id' => $userId,
|
|
'username' => $user?->username,
|
|
'display' => $user?->username ?? $user?->name ?? 'User',
|
|
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
|
|
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
|
'level' => (int) ($user?->level ?? 1),
|
|
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
|
],
|
|
];
|
|
|
|
if ($includeReplies && $c->relationLoaded('approvedReplies')) {
|
|
$data['replies'] = $c->approvedReplies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
|
|
} elseif ($includeReplies && $c->relationLoaded('replies')) {
|
|
$data['replies'] = $c->replies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
|
|
} else {
|
|
$data['replies'] = [];
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
private function renderCommentContent(ArtworkComment $comment): string
|
|
{
|
|
$rawContent = (string) ($comment->raw_content ?? $comment->content ?? '');
|
|
$renderedContent = $comment->rendered_content;
|
|
|
|
if (! is_string($renderedContent) || trim($renderedContent) === '') {
|
|
$renderedContent = $rawContent !== ''
|
|
? ContentSanitizer::render($rawContent)
|
|
: nl2br(e(strip_tags((string) ($comment->content ?? ''))));
|
|
}
|
|
|
|
return ContentSanitizer::sanitizeRenderedHtml(
|
|
$renderedContent,
|
|
$this->commentAuthorCanPublishLinks($comment)
|
|
);
|
|
}
|
|
|
|
private function commentAuthorCanPublishLinks(ArtworkComment $comment): bool
|
|
{
|
|
$level = (int) ($comment->user?->level ?? 1);
|
|
$rank = strtolower((string) ($comment->user?->rank ?? 'Newbie'));
|
|
|
|
return $level > 1 && $rank !== 'newbie';
|
|
}
|
|
|
|
private function notifyRecipients(Artwork $artwork, ArtworkComment $comment, User $actor, ?int $parentId): void
|
|
{
|
|
$notifiedUserIds = [];
|
|
$creatorId = (int) ($artwork->user_id ?? 0);
|
|
|
|
if ($creatorId > 0 && $creatorId !== (int) $actor->id) {
|
|
$creator = User::query()->find($creatorId);
|
|
if ($creator) {
|
|
$creator->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
|
|
$notifiedUserIds[] = (int) $creator->id;
|
|
}
|
|
}
|
|
|
|
if ($parentId) {
|
|
$parentUserId = (int) (ArtworkComment::query()->whereKey($parentId)->value('user_id') ?? 0);
|
|
if ($parentUserId > 0 && $parentUserId !== (int) $actor->id && ! in_array($parentUserId, $notifiedUserIds, true)) {
|
|
$parentUser = User::query()->find($parentUserId);
|
|
if ($parentUser) {
|
|
$parentUser->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
|
|
$notifiedUserIds[] = (int) $parentUser->id;
|
|
}
|
|
}
|
|
}
|
|
|
|
User::query()
|
|
->whereIn(
|
|
'id',
|
|
UserMention::query()
|
|
->where('comment_id', (int) $comment->id)
|
|
->pluck('mentioned_user_id')
|
|
->map(fn ($id) => (int) $id)
|
|
->unique()
|
|
->all()
|
|
)
|
|
->get()
|
|
->each(function (User $mentionedUser) use ($artwork, $comment, $actor): void {
|
|
if ((int) $mentionedUser->id === (int) $actor->id) {
|
|
return;
|
|
}
|
|
|
|
$mentionedUser->notify(new ArtworkMentionedNotification($artwork, $comment, $actor));
|
|
});
|
|
}
|
|
}
|