messages implemented

This commit is contained in:
2026-02-26 21:12:32 +01:00
parent d0aefc5ddc
commit 15b7b77d20
168 changed files with 14728 additions and 6786 deletions

View File

@@ -0,0 +1,139 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Controller;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Services\Messaging\MessageSearchIndexer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Meilisearch\Client;
class MessageSearchController extends Controller
{
public function __construct(
private readonly MessageSearchIndexer $indexer,
) {}
public function index(Request $request): JsonResponse
{
$user = $request->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());
} catch (\Throwable) {
$query = Message::query()
->select('id')
->whereNull('deleted_at')
->whereIn('conversation_id', $allowedConversationIds)
->when($conversationId !== null, fn ($q) => $q->where('conversation_id', $conversationId))
->where('body', 'like', '%' . (string) $data['q'] . '%')
->orderByDesc('created_at')
->orderByDesc('id');
$estimated = (clone $query)->count();
$hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]);
}
$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,
]);
}
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']);
}
}