Files
takeone-youtube-clone/app/Http/Controllers/CommentController.php
T
ghassanandClaude Opus 4.7 c9123bc7b8 feat(comments): 60s dedupe window + idempotency key
- Server window raised from 5s to 60s so slow double-fires (observed
  on /videos/CRKAf: same body saved 3-7 times, seconds to minutes
  apart) collapse into one comment.
- X-Idempotency-Key honored: the client sends a per-attempt UUID; the
  server caches the resulting comment id for 5 minutes under that key.
  A retry with the same key returns the first-written comment without
  touching the DB a second time.
- Comment and reply submit JS both send the key via fetch().

The existing _busy flag + disabled submit button already covers the
fast double-click case on the client; this hardens the server side so
duplicates can't slip through even when the client guard is bypassed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:38:03 +03:00

210 lines
7.3 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Comment;
use App\Models\CommentLike;
use App\Models\Video;
use App\Notifications\NewCommentLikeNotification;
use App\Notifications\NewCommentNotification;
use App\Notifications\NewReplyNotification;
use App\Services\LlmLyricsService;
use App\Support\SafeNotifier;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class CommentController extends Controller
{
public function __construct()
{
$this->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]);
}
}