- 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]>
210 lines
7.3 KiB
PHP
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]);
|
|
}
|
|
}
|