feat: add Reverb realtime messaging

This commit is contained in:
2026-03-21 12:51:59 +01:00
parent 60f78e8235
commit e8b5edf5d2
45 changed files with 3609 additions and 339 deletions

View File

@@ -2,12 +2,17 @@
namespace App\Http\Controllers\Api\Messaging;
use App\Events\ConversationUpdated;
use App\Http\Controllers\Controller;
use App\Http\Requests\Messaging\ManageConversationParticipantRequest;
use App\Http\Requests\Messaging\RenameConversationRequest;
use App\Http\Requests\Messaging\StoreConversationRequest;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
use App\Services\Messaging\MessageNotificationService;
use App\Services\Messaging\ConversationStateService;
use App\Services\Messaging\SendMessageAction;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@@ -16,6 +21,11 @@ use Illuminate\Support\Facades\Schema;
class ConversationController extends Controller
{
public function __construct(
private readonly ConversationStateService $conversationState,
private readonly SendMessageAction $sendMessage,
) {}
// ── GET /api/messages/conversations ─────────────────────────────────────
public function index(Request $request): JsonResponse
@@ -40,10 +50,13 @@ class ConversationController extends Controller
->where('messages.sender_id', '!=', $user->id)
->whereNull('messages.deleted_at')
->where(function ($query) {
$query->whereNull('cp_me.last_read_at')
$query->whereNull('cp_me.last_read_message_id')
->whereNull('cp_me.last_read_at')
->orWhereColumn('messages.id', '>', 'cp_me.last_read_message_id')
->orWhereColumn('messages.created_at', '>', 'cp_me.last_read_at');
}),
])
->where('conversations.is_active', true)
->with([
'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']),
'latestMessage.sender:id,username',
@@ -80,18 +93,10 @@ class ConversationController extends Controller
// ── POST /api/messages/conversation ─────────────────────────────────────
public function store(Request $request): JsonResponse
public function store(StoreConversationRequest $request): JsonResponse
{
$user = $request->user();
$data = $request->validate([
'type' => 'required|in:direct,group',
'recipient_id' => 'required_if:type,direct|integer|exists:users,id',
'participant_ids' => 'required_if:type,group|array|min:2',
'participant_ids.*'=> 'integer|exists:users,id',
'title' => 'required_if:type,group|nullable|string|max:120',
'body' => 'required|string|max:5000',
]);
$data = $request->validated();
if ($data['type'] === 'direct') {
return $this->createDirect($request, $user, $data);
@@ -104,20 +109,28 @@ class ConversationController extends Controller
public function markRead(Request $request, int $id): JsonResponse
{
$participant = $this->participantRecord($request, $id);
$participant->update(['last_read_at' => now()]);
$this->touchConversationCachesForUsers([$request->user()->id]);
$conversation = $this->findAuthorized($request, $id);
$participant = $this->conversationState->markConversationRead(
$conversation,
$request->user(),
$request->integer('message_id') ?: null,
);
return response()->json(['ok' => true]);
return response()->json([
'ok' => true,
'last_read_at' => optional($participant->last_read_at)?->toIso8601String(),
'last_read_message_id' => $participant->last_read_message_id,
]);
}
// ── 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->touchConversationCachesForUsers([$request->user()->id]);
$this->broadcastConversationUpdate($conversation, 'conversation.archived');
return response()->json(['is_archived' => $participant->is_archived]);
}
@@ -126,27 +139,30 @@ class ConversationController extends Controller
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->touchConversationCachesForUsers([$request->user()->id]);
$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->touchConversationCachesForUsers([$request->user()->id]);
$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->touchConversationCachesForUsers([$request->user()->id]);
$this->broadcastConversationUpdate($conversation, 'conversation.unpinned');
return response()->json(['is_pinned' => false]);
}
@@ -182,14 +198,15 @@ class ConversationController extends Controller
}
$participant->update(['left_at' => now()]);
$this->touchConversationCachesForUsers($participantUserIds);
$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(Request $request, int $id): JsonResponse
public function addUser(ManageConversationParticipantRequest $request, int $id): JsonResponse
{
$conv = $this->findAuthorized($request, $id);
$this->requireAdmin($request, $id);
@@ -198,9 +215,7 @@ class ConversationController extends Controller
->pluck('user_id')
->all();
$data = $request->validate([
'user_id' => 'required|integer|exists:users,id',
]);
$data = $request->validated();
$existing = ConversationParticipant::where('conversation_id', $id)
->where('user_id', $data['user_id'])
@@ -220,20 +235,18 @@ class ConversationController extends Controller
}
$participantUserIds[] = (int) $data['user_id'];
$this->touchConversationCachesForUsers($participantUserIds);
$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(Request $request, int $id): JsonResponse
public function removeUser(ManageConversationParticipantRequest $request, int $id): JsonResponse
{
$this->requireAdmin($request, $id);
$data = $request->validate([
'user_id' => 'required|integer',
]);
$data = $request->validated();
// Cannot remove the conversation creator
$conv = Conversation::findOrFail($id);
@@ -263,26 +276,28 @@ class ConversationController extends Controller
->whereNull('left_at')
->update(['left_at' => now()]);
$this->touchConversationCachesForUsers($participantUserIds);
$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(Request $request, int $id): JsonResponse
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->validate(['title' => 'required|string|max:120']);
$data = $request->validated();
$conv->update(['title' => $data['title']]);
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
->whereNull('left_at')
->pluck('user_id')
->all();
$this->touchConversationCachesForUsers($participantUserIds);
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.renamed', $participantUserIds);
return response()->json(['title' => $conv->title]);
}
@@ -307,8 +322,10 @@ class ConversationController extends Controller
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([
@@ -320,17 +337,12 @@ class ConversationController extends Controller
});
}
// Insert first / next message
$message = $conv->messages()->create([
'sender_id' => $user->id,
'body' => $data['body'],
$this->sendMessage->execute($conv, $user, [
'body' => $data['body'],
'client_temp_id' => $data['client_temp_id'] ?? null,
]);
$conv->update(['last_message_at' => $message->created_at]);
app(MessageNotificationService::class)->notifyNewMessage($conv, $message, $user);
$this->touchConversationCachesForUsers([$user->id, $recipient->id]);
return response()->json($conv->load('allParticipants.user:id,username'), 201);
return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201);
}
private function createGroup(Request $request, User $user, array $data): JsonResponse
@@ -339,9 +351,11 @@ class ConversationController extends Controller
$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) => [
@@ -353,27 +367,21 @@ class ConversationController extends Controller
ConversationParticipant::insert($rows);
$message = $conv->messages()->create([
'sender_id' => $user->id,
'body' => $data['body'],
]);
$conv->update(['last_message_at' => $message->created_at]);
return [$conv, $message];
return $conv;
});
[$conversation, $message] = $conv;
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $user);
$this->touchConversationCachesForUsers($participantIds);
$this->sendMessage->execute($conv, $user, [
'body' => $data['body'],
'client_temp_id' => $data['client_temp_id'] ?? null,
]);
return response()->json($conversation->load('allParticipants.user:id,username'), 201);
return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201);
}
private function findAuthorized(Request $request, int $id): Conversation
{
$conv = Conversation::findOrFail($id);
$this->assertParticipant($request, $id);
$this->authorize('view', $conv);
return $conv;
}
@@ -399,28 +407,13 @@ class ConversationController extends Controller
private function requireAdmin(Request $request, int $id): void
{
abort_unless(
ConversationParticipant::where('conversation_id', $id)
->where('user_id', $request->user()->id)
->where('role', 'admin')
->whereNull('left_at')
->exists(),
403,
'Only admins can perform this action.'
);
$conversation = Conversation::findOrFail($id);
$this->authorize('manageParticipants', $conversation);
}
private function touchConversationCachesForUsers(array $userIds): void
{
foreach (array_unique($userIds) as $userId) {
if (! $userId) {
continue;
}
$versionKey = $this->cacheVersionKey((int) $userId);
Cache::add($versionKey, 1, now()->addDay());
Cache::increment($versionKey);
}
$this->conversationState->touchConversationCachesForUsers($userIds);
}
private function cacheVersionKey(int $userId): string
@@ -433,6 +426,16 @@ class ConversationController extends Controller
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')) {