user(); $data = $request->validate([ 'q' => 'required|string|min:1|max:200', 'conversation_id' => 'nullable|integer|exists:conversations,id', 'cursor' => 'nullable|integer|min:0', ]); $allowedConversationIds = ConversationParticipant::query() ->where('user_id', $user->id) ->whereNull('left_at') ->pluck('conversation_id') ->map(fn ($id) => (int) $id) ->all(); $conversationId = isset($data['conversation_id']) ? (int) $data['conversation_id'] : null; if ($conversationId !== null && ! in_array($conversationId, $allowedConversationIds, true)) { abort(403, 'You are not a participant of this conversation.'); } if (empty($allowedConversationIds)) { return response()->json(['data' => [], 'next_cursor' => null]); } $limit = max(1, (int) config('messaging.search.page_size', 20)); $offset = max(0, (int) ($data['cursor'] ?? 0)); $hits = collect(); $estimated = 0; try { $client = new Client( config('scout.meilisearch.host'), config('scout.meilisearch.key') ); $prefix = (string) config('scout.prefix', ''); $indexName = $prefix . (string) config('messaging.search.index', 'messages'); $conversationFilter = $conversationId !== null ? "conversation_id = {$conversationId}" : 'conversation_id IN [' . implode(',', $allowedConversationIds) . ']'; $result = $client ->index($indexName) ->search((string) $data['q'], [ 'limit' => $limit, 'offset' => $offset, 'sort' => ['created_at:desc'], 'filter' => $conversationFilter, ]); $hits = collect($result->getHits() ?? []); $estimated = (int) ($result->getEstimatedTotalHits() ?? $hits->count()); if ($hits->isEmpty()) { [$hits, $estimated] = $this->fallbackHits($allowedConversationIds, $conversationId, (string) $data['q'], $offset, $limit); } } catch (\Throwable) { [$hits, $estimated] = $this->fallbackHits($allowedConversationIds, $conversationId, (string) $data['q'], $offset, $limit); } $messageIds = $hits->pluck('id')->map(fn ($id) => (int) $id)->all(); $messages = Message::query() ->whereIn('id', $messageIds) ->whereIn('conversation_id', $allowedConversationIds) ->whereNull('deleted_at') ->with(['sender:id,username', 'attachments']) ->get() ->keyBy('id'); $ordered = $hits ->map(function (array $hit) use ($messages) { $message = $messages->get((int) ($hit['id'] ?? 0)); if (! $message) { return null; } return [ 'id' => $message->id, 'conversation_id' => $message->conversation_id, 'sender_id' => $message->sender_id, 'sender' => $message->sender, 'body' => $message->body, 'created_at' => optional($message->created_at)?->toISOString(), 'has_attachments' => $message->attachments->isNotEmpty(), ]; }) ->filter() ->values(); $nextCursor = ($offset + $limit) < $estimated ? ($offset + $limit) : null; return response()->json([ 'data' => $ordered, 'next_cursor' => $nextCursor, ]); } private function fallbackHits(array $allowedConversationIds, ?int $conversationId, string $queryString, int $offset, int $limit): array { $query = Message::query() ->select('id') ->whereNull('deleted_at') ->whereIn('conversation_id', $allowedConversationIds) ->when($conversationId !== null, fn ($builder) => $builder->where('conversation_id', $conversationId)) ->where('body', 'like', '%' . $queryString . '%') ->orderByDesc('created_at') ->orderByDesc('id'); $estimated = (clone $query)->count(); $hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]); return [$hits, $estimated]; } public function rebuild(Request $request): JsonResponse { abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.'); $conversationId = $request->integer('conversation_id'); if ($conversationId > 0) { $this->indexer->rebuildConversation($conversationId); return response()->json(['queued' => true, 'scope' => 'conversation']); } $this->indexer->rebuildAll(); return response()->json(['queued' => true, 'scope' => 'all']); } }