user(); $page = max(1, (int) $request->integer('page', 1)); $cacheVersion = (int) Cache::get($this->cacheVersionKey($user->id), 1); $cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion); $conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) { return Conversation::query() ->select('conversations.*') ->join('conversation_participants as cp_me', function ($join) use ($user) { $join->on('cp_me.conversation_id', '=', 'conversations.id') ->where('cp_me.user_id', '=', $user->id) ->whereNull('cp_me.left_at'); }) ->addSelect([ 'unread_count' => Message::query() ->selectRaw('count(*)') ->whereColumn('messages.conversation_id', 'conversations.id') ->where('messages.sender_id', '!=', $user->id) ->whereNull('messages.deleted_at') ->where(function ($query) { $query->whereNull('cp_me.last_read_at') ->orWhereColumn('messages.created_at', '>', 'cp_me.last_read_at'); }), ]) ->with([ 'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']), 'latestMessage.sender:id,username', ]) ->orderByDesc('cp_me.is_pinned') ->orderByDesc('cp_me.pinned_at') ->orderByDesc('last_message_at') ->orderByDesc('conversations.id') ->paginate(20, ['conversations.*'], 'page', $page); }); $conversations->through(function ($conv) use ($user) { $conv->my_participant = $conv->allParticipants ->firstWhere('user_id', $user->id); return $conv; }); return response()->json($conversations); } // ── GET /api/messages/conversation/{id} ───────────────────────────────── public function show(Request $request, int $id): JsonResponse { $conv = $this->findAuthorized($request, $id); $conv->load([ 'allParticipants.user:id,username', 'creator:id,username', ]); return response()->json($conv); } // ── POST /api/messages/conversation ───────────────────────────────────── public function store(Request $request): JsonResponse { $user = $request->user(); $data = $request->validate([ 'type' => 'required|in:direct,group', 'recipient_id' => 'required_if:type,direct|integer|exists:users,id', 'participant_ids' => 'required_if:type,group|array|min:2', 'participant_ids.*'=> 'integer|exists:users,id', 'title' => 'required_if:type,group|nullable|string|max:120', 'body' => 'required|string|max:5000', ]); if ($data['type'] === 'direct') { return $this->createDirect($request, $user, $data); } return $this->createGroup($request, $user, $data); } // ── POST /api/messages/{conversation_id}/read ──────────────────────────── public function markRead(Request $request, int $id): JsonResponse { $participant = $this->participantRecord($request, $id); $participant->update(['last_read_at' => now()]); $this->touchConversationCachesForUsers([$request->user()->id]); return response()->json(['ok' => true]); } // ── POST /api/messages/{conversation_id}/archive ───────────────────────── public function archive(Request $request, int $id): JsonResponse { $participant = $this->participantRecord($request, $id); $participant->update(['is_archived' => ! $participant->is_archived]); $this->touchConversationCachesForUsers([$request->user()->id]); return response()->json(['is_archived' => $participant->is_archived]); } // ── POST /api/messages/{conversation_id}/mute ──────────────────────────── public function mute(Request $request, int $id): JsonResponse { $participant = $this->participantRecord($request, $id); $participant->update(['is_muted' => ! $participant->is_muted]); $this->touchConversationCachesForUsers([$request->user()->id]); return response()->json(['is_muted' => $participant->is_muted]); } public function pin(Request $request, int $id): JsonResponse { $participant = $this->participantRecord($request, $id); $participant->update(['is_pinned' => true, 'pinned_at' => now()]); $this->touchConversationCachesForUsers([$request->user()->id]); return response()->json(['is_pinned' => true]); } public function unpin(Request $request, int $id): JsonResponse { $participant = $this->participantRecord($request, $id); $participant->update(['is_pinned' => false, 'pinned_at' => null]); $this->touchConversationCachesForUsers([$request->user()->id]); return response()->json(['is_pinned' => false]); } // ── DELETE /api/messages/{conversation_id}/leave ───────────────────────── public function leave(Request $request, int $id): JsonResponse { $conv = $this->findAuthorized($request, $id); $participant = $this->participantRecord($request, $id); $participantUserIds = ConversationParticipant::where('conversation_id', $id) ->whereNull('left_at') ->pluck('user_id') ->all(); if ($conv->isGroup()) { // Last admin protection $adminCount = ConversationParticipant::where('conversation_id', $id) ->where('role', 'admin') ->whereNull('left_at') ->count(); if ($adminCount === 1 && $participant->role === 'admin') { $otherMember = ConversationParticipant::where('conversation_id', $id) ->where('user_id', '!=', $request->user()->id) ->whereNull('left_at') ->first(); if ($otherMember) { $otherMember->update(['role' => 'admin']); } } } $participant->update(['left_at' => now()]); $this->touchConversationCachesForUsers($participantUserIds); return response()->json(['ok' => true]); } // ── POST /api/messages/{conversation_id}/add-user ──────────────────────── public function addUser(Request $request, int $id): JsonResponse { $conv = $this->findAuthorized($request, $id); $this->requireAdmin($request, $id); $participantUserIds = ConversationParticipant::where('conversation_id', $id) ->whereNull('left_at') ->pluck('user_id') ->all(); $data = $request->validate([ 'user_id' => 'required|integer|exists:users,id', ]); $existing = ConversationParticipant::where('conversation_id', $id) ->where('user_id', $data['user_id']) ->first(); if ($existing) { if ($existing->left_at) { $existing->update(['left_at' => null, 'joined_at' => now()]); } } else { ConversationParticipant::create([ 'conversation_id' => $id, 'user_id' => $data['user_id'], 'role' => 'member', 'joined_at' => now(), ]); } $participantUserIds[] = (int) $data['user_id']; $this->touchConversationCachesForUsers($participantUserIds); return response()->json(['ok' => true]); } // ── DELETE /api/messages/{conversation_id}/remove-user ─────────────────── public function removeUser(Request $request, int $id): JsonResponse { $this->requireAdmin($request, $id); $data = $request->validate([ 'user_id' => 'required|integer', ]); // Cannot remove the conversation creator $conv = Conversation::findOrFail($id); abort_if($conv->created_by === (int) $data['user_id'], 403, 'Cannot remove the conversation creator.'); $targetParticipant = ConversationParticipant::where('conversation_id', $id) ->where('user_id', $data['user_id']) ->whereNull('left_at') ->first(); if ($targetParticipant && $targetParticipant->role === 'admin') { $adminCount = ConversationParticipant::where('conversation_id', $id) ->where('role', 'admin') ->whereNull('left_at') ->count(); abort_if($adminCount <= 1, 422, 'Cannot remove the last admin from this conversation.'); } $participantUserIds = ConversationParticipant::where('conversation_id', $id) ->whereNull('left_at') ->pluck('user_id') ->all(); ConversationParticipant::where('conversation_id', $id) ->where('user_id', $data['user_id']) ->whereNull('left_at') ->update(['left_at' => now()]); $this->touchConversationCachesForUsers($participantUserIds); return response()->json(['ok' => true]); } // ── POST /api/messages/{conversation_id}/rename ────────────────────────── public function rename(Request $request, int $id): JsonResponse { $conv = $this->findAuthorized($request, $id); abort_unless($conv->isGroup(), 422, 'Only group conversations can be renamed.'); $this->requireAdmin($request, $id); $data = $request->validate(['title' => 'required|string|max:120']); $conv->update(['title' => $data['title']]); $participantUserIds = ConversationParticipant::where('conversation_id', $id) ->whereNull('left_at') ->pluck('user_id') ->all(); $this->touchConversationCachesForUsers($participantUserIds); return response()->json(['title' => $conv->title]); } // ── Private helpers ────────────────────────────────────────────────────── private function createDirect(Request $request, User $user, array $data): JsonResponse { $recipient = User::findOrFail($data['recipient_id']); abort_if($recipient->id === $user->id, 422, 'You cannot message yourself.'); if (! $recipient->allowsMessagesFrom($user)) { abort(403, 'This user does not accept messages from you.'); } $this->assertNotBlockedBetween($user, $recipient); // Reuse existing conversation if one exists $conv = Conversation::findDirect($user->id, $recipient->id); if (! $conv) { $conv = DB::transaction(function () use ($user, $recipient) { $conv = Conversation::create([ 'type' => 'direct', 'created_by' => $user->id, ]); ConversationParticipant::insert([ ['conversation_id' => $conv->id, 'user_id' => $user->id, 'role' => 'admin', 'joined_at' => now()], ['conversation_id' => $conv->id, 'user_id' => $recipient->id, 'role' => 'member', 'joined_at' => now()], ]); return $conv; }); } // Insert first / next message $message = $conv->messages()->create([ 'sender_id' => $user->id, 'body' => $data['body'], ]); $conv->update(['last_message_at' => $message->created_at]); app(MessageNotificationService::class)->notifyNewMessage($conv, $message, $user); $this->touchConversationCachesForUsers([$user->id, $recipient->id]); return response()->json($conv->load('allParticipants.user:id,username'), 201); } private function createGroup(Request $request, User $user, array $data): JsonResponse { $participantIds = array_unique(array_merge([$user->id], $data['participant_ids'])); $conv = DB::transaction(function () use ($user, $data, $participantIds) { $conv = Conversation::create([ 'type' => 'group', 'title' => $data['title'], 'created_by' => $user->id, ]); $rows = array_map(fn ($uid) => [ 'conversation_id' => $conv->id, 'user_id' => $uid, 'role' => $uid === $user->id ? 'admin' : 'member', 'joined_at' => now(), ], $participantIds); ConversationParticipant::insert($rows); $message = $conv->messages()->create([ 'sender_id' => $user->id, 'body' => $data['body'], ]); $conv->update(['last_message_at' => $message->created_at]); return [$conv, $message]; }); [$conversation, $message] = $conv; app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $user); $this->touchConversationCachesForUsers($participantIds); return response()->json($conversation->load('allParticipants.user:id,username'), 201); } private function findAuthorized(Request $request, int $id): Conversation { $conv = Conversation::findOrFail($id); $this->assertParticipant($request, $id); return $conv; } private function participantRecord(Request $request, int $conversationId): ConversationParticipant { return ConversationParticipant::where('conversation_id', $conversationId) ->where('user_id', $request->user()->id) ->whereNull('left_at') ->firstOrFail(); } private function assertParticipant(Request $request, int $id): void { abort_unless( ConversationParticipant::where('conversation_id', $id) ->where('user_id', $request->user()->id) ->whereNull('left_at') ->exists(), 403, 'You are not a participant of this conversation.' ); } private function requireAdmin(Request $request, int $id): void { abort_unless( ConversationParticipant::where('conversation_id', $id) ->where('user_id', $request->user()->id) ->where('role', 'admin') ->whereNull('left_at') ->exists(), 403, 'Only admins can perform this action.' ); } private function touchConversationCachesForUsers(array $userIds): void { foreach (array_unique($userIds) as $userId) { if (! $userId) { continue; } $versionKey = $this->cacheVersionKey((int) $userId); Cache::add($versionKey, 1, now()->addDay()); Cache::increment($versionKey); } } private function cacheVersionKey(int $userId): string { return "messages:conversations:version:{$userId}"; } private function conversationListCacheKey(int $userId, int $page, int $version): string { return "messages:conversations:user:{$userId}:page:{$page}:v:{$version}"; } private function assertNotBlockedBetween(User $sender, User $recipient): void { if (! Schema::hasTable('user_blocks')) { return; } $blocked = false; if (Schema::hasColumns('user_blocks', ['user_id', 'blocked_user_id'])) { $blocked = DB::table('user_blocks') ->where(function ($q) use ($sender, $recipient) { $q->where('user_id', $sender->id)->where('blocked_user_id', $recipient->id); }) ->orWhere(function ($q) use ($sender, $recipient) { $q->where('user_id', $recipient->id)->where('blocked_user_id', $sender->id); }) ->exists(); } elseif (Schema::hasColumns('user_blocks', ['blocker_id', 'blocked_id'])) { $blocked = DB::table('user_blocks') ->where(function ($q) use ($sender, $recipient) { $q->where('blocker_id', $sender->id)->where('blocked_id', $recipient->id); }) ->orWhere(function ($q) use ($sender, $recipient) { $q->where('blocker_id', $recipient->id)->where('blocked_id', $sender->id); }) ->exists(); } abort_if($blocked, 403, 'Messaging is not available between these users.'); } }