middleware('auth')->except(['index', 'translate']); } public function index(Video $video) { $comments = $video->comments()->whereNull('parent_id')->with(['user', 'replies.user'])->get(); return response()->json($comments); } public function store(Request $request, Video $video) { $request->validate([ 'body' => 'required|string|max:1000', 'parent_id' => 'nullable|exists:comments,id', ]); $commenter = Auth::user(); // Idempotency guard: a client that retries (duplicate listeners after // an SPA swap, fast double-click, flaky connection) can POST the same // comment twice. We dedupe on two signals: // // 1. X-Idempotency-Key — the fetch() client sends a per-attempt // UUID; the browser's own retry logic reuses the same value. // This catches the deliberate-retry case before body comparison. // // 2. Identical (user, video, parent, body) within 60s — covers // the duplicate-listener case where two independent fetch()es // fire with different idempotency keys for the same user intent. // // Either match returns the first-written comment unchanged. $idemKey = (string) $request->header('X-Idempotency-Key', ''); $cacheKey = $idemKey !== '' ? 'comment-idem:' . $commenter->id . ':' . $video->id . ':' . substr(hash('sha256', $idemKey), 0, 40) : null; if ($cacheKey) { $existingId = \Illuminate\Support\Facades\Cache::get($cacheKey); if ($existingId && $existing = Comment::find($existingId)) { return response()->json([ 'success' => true, 'comment' => $existing->load('user:id,name,avatar'), ]); } } $existing = $video->comments() ->where('user_id', $commenter->id) ->where('body', $request->body) ->where(function ($q) use ($request) { $request->parent_id ? $q->where('parent_id', $request->parent_id) : $q->whereNull('parent_id'); }) ->where('created_at', '>=', now()->subSeconds(60)) ->latest('id') ->first(); if ($existing) { if ($cacheKey) { \Illuminate\Support\Facades\Cache::put($cacheKey, $existing->id, now()->addMinutes(5)); } return response()->json([ 'success' => true, 'comment' => $existing->load('user:id,name,avatar'), ]); } $comment = $video->comments()->create([ 'user_id' => $commenter->id, 'body' => $request->body, 'parent_id' => $request->parent_id, ]); if ($cacheKey) { \Illuminate\Support\Facades\Cache::put($cacheKey, $comment->id, now()->addMinutes(5)); } $comment->load('user:id,name,avatar'); // Fire notifications (never notify yourself) if ($request->parent_id) { // Reply — notify the parent comment's author $parent = Comment::find($request->parent_id); if ($parent && $parent->user_id !== $commenter->id) { SafeNotifier::send($parent->user, new NewReplyNotification($video, $comment, $commenter)); } } else { // Top-level comment — notify the video owner if ($video->user_id !== $commenter->id) { SafeNotifier::send($video->user, new NewCommentNotification($video, $comment, $commenter)); } } return response()->json([ 'success' => true, 'comment' => $comment->load('user:id,name,avatar'), ]); } public function update(Request $request, Comment $comment) { if ($comment->user_id !== Auth::id()) { return response()->json(['error' => 'Unauthorized'], 403); } $request->validate([ 'body' => 'required|string|max:1000', ]); $comment->update(['body' => $request->body]); $comment->load('user:id,name,avatar'); return response()->json($comment->load('user:id,name,avatar')); } public function destroy(Comment $comment) { if ($comment->user_id !== Auth::id()) { return response()->json(['error' => 'Unauthorized'], 403); } $comment->delete(); return response()->json(['success' => true]); } public function translate(Request $request, Comment $comment, LlmLyricsService $llm) { if (! $llm->isEnabled()) { return response()->json(['error' => 'Translation is not configured'], 503); } $target = (string) $request->input('target', 'en'); // Derive a source-language hint from the comment author's most-frequent // access country (video_views + profile_visits, falling back to their // nationality setting). Never trust the client for this — it's used // to disambiguate scripts the LLM might otherwise misidentify. $topCountries = \App\Models\User::topCountriesFor([$comment->user_id]); $sourceCountry = $topCountries[$comment->user_id] ?? null; $sourceLang = \App\Data\CountryLanguages::primary($sourceCountry); $translated = $llm->translate((string) $comment->body, $target, $sourceLang); if ($translated === '') { return response()->json(['error' => 'Translation failed'], 502); } return response()->json([ 'success' => true, 'translated' => $translated, 'target' => $target, 'source_hint' => $sourceLang, 'author_country'=> $sourceCountry, ]); } public function like(Comment $comment) { $userId = Auth::id(); $existing = CommentLike::where('comment_id', $comment->id) ->where('user_id', $userId) ->first(); if ($existing) { $existing->delete(); $liked = false; } else { CommentLike::create(['comment_id' => $comment->id, 'user_id' => $userId]); $liked = true; // Notify the comment author (not self-likes) if ($comment->user_id !== $userId) { $video = $comment->video; if ($video) { SafeNotifier::send( $comment->user, new NewCommentLikeNotification($video, $comment, Auth::user()) ); } } } $count = CommentLike::where('comment_id', $comment->id)->count(); return response()->json(['liked' => $liked, 'count' => $count]); } }