findConversationOrFail($conversationId); $cursor = $request->integer('cursor') ?: $request->integer('before_id'); $afterId = $request->integer('after_id'); if ($afterId) { $messages = Message::withTrashed() ->where('conversation_id', $conversationId) ->with(['sender:id,username', 'reactions', 'attachments']) ->where('id', '>', $afterId) ->orderBy('id') ->limit(100) ->get() ->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id)) ->values(); return response()->json([ 'data' => $messages, 'next_cursor' => null, ]); } $query = Message::withTrashed() ->where('conversation_id', $conversationId) ->with(['sender:id,username', 'reactions', 'attachments']) ->orderByDesc('created_at') ->orderByDesc('id'); if ($cursor) { $query->where('id', '<', $cursor); } $chunk = $query->limit(self::PAGE_SIZE + 1)->get(); $hasMore = $chunk->count() > self::PAGE_SIZE; $messages = $chunk->take(self::PAGE_SIZE)->reverse()->values(); $nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null; return response()->json([ 'data' => $messages->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id))->values(), 'next_cursor' => $nextCursor, ]); } // ── POST /api/messages/{conversation_id} ───────────────────────────────── public function store(StoreMessageRequest $request, int $conversationId): JsonResponse { $conversation = $this->findConversationOrFail($conversationId); $data = $request->validated(); $data['attachments'] = $request->file('attachments', []); $body = trim((string) ($data['body'] ?? '')); abort_if($body === '' && empty($data['attachments']), 422, 'Message body or attachment is required.'); $message = $this->sendMessage->execute($conversation, $request->user(), $data); return response()->json($this->payloadFactory->message($message, (int) $request->user()->id), 201); } // ── POST /api/messages/{conversation_id}/react ─────────────────────────── public function react(ToggleMessageReactionRequest $request, int $conversationId, int $messageId): JsonResponse { $this->findConversationOrFail($conversationId); $data = $request->validated(); $this->assertAllowedReaction($data['reaction']); $existing = MessageReaction::where([ 'message_id' => $messageId, 'user_id' => $request->user()->id, 'reaction' => $data['reaction'], ])->first(); if ($existing) { $existing->delete(); } else { MessageReaction::create([ 'message_id' => $messageId, 'user_id' => $request->user()->id, 'reaction' => $data['reaction'], ]); } return response()->json($this->reactionSummary($messageId, (int) $request->user()->id)); } // ── DELETE /api/messages/{conversation_id}/react ───────────────────────── public function unreact(ToggleMessageReactionRequest $request, int $conversationId, int $messageId): JsonResponse { $this->findConversationOrFail($conversationId); $data = $request->validated(); $this->assertAllowedReaction($data['reaction']); MessageReaction::where([ 'message_id' => $messageId, 'user_id' => $request->user()->id, 'reaction' => $data['reaction'], ])->delete(); return response()->json($this->reactionSummary($messageId, (int) $request->user()->id)); } public function reactByMessage(ToggleMessageReactionRequest $request, int $messageId): JsonResponse { $message = Message::query()->findOrFail($messageId); $this->findConversationOrFail((int) $message->conversation_id); $data = $request->validated(); $this->assertAllowedReaction($data['reaction']); $existing = MessageReaction::where([ 'message_id' => $messageId, 'user_id' => $request->user()->id, 'reaction' => $data['reaction'], ])->first(); if ($existing) { $existing->delete(); } else { MessageReaction::create([ 'message_id' => $messageId, 'user_id' => $request->user()->id, 'reaction' => $data['reaction'], ]); } return response()->json($this->reactionSummary($messageId, (int) $request->user()->id)); } public function unreactByMessage(ToggleMessageReactionRequest $request, int $messageId): JsonResponse { $message = Message::query()->findOrFail($messageId); $this->findConversationOrFail((int) $message->conversation_id); $data = $request->validated(); $this->assertAllowedReaction($data['reaction']); MessageReaction::where([ 'message_id' => $messageId, 'user_id' => $request->user()->id, 'reaction' => $data['reaction'], ])->delete(); return response()->json($this->reactionSummary($messageId, (int) $request->user()->id)); } // ── PATCH /api/messages/message/{messageId} ─────────────────────────────── public function update(UpdateMessageRequest $request, int $messageId): JsonResponse { $message = Message::findOrFail($messageId); $this->authorize('update', $message); abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted message.'); $data = $request->validated(); $message->update([ 'body' => $data['body'], 'edited_at' => now(), ]); app(MessageSearchIndexer::class)->updateMessage($message); $participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id); $this->conversationState->touchConversationCachesForUsers($participantUserIds); DB::afterCommit(function () use ($message, $participantUserIds): void { event(new MessageUpdated($message->fresh(['sender:id,username,name', 'attachments', 'reactions']))); $conversation = Conversation::find($message->conversation_id); if ($conversation) { foreach ($participantUserIds as $participantId) { event(new ConversationUpdated((int) $participantId, $conversation, 'message.updated')); } } }); return response()->json($this->payloadFactory->message($message->fresh(['sender:id,username,name', 'attachments', 'reactions']), (int) $request->user()->id)); } // ── DELETE /api/messages/message/{messageId} ────────────────────────────── public function destroy(Request $request, int $messageId): JsonResponse { $message = Message::findOrFail($messageId); $this->authorize('delete', $message); $participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id); app(MessageSearchIndexer::class)->deleteMessage($message); $message->delete(); $this->conversationState->touchConversationCachesForUsers($participantUserIds); DB::afterCommit(function () use ($message, $participantUserIds): void { $message->refresh(); event(new MessageDeleted($message)); $conversation = Conversation::find($message->conversation_id); if ($conversation) { foreach ($participantUserIds as $participantId) { event(new ConversationUpdated((int) $participantId, $conversation, 'message.deleted')); } } }); return response()->json(['ok' => true]); } // ── Private helpers ────────────────────────────────────────────────────── private function assertParticipant(Request $request, int $conversationId): void { abort_unless( ConversationParticipant::where('conversation_id', $conversationId) ->where('user_id', $request->user()->id) ->whereNull('left_at') ->exists(), 403, 'You are not a participant of this conversation.' ); } private function touchConversationCachesForUsers(array $userIds): void { $this->conversationState->touchConversationCachesForUsers($userIds); } private function assertAllowedReaction(string $reaction): void { $allowed = (array) config('messaging.reactions.allowed', []); abort_unless(in_array($reaction, $allowed, true), 422, 'Reaction is not allowed.'); } private function reactionSummary(int $messageId, int $userId): array { $rows = MessageReaction::query() ->selectRaw('reaction, count(*) as aggregate_count') ->where('message_id', $messageId) ->groupBy('reaction') ->get(); $summary = []; foreach ($rows as $row) { $summary[(string) $row->reaction] = (int) $row->aggregate_count; } $mine = MessageReaction::query() ->where('message_id', $messageId) ->where('user_id', $userId) ->pluck('reaction') ->values() ->all(); $summary['me'] = $mine; return $summary; } private function findConversationOrFail(int $conversationId): Conversation { $conversation = Conversation::query()->findOrFail($conversationId); $this->authorize('view', $conversation); return $conversation; } }