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) { $query = 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'); }) ->where('conversations.is_active', true) ->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'); $this->unreadCounters->applyUnreadCountSelect($query, $user, 'cp_me'); return $query->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->toArray(), 'summary' => [ 'unread_total' => $this->unreadCounters->totalUnreadForUser($user), ], ]); } // ── 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(StoreConversationRequest $request): JsonResponse { $user = $request->user(); $data = $request->validated(); 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 { $conversation = $this->findAuthorized($request, $id); $participant = $this->conversationReads->markConversationRead( $conversation, $request->user(), $request->integer('message_id') ?: null, ); return response()->json([ 'ok' => true, 'last_read_at' => optional($participant->last_read_at)?->toIso8601String(), 'last_read_message_id' => $participant->last_read_message_id, 'unread_total' => $this->unreadCounters->totalUnreadForUser($request->user()), ]); } // ── POST /api/messages/{conversation_id}/archive ───────────────────────── public function archive(Request $request, int $id): JsonResponse { $conversation = $this->findAuthorized($request, $id); $participant = $this->participantRecord($request, $id); $participant->update(['is_archived' => ! $participant->is_archived]); $this->broadcastConversationUpdate($conversation, 'conversation.archived'); return response()->json(['is_archived' => $participant->is_archived]); } // ── POST /api/messages/{conversation_id}/mute ──────────────────────────── public function mute(Request $request, int $id): JsonResponse { $conversation = $this->findAuthorized($request, $id); $participant = $this->participantRecord($request, $id); $participant->update(['is_muted' => ! $participant->is_muted]); $this->broadcastConversationUpdate($conversation, 'conversation.muted'); return response()->json(['is_muted' => $participant->is_muted]); } public function pin(Request $request, int $id): JsonResponse { $conversation = $this->findAuthorized($request, $id); $participant = $this->participantRecord($request, $id); $participant->update(['is_pinned' => true, 'pinned_at' => now()]); $this->broadcastConversationUpdate($conversation, 'conversation.pinned'); return response()->json(['is_pinned' => true]); } public function unpin(Request $request, int $id): JsonResponse { $conversation = $this->findAuthorized($request, $id); $participant = $this->participantRecord($request, $id); $participant->update(['is_pinned' => false, 'pinned_at' => null]); $this->broadcastConversationUpdate($conversation, 'conversation.unpinned'); 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->conversationState->touchConversationCachesForUsers($participantUserIds); $this->broadcastConversationUpdate($conv, 'conversation.left', $participantUserIds); return response()->json(['ok' => true]); } // ── POST /api/messages/{conversation_id}/add-user ──────────────────────── public function addUser(ManageConversationParticipantRequest $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->validated(); $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->conversationState->touchConversationCachesForUsers($participantUserIds); $this->broadcastConversationUpdate($conv, 'conversation.participant_added', $participantUserIds); return response()->json(['ok' => true]); } // ── DELETE /api/messages/{conversation_id}/remove-user ─────────────────── public function removeUser(ManageConversationParticipantRequest $request, int $id): JsonResponse { $this->requireAdmin($request, $id); $data = $request->validated(); // 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->conversationState->touchConversationCachesForUsers($participantUserIds); $this->broadcastConversationUpdate($conv, 'conversation.participant_removed', $participantUserIds); return response()->json(['ok' => true]); } // ── POST /api/messages/{conversation_id}/rename ────────────────────────── public function rename(RenameConversationRequest $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->validated(); $conv->update(['title' => $data['title']]); $participantUserIds = ConversationParticipant::where('conversation_id', $id) ->whereNull('left_at') ->pluck('user_id') ->all(); $this->conversationState->touchConversationCachesForUsers($participantUserIds); $this->broadcastConversationUpdate($conv, 'conversation.renamed', $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([ 'uuid' => (string) \Illuminate\Support\Str::uuid(), 'type' => 'direct', 'created_by' => $user->id, 'is_active' => true, ]); 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; }); } $this->sendMessage->execute($conv, $user, [ 'body' => $data['body'], 'client_temp_id' => $data['client_temp_id'] ?? null, ]); return response()->json($conv->fresh()->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([ 'uuid' => (string) \Illuminate\Support\Str::uuid(), 'type' => 'group', 'title' => $data['title'], 'created_by' => $user->id, 'is_active' => true, ]); $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); return $conv; }); $this->sendMessage->execute($conv, $user, [ 'body' => $data['body'], 'client_temp_id' => $data['client_temp_id'] ?? null, ]); return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201); } private function findAuthorized(Request $request, int $id): Conversation { $conv = Conversation::findOrFail($id); $this->authorize('view', $conv); 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 { $conversation = Conversation::findOrFail($id); $this->authorize('manageParticipants', $conversation); } private function touchConversationCachesForUsers(array $userIds): void { $this->conversationState->touchConversationCachesForUsers($userIds); } 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 broadcastConversationUpdate(Conversation $conversation, string $reason, ?array $participantIds = null): void { $participantIds ??= $this->conversationState->activeParticipantIds($conversation); $this->conversationState->touchConversationCachesForUsers($participantIds); foreach ($participantIds as $participantId) { event(new ConversationUpdated((int) $participantId, $conversation, $reason)); } } 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.'); } }