feat: notificaciones en tiempo real para social (likes, comentarios, solicitudes)

- Notifier helper: guarda en BD + broadcast en un solo llamado
- AppNotification: notificación genérica para canal database
- FriendshipController: notifica al recibir solicitud y al aceptarla
- PostController: notifica al autor cuando le dan like o comentan (excepto en propio post)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 17:15:28 -06:00
parent 97c04fbfb4
commit 0273fbfddd
4 changed files with 121 additions and 17 deletions
+27 -11
View File
@@ -4,12 +4,11 @@ namespace App\Http\Controllers;
use App\Models\Friendship; use App\Models\Friendship;
use App\Models\User; use App\Models\User;
use App\Notifications\FriendRequestNotification; use App\Support\Notifier;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class FriendshipController extends Controller class FriendshipController extends Controller
{ {
// Solicitudes pendientes recibidas
public function index() public function index()
{ {
$requests = auth()->user() $requests = auth()->user()
@@ -22,7 +21,6 @@ class FriendshipController extends Controller
return view('friendships.index', compact('requests')); return view('friendships.index', compact('requests'));
} }
// Enviar solicitud
public function send(User $user) public function send(User $user)
{ {
$me = auth()->user(); $me = auth()->user();
@@ -31,33 +29,45 @@ class FriendshipController extends Controller
return back()->with('error', 'No puedes enviarte una solicitud a ti mismo.'); return back()->with('error', 'No puedes enviarte una solicitud a ti mismo.');
} }
$existing = $me->friendshipWith($user); if ($me->friendshipWith($user)) {
if ($existing) {
return back()->with('error', 'Ya existe una relación con este usuario.'); return back()->with('error', 'Ya existe una relación con este usuario.');
} }
$friendship = Friendship::create([ Friendship::create([
'sender_id' => $me->id, 'sender_id' => $me->id,
'receiver_id' => $user->id, 'receiver_id' => $user->id,
'status' => 'pending', 'status' => 'pending',
]); ]);
$user->notify(new FriendRequestNotification($me)); Notifier::send(
recipient: $user,
title: $me->name . ' te envió una solicitud de amistad',
icon: 'fa-user-plus',
color: 'primary',
url: route('friendships.index'),
);
return back()->with('success', 'Solicitud de amistad enviada.'); return back()->with('success', 'Solicitud de amistad enviada.');
} }
// Aceptar solicitud
public function accept(Friendship $friendship) public function accept(Friendship $friendship)
{ {
abort_unless($friendship->receiver_id === auth()->id(), 403); abort_unless($friendship->receiver_id === auth()->id(), 403);
$friendship->update(['status' => 'accepted']); $friendship->update(['status' => 'accepted']);
$friendship->load('receiver');
Notifier::send(
recipient: $friendship->sender,
title: $friendship->receiver->name . ' aceptó tu solicitud de amistad',
icon: 'fa-user-check',
color: 'success',
url: route('social.profile', $friendship->receiver),
);
return back()->with('success', 'Solicitud aceptada.'); return back()->with('success', 'Solicitud aceptada.');
} }
// Rechazar o cancelar solicitud
public function destroy(Friendship $friendship) public function destroy(Friendship $friendship)
{ {
$me = auth()->id(); $me = auth()->id();
@@ -71,7 +81,7 @@ class FriendshipController extends Controller
return back()->with('success', 'Solicitud eliminada.'); return back()->with('success', 'Solicitud eliminada.');
} }
// AJAX: enviar/cancelar solicitud desde búsqueda o perfil // AJAX desde búsqueda/perfil
public function toggle(User $user) public function toggle(User $user)
{ {
$me = auth()->user(); $me = auth()->user();
@@ -93,7 +103,13 @@ class FriendshipController extends Controller
'status' => 'pending', 'status' => 'pending',
]); ]);
$user->notify(new FriendRequestNotification($me)); Notifier::send(
recipient: $user,
title: $me->name . ' te envió una solicitud de amistad',
icon: 'fa-user-plus',
color: 'primary',
url: route('friendships.index'),
);
return response()->json(['status' => 'pending']); return response()->json(['status' => 'pending']);
} }
+32 -4
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Post; use App\Models\Post;
use App\Models\PostComment; use App\Models\PostComment;
use App\Models\PostLike; use App\Models\PostLike;
use App\Support\Notifier;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -51,15 +52,28 @@ class PostController extends Controller
public function like(Post $post) public function like(Post $post)
{ {
$userId = auth()->id(); $me = auth()->user();
$liked = PostLike::where('user_id', $userId)->where('post_id', $post->id)->first(); $liked = PostLike::where('user_id', $me->id)->where('post_id', $post->id)->first();
if ($liked) { if ($liked) {
$liked->delete(); $liked->delete();
$liked = false; $liked = false;
} else { } else {
PostLike::create(['user_id' => $userId, 'post_id' => $post->id]); PostLike::create(['user_id' => $me->id, 'post_id' => $post->id]);
$liked = true; $liked = true;
// Notify post author (not if liking own post)
if ($post->user_id !== $me->id) {
$post->load('author');
Notifier::send(
recipient: $post->author,
title: $me->name . ' le dio Me gusta a tu publicación',
body: $post->body ? \Str::limit($post->body, 60) : '',
icon: 'fa-heart',
color: 'danger',
url: route('feed') . '#post-' . $post->id,
);
}
} }
return response()->json([ return response()->json([
@@ -72,14 +86,28 @@ class PostController extends Controller
{ {
$request->validate(['body' => 'required|string|max:500']); $request->validate(['body' => 'required|string|max:500']);
$me = auth()->user();
$comment = PostComment::create([ $comment = PostComment::create([
'user_id' => auth()->id(), 'user_id' => $me->id,
'post_id' => $post->id, 'post_id' => $post->id,
'body' => $request->body, 'body' => $request->body,
]); ]);
$comment->load('author'); $comment->load('author');
// Notify post author (not if commenting on own post)
if ($post->user_id !== $me->id) {
$post->load('author');
Notifier::send(
recipient: $post->author,
title: $me->name . ' comentó en tu publicación',
body: \Str::limit($request->body, 60),
icon: 'fa-comment',
color: 'info',
url: route('feed') . '#post-' . $post->id,
);
}
return response()->json([ return response()->json([
'id' => $comment->id, 'id' => $comment->id,
'body' => $comment->body, 'body' => $comment->body,
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class AppNotification extends Notification
{
use Queueable;
public function __construct(
public readonly string $title,
public readonly string $body = '',
public readonly ?string $url = null,
public readonly string $icon = 'fa-bell',
public readonly string $color = 'primary',
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
return [
'title' => $this->title,
'body' => $this->body,
'url' => $this->url,
'icon' => $this->icon,
'color' => $this->color,
];
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Support;
use App\Events\NewNotification;
use App\Models\User;
use App\Notifications\AppNotification;
class Notifier
{
public static function send(
User $recipient,
string $title,
string $body = '',
string $icon = 'fa-bell',
string $color = 'primary',
?string $url = null,
): void {
// Persist in DB so it shows on page reload
$recipient->notify(new AppNotification($title, $body, $url, $icon, $color));
// Broadcast in real-time to the bell dropdown
broadcast(new NewNotification($recipient->id, $title, $body, $icon, $color, $url));
}
}