assertParticipant($request, $conversationId); $cursor = $request->integer('cursor'); $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, 'next_cursor' => $nextCursor, ]); } // ── POST /api/messages/{conversation_id} ───────────────────────────────── public function store(Request $request, int $conversationId): JsonResponse { $this->assertParticipant($request, $conversationId); $data = $request->validate([ 'body' => 'nullable|string|max:5000', 'attachments' => 'sometimes|array|max:5', 'attachments.*' => 'file|max:25600', ]); $body = trim((string) ($data['body'] ?? '')); $files = $request->file('attachments', []); abort_if($body === '' && empty($files), 422, 'Message body or attachment is required.'); $message = Message::create([ 'conversation_id' => $conversationId, 'sender_id' => $request->user()->id, 'body' => $body, ]); foreach ($files as $file) { if ($file instanceof UploadedFile) { $this->storeAttachment($file, $message, (int) $request->user()->id); } } Conversation::where('id', $conversationId) ->update(['last_message_at' => $message->created_at]); $conversation = Conversation::findOrFail($conversationId); app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $request->user()); app(MessageSearchIndexer::class)->indexMessage($message); event(new MessageSent($conversationId, $message->id, $request->user()->id)); $participantUserIds = ConversationParticipant::where('conversation_id', $conversationId) ->whereNull('left_at') ->pluck('user_id') ->all(); $this->touchConversationCachesForUsers($participantUserIds); $message->load(['sender:id,username', 'attachments']); return response()->json($message, 201); } // ── POST /api/messages/{conversation_id}/react ─────────────────────────── public function react(Request $request, int $conversationId, int $messageId): JsonResponse { $this->assertParticipant($request, $conversationId); $data = $request->validate(['reaction' => 'required|string|max:32']); $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(Request $request, int $conversationId, int $messageId): JsonResponse { $this->assertParticipant($request, $conversationId); $data = $request->validate(['reaction' => 'required|string|max:32']); $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(Request $request, int $messageId): JsonResponse { $message = Message::query()->findOrFail($messageId); $this->assertParticipant($request, (int) $message->conversation_id); $data = $request->validate(['reaction' => 'required|string|max:32']); $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(Request $request, int $messageId): JsonResponse { $message = Message::query()->findOrFail($messageId); $this->assertParticipant($request, (int) $message->conversation_id); $data = $request->validate(['reaction' => 'required|string|max:32']); $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(Request $request, int $messageId): JsonResponse { $message = Message::findOrFail($messageId); abort_unless( $message->sender_id === $request->user()->id, 403, 'You may only edit your own messages.' ); abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted message.'); $data = $request->validate(['body' => 'required|string|max:5000']); $message->update([ 'body' => $data['body'], 'edited_at' => now(), ]); app(MessageSearchIndexer::class)->updateMessage($message); $participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id) ->whereNull('left_at') ->pluck('user_id') ->all(); $this->touchConversationCachesForUsers($participantUserIds); return response()->json($message->fresh()); } // ── DELETE /api/messages/message/{messageId} ────────────────────────────── public function destroy(Request $request, int $messageId): JsonResponse { $message = Message::findOrFail($messageId); abort_unless( $message->sender_id === $request->user()->id || $request->user()->isAdmin(), 403, 'You may only delete your own messages.' ); $participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id) ->whereNull('left_at') ->pluck('user_id') ->all(); app(MessageSearchIndexer::class)->deleteMessage($message); $message->delete(); $this->touchConversationCachesForUsers($participantUserIds); 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 { foreach (array_unique($userIds) as $userId) { if (! $userId) { continue; } $versionKey = "messages:conversations:version:{$userId}"; Cache::add($versionKey, 1, now()->addDay()); Cache::increment($versionKey); } } 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 storeAttachment(UploadedFile $file, Message $message, int $userId): void { $mime = (string) $file->getMimeType(); $finfoMime = (string) finfo_file(finfo_open(FILEINFO_MIME_TYPE), $file->getPathname()); $detectedMime = $finfoMime !== '' ? $finfoMime : $mime; $allowedImage = (array) config('messaging.attachments.allowed_image_mimes', []); $allowedFile = (array) config('messaging.attachments.allowed_file_mimes', []); $type = in_array($detectedMime, $allowedImage, true) ? 'image' : 'file'; $allowed = $type === 'image' ? $allowedImage : $allowedFile; abort_unless(in_array($detectedMime, $allowed, true), 422, 'Unsupported attachment type.'); $maxBytes = $type === 'image' ? ((int) config('messaging.attachments.max_image_kb', 10240) * 1024) : ((int) config('messaging.attachments.max_file_kb', 25600) * 1024); abort_if($file->getSize() > $maxBytes, 422, 'Attachment exceeds allowed size.'); $year = now()->format('Y'); $month = now()->format('m'); $ext = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'bin'); $path = "messages/{$message->conversation_id}/{$year}/{$month}/" . uniqid('att_', true) . ".{$ext}"; $diskName = (string) config('messaging.attachments.disk', 'local'); Storage::disk($diskName)->put($path, file_get_contents($file->getPathname())); $width = null; $height = null; if ($type === 'image') { $dimensions = @getimagesize($file->getPathname()); $width = isset($dimensions[0]) ? (int) $dimensions[0] : null; $height = isset($dimensions[1]) ? (int) $dimensions[1] : null; } MessageAttachment::query()->create([ 'message_id' => $message->id, 'user_id' => $userId, 'type' => $type, 'mime' => $detectedMime, 'size_bytes' => (int) $file->getSize(), 'width' => $width, 'height' => $height, 'sha256' => hash_file('sha256', $file->getPathname()), 'original_name' => substr((string) $file->getClientOriginalName(), 0, 255), 'storage_path' => $path, 'created_at' => now(), ]); } }