Files
ghassanandClaude Opus 4.7 4d9bc0f501 feat(engagement): video dislike + creator heart + creator pin
Three features that depend on the two additive migrations shipped in
the same commit (safe defaults, cascading foreign keys, no data loss
possible).

## Dislike button
- New video_dislikes table (migration 2026_10_21_000001) with the same
  shape as video_likes.
- Video model: dislikes() relation + isDislikedBy() / getDislikeCount
  Attribute() helpers mirroring the like pair.
- UserController@toggleDislike: endpoint behind POST
  /videos/{video}/toggle-dislike. Mutually exclusive with like — the
  toggle*Like / toggle*Dislike controllers detach the opposite state
  so a user is in exactly one of {liked, disliked, neither}. YouTube
  parity.
- video-actions component: a second thumbs-down button next to the
  existing like. Both reconcile in parallel on either response so the
  UI pair always reflects the authoritative server state.

## Creator heart + pin
- comments table gains hearted + pinned boolean columns (migration
  2026_10_21_000002, both default false, pinned is indexed).
- CommentController@heart + @pin: 403 for anyone but the video owner;
  pin() also rejects nested replies (422) and auto-unpins the
  previously-pinned top-level comment so the "at most one pinned"
  invariant is enforced at the DB level.
- Comment component:
    - Pinned comments render ahead of everything else; reorder()
      clears the comments() relation's default ->latest() so pinned
      DESC becomes the PRIMARY sort instead of a tiebreaker.
    - Creator sees heart + pin toggle buttons in the action bar.
    - Hearted comments carry a small red heart badge in the meta row.
    - Pinned comments carry a "Pinned by {creator}" header and a
      red left accent so they're visually distinct from the stream.

## Tests (9 new, 63 total)
- Dislike toggle adds/removes the row.
- Disliking clears an existing like and vice versa.
- Owner can heart; non-owner gets 403.
- Only one comment pinned at a time; replies can't be pinned (422);
  non-owner gets 403.
- Pinned comments render before newer unpinned ones.

## Deploy note
Running the migrations against the live DB needs:
    php artisan migrate

The two migration files are additive and reversible via `migrate:rollback`.

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

262 lines
9.0 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]);
}
/**
* Toggle the "creator heart" on a comment. Only the video owner can
* drop a heart; anyone else gets a 403.
*/
public function heart(Comment $comment)
{
$video = $comment->video;
if (! $video || $video->user_id !== Auth::id()) {
return response()->json(['error' => 'Only the video owner can heart a comment.'], 403);
}
$comment->hearted = ! $comment->hearted;
$comment->save();
return response()->json([
'hearted' => (bool) $comment->hearted,
]);
}
/**
* Toggle "pinned" on a top-level comment. Only the video owner can
* pin, and pinned comments sort ahead of everything else in the list.
* At most one comment is pinned at a time — pinning a new one unpins
* the previous.
*/
public function pin(Comment $comment)
{
$video = $comment->video;
if (! $video || $video->user_id !== Auth::id()) {
return response()->json(['error' => 'Only the video owner can pin a comment.'], 403);
}
if ($comment->parent_id) {
return response()->json(['error' => 'Only top-level comments can be pinned.'], 422);
}
$wasPinned = (bool) $comment->pinned;
if (! $wasPinned) {
// Unpin any previously-pinned comment on this video first so
// the UI invariant "at most one pinned" is maintained at the
// DB level, not just in the view.
Comment::where('video_id', $video->id)
->where('pinned', true)
->update(['pinned' => false]);
}
$comment->pinned = ! $wasPinned;
$comment->save();
return response()->json([
'pinned' => (bool) $comment->pinned,
]);
}
}