Add App\Support\SafeNotifier that wraps $notifiable->notify() in a Throwable guard with a logged warning, so SMTP outages (the current Gmail 535 errors) no longer propagate as a 500 to the user. Apply to the two hot paths that were still unprotected: CommentController (new comment, reply, like) and RegisteredUserController (super-admin registration alert). User::sendEmailVerificationNotification() is wrapped inline for the same reason. Also add docs/mail-setup.md covering the three delivery options: Gmail app password (quick), Mailgun, Postmark — with the exact .env keys for each. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
179 lines
6.0 KiB
PHP
179 lines
6.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, 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.
|
|
$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(5))
|
|
->latest('id')
|
|
->first();
|
|
|
|
if ($existing) {
|
|
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,
|
|
]);
|
|
|
|
$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]);
|
|
}
|
|
}
|