From c9123bc7b855c50798321cd59c45d91c33818b1f Mon Sep 17 00:00:00 2001 From: ghassan Date: Wed, 21 Oct 2026 00:38:03 +0300 Subject: [PATCH] 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) --- app/Http/Controllers/CommentController.php | 41 ++++++++++++++++--- .../views/components/video-comments.blade.php | 17 +++++++- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php index 3f74b3f..b51ca21 100644 --- a/app/Http/Controllers/CommentController.php +++ b/app/Http/Controllers/CommentController.php @@ -36,10 +36,34 @@ class CommentController extends Controller $commenter = Auth::user(); - // Idempotency guard: a client that retries (duplicate listeners after an - // SPA swap, fast double-click, a flaky connection) can POST the identical - // body twice. Treat an identical comment from the same user to the same - // video within 5 seconds as the same submission and return the first one. + // 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) @@ -48,11 +72,14 @@ class CommentController extends Controller ? $q->where('parent_id', $request->parent_id) : $q->whereNull('parent_id'); }) - ->where('created_at', '>=', now()->subSeconds(5)) + ->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'), @@ -65,6 +92,10 @@ class CommentController extends Controller '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) diff --git a/resources/views/components/video-comments.blade.php b/resources/views/components/video-comments.blade.php index 8319552..4e2880d 100644 --- a/resources/views/components/video-comments.blade.php +++ b/resources/views/components/video-comments.blade.php @@ -1135,9 +1135,17 @@ async function postComment() { submitBtn.disabled = true; submitBtn.textContent = 'Posting…'; try { + // Idempotency key: random per attempt. On retry the client reuses the + // same key so the server returns the original comment instead of + // writing a second one. + const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2))); const r = await fetch('/videos/' + YTC.videoId + '/comments', { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf }, + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': YTC.csrf, + 'X-Idempotency-Key': idemKey, + }, body: JSON.stringify({ body: text }) }); if (!(r.headers.get('content-type') || '').includes('application/json')) { @@ -1178,9 +1186,14 @@ async function postReply(vid, parentId) { btn.textContent = 'Posting…'; } try { + const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2))); const r = await fetch('/videos/' + vid + '/comments', { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf }, + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': YTC.csrf, + 'X-Idempotency-Key': idemKey, + }, body: JSON.stringify({ body: text, parent_id: parentId }) }); if (!(r.headers.get('content-type') || '').includes('application/json')) {