feat: add Reverb realtime messaging
This commit is contained in:
@@ -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')) {
|
||||
|
||||
Reference in New Issue
Block a user