feat: funcionalidad de red social (feed, amigos, búsqueda)

- Migraciones: posts, post_likes, post_comments
- Modelos Post, PostLike, PostComment con relaciones
- User: métodos friendshipWith, isFriendWith, friendIds, posts
- PostController: feed, crear publicación con imagen, like toggle, comentarios, eliminar
- FriendshipController: listar solicitudes, enviar, aceptar, rechazar, toggle AJAX
- SocialProfileController: perfil público con publicaciones y estado de amistad
- UserSearchController: búsqueda AJAX por nombre con estado de amistad
- FriendRequestNotification: notificación al recibir solicitud
- Vistas: feed/index, feed/_post, friendships/index, social/profile
- Header: barra de búsqueda con autocomplete AJAX (agregar amigo / mensaje)
- Menú lateral: Comunidad, Amigos, Mensajes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 17:09:04 -06:00
parent 0f7de9b4d8
commit 97c04fbfb4
22 changed files with 1005 additions and 308 deletions
@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers;
use App\Models\Friendship;
use App\Models\User;
use App\Notifications\FriendRequestNotification;
use Illuminate\Http\Request;
class FriendshipController extends Controller
{
// Solicitudes pendientes recibidas
public function index()
{
$requests = auth()->user()
->receivedFriendRequests()
->where('status', 'pending')
->with('sender')
->latest()
->get();
return view('friendships.index', compact('requests'));
}
// Enviar solicitud
public function send(User $user)
{
$me = auth()->user();
if ($me->id === $user->id) {
return back()->with('error', 'No puedes enviarte una solicitud a ti mismo.');
}
$existing = $me->friendshipWith($user);
if ($existing) {
return back()->with('error', 'Ya existe una relación con este usuario.');
}
$friendship = Friendship::create([
'sender_id' => $me->id,
'receiver_id' => $user->id,
'status' => 'pending',
]);
$user->notify(new FriendRequestNotification($me));
return back()->with('success', 'Solicitud de amistad enviada.');
}
// Aceptar solicitud
public function accept(Friendship $friendship)
{
abort_unless($friendship->receiver_id === auth()->id(), 403);
$friendship->update(['status' => 'accepted']);
return back()->with('success', 'Solicitud aceptada.');
}
// Rechazar o cancelar solicitud
public function destroy(Friendship $friendship)
{
$me = auth()->id();
abort_unless(
$friendship->sender_id === $me || $friendship->receiver_id === $me,
403
);
$friendship->delete();
return back()->with('success', 'Solicitud eliminada.');
}
// AJAX: enviar/cancelar solicitud desde búsqueda o perfil
public function toggle(User $user)
{
$me = auth()->user();
if ($me->id === $user->id) {
return response()->json(['error' => 'No permitido'], 422);
}
$existing = $me->friendshipWith($user);
if ($existing) {
$existing->delete();
return response()->json(['status' => 'none']);
}
Friendship::create([
'sender_id' => $me->id,
'receiver_id' => $user->id,
'status' => 'pending',
]);
$user->notify(new FriendRequestNotification($me));
return response()->json(['status' => 'pending']);
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\PostComment;
use App\Models\PostLike;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class PostController extends Controller
{
public function index()
{
$me = auth()->user();
$friendIds = $me->friendIds();
$ids = array_merge($friendIds, [$me->id]);
$posts = Post::whereIn('user_id', $ids)
->with(['author', 'likes', 'comments.author'])
->latest()
->paginate(15);
return view('feed.index', compact('posts'));
}
public function store(Request $request)
{
$request->validate([
'body' => 'nullable|string|max:2000',
'image' => 'nullable|image|max:5120',
]);
if (!$request->filled('body') && !$request->hasFile('image')) {
return back()->with('error', 'Escribe algo o adjunta una imagen.');
}
$imagePath = null;
if ($request->hasFile('image')) {
$imagePath = $request->file('image')->store('posts', 'public');
}
Post::create([
'user_id' => auth()->id(),
'body' => $request->body,
'image_path' => $imagePath,
]);
return back()->with('success', 'Publicación creada.');
}
public function like(Post $post)
{
$userId = auth()->id();
$liked = PostLike::where('user_id', $userId)->where('post_id', $post->id)->first();
if ($liked) {
$liked->delete();
$liked = false;
} else {
PostLike::create(['user_id' => $userId, 'post_id' => $post->id]);
$liked = true;
}
return response()->json([
'liked' => $liked,
'count' => $post->likes()->count(),
]);
}
public function comment(Request $request, Post $post)
{
$request->validate(['body' => 'required|string|max:500']);
$comment = PostComment::create([
'user_id' => auth()->id(),
'post_id' => $post->id,
'body' => $request->body,
]);
$comment->load('author');
return response()->json([
'id' => $comment->id,
'body' => $comment->body,
'author' => $comment->author->name,
'avatar' => $comment->author->profile_photo_url,
'created_at' => $comment->created_at->diffForHumans(),
]);
}
public function destroy(Post $post)
{
abort_unless($post->user_id === auth()->id(), 403);
if ($post->image_path) {
Storage::disk('public')->delete($post->image_path);
}
$post->delete();
return back()->with('success', 'Publicación eliminada.');
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
class SocialProfileController extends Controller
{
public function show(User $user)
{
$me = auth()->user();
$friendship = $me->friendshipWith($user);
$posts = $user->posts()
->with(['author', 'likes', 'comments.author'])
->latest()
->paginate(10);
return view('social.profile', compact('user', 'friendship', 'posts'));
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
class UserSearchController extends Controller
{
public function __invoke(Request $request)
{
$q = trim($request->get('q', ''));
$me = auth()->user();
if (strlen($q) < 2) {
return response()->json([]);
}
$users = User::where('id', '!=', $me->id)
->where(function ($query) use ($q) {
$query->where('name', 'like', "%{$q}%")
->orWhere('apellidoPaterno', 'like', "%{$q}%")
->orWhere('apellidoMaterno', 'like', "%{$q}%");
})
->limit(8)
->get();
return response()->json(
$users->map(function (User $user) use ($me) {
$friendship = $me->friendshipWith($user);
$status = $friendship?->status ?? 'none';
$isReceiver = $friendship?->receiver_id === $me->id;
return [
'id' => $user->id,
'name' => $user->name . ' ' . $user->apellidoPaterno,
'avatar' => $user->profile_photo_url,
'profile_url' => route('social.profile', $user),
'chat_url' => route('chat.show', $user),
'friendship' => [
'status' => $status,
'id' => $friendship?->id,
'is_receiver' => $isReceiver,
],
];
})
);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Post extends Model
{
protected $fillable = ['user_id', 'body', 'image_path'];
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function likes(): HasMany
{
return $this->hasMany(PostLike::class);
}
public function comments(): HasMany
{
return $this->hasMany(PostComment::class)->with('author')->orderBy('created_at');
}
public function isLikedBy(User $user): bool
{
return $this->likes()->where('user_id', $user->id)->exists();
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PostComment extends Model
{
protected $fillable = ['user_id', 'post_id', 'body'];
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PostLike extends Model
{
protected $fillable = ['user_id', 'post_id'];
}
+39
View File
@@ -131,6 +131,45 @@ class User extends Authenticatable
return $this->belongsToMany(Alumno::class);
}
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
public function sentFriendRequests(): HasMany
{
return $this->hasMany(Friendship::class, 'sender_id');
}
public function receivedFriendRequests(): HasMany
{
return $this->hasMany(Friendship::class, 'receiver_id');
}
public function friendshipWith(User $user): ?Friendship
{
return Friendship::where(function ($q) use ($user) {
$q->where('sender_id', $this->id)->where('receiver_id', $user->id);
})
->orWhere(function ($q) use ($user) {
$q->where('sender_id', $user->id)->where('receiver_id', $this->id);
})
->first();
}
public function isFriendWith(User $user): bool
{
$friendship = $this->friendshipWith($user);
return $friendship?->status === 'accepted';
}
public function friendIds(): array
{
$sent = Friendship::where('sender_id', $this->id)->where('status', 'accepted')->pluck('receiver_id');
$received = Friendship::where('receiver_id', $this->id)->where('status', 'accepted')->pluck('sender_id');
return $sent->merge($received)->unique()->values()->all();
}
protected static function booted()
{
static::creating(function ($user) {
@@ -0,0 +1,30 @@
<?php
namespace App\Notifications;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class FriendRequestNotification extends Notification
{
use Queueable;
public function __construct(public User $sender) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
return [
'title' => $this->sender->name . ' te envió una solicitud de amistad',
'body' => null,
'url' => route('friendships.index'),
'icon' => 'fa-user-plus',
'color' => 'primary',
];
}
}