Save workspace changes
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MessageAttachment;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AttachmentController extends Controller
|
||||
{
|
||||
public function show(Request $request, int $id)
|
||||
{
|
||||
$attachment = MessageAttachment::query()
|
||||
->with('message:id,conversation_id')
|
||||
->findOrFail($id);
|
||||
|
||||
$conversationId = (int) ($attachment->message?->conversation_id ?? 0);
|
||||
abort_if($conversationId <= 0, 404, 'Attachment not available.');
|
||||
|
||||
$authorized = \App\Models\ConversationParticipant::query()
|
||||
->where('conversation_id', $conversationId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->exists();
|
||||
|
||||
abort_unless($authorized, 403, 'You are not allowed to access this attachment.');
|
||||
|
||||
$diskName = (string) config('messaging.attachments.disk', 'local');
|
||||
$disk = Storage::disk($diskName);
|
||||
|
||||
return new StreamedResponse(function () use ($disk, $attachment): void {
|
||||
echo $disk->get($attachment->storage_path);
|
||||
}, 200, [
|
||||
'Content-Type' => $attachment->mime,
|
||||
'Content-Disposition' => 'inline; filename="' . addslashes($attachment->original_name) . '"',
|
||||
'Content-Length' => (string) $attachment->size_bytes,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
<?php
|
||||
|
||||
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\User;
|
||||
use App\Services\Messaging\ConversationReadService;
|
||||
use App\Services\Messaging\ConversationStateService;
|
||||
use App\Services\Messaging\SendMessageAction;
|
||||
use App\Services\Messaging\UnreadCounterService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ConversationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ConversationStateService $conversationState,
|
||||
private readonly ConversationReadService $conversationReads,
|
||||
private readonly SendMessageAction $sendMessage,
|
||||
private readonly UnreadCounterService $unreadCounters,
|
||||
) {}
|
||||
|
||||
// ── GET /api/messages/conversations ─────────────────────────────────────
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->integer('page', 1));
|
||||
$cacheVersion = (int) Cache::get($this->cacheVersionKey($user->id), 1);
|
||||
$cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion);
|
||||
|
||||
$conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) {
|
||||
$query = Conversation::query()
|
||||
->select('conversations.*')
|
||||
->join('conversation_participants as cp_me', function ($join) use ($user) {
|
||||
$join->on('cp_me.conversation_id', '=', 'conversations.id')
|
||||
->where('cp_me.user_id', '=', $user->id)
|
||||
->whereNull('cp_me.left_at');
|
||||
})
|
||||
->where('conversations.is_active', true)
|
||||
->with([
|
||||
'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']),
|
||||
'latestMessage.sender:id,username',
|
||||
])
|
||||
->orderByDesc('cp_me.is_pinned')
|
||||
->orderByDesc('cp_me.pinned_at')
|
||||
->orderByDesc('last_message_at')
|
||||
->orderByDesc('conversations.id');
|
||||
|
||||
$this->unreadCounters->applyUnreadCountSelect($query, $user, 'cp_me');
|
||||
|
||||
return $query->paginate(20, ['conversations.*'], 'page', $page);
|
||||
});
|
||||
|
||||
$conversations->through(function ($conv) use ($user) {
|
||||
$conv->my_participant = $conv->allParticipants
|
||||
->firstWhere('user_id', $user->id);
|
||||
return $conv;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
...$conversations->toArray(),
|
||||
'summary' => [
|
||||
'unread_total' => $this->unreadCounters->totalUnreadForUser($user),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── GET /api/messages/conversation/{id} ─────────────────────────────────
|
||||
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conv = $this->findAuthorized($request, $id);
|
||||
|
||||
$conv->load([
|
||||
'allParticipants.user:id,username',
|
||||
'creator:id,username',
|
||||
]);
|
||||
|
||||
return response()->json($conv);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/conversation ─────────────────────────────────────
|
||||
|
||||
public function store(StoreConversationRequest $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$data = $request->validated();
|
||||
|
||||
if ($data['type'] === 'direct') {
|
||||
return $this->createDirect($request, $user, $data);
|
||||
}
|
||||
|
||||
return $this->createGroup($request, $user, $data);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/read ────────────────────────────
|
||||
|
||||
public function markRead(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conversation = $this->findAuthorized($request, $id);
|
||||
$participant = $this->conversationReads->markConversationRead(
|
||||
$conversation,
|
||||
$request->user(),
|
||||
$request->integer('message_id') ?: null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'last_read_at' => optional($participant->last_read_at)?->toIso8601String(),
|
||||
'last_read_message_id' => $participant->last_read_message_id,
|
||||
'unread_total' => $this->unreadCounters->totalUnreadForUser($request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 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->broadcastConversationUpdate($conversation, 'conversation.archived');
|
||||
|
||||
return response()->json(['is_archived' => $participant->is_archived]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/mute ────────────────────────────
|
||||
|
||||
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->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->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->broadcastConversationUpdate($conversation, 'conversation.unpinned');
|
||||
|
||||
return response()->json(['is_pinned' => false]);
|
||||
}
|
||||
|
||||
// ── DELETE /api/messages/{conversation_id}/leave ─────────────────────────
|
||||
|
||||
public function leave(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conv = $this->findAuthorized($request, $id);
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
|
||||
if ($conv->isGroup()) {
|
||||
// Last admin protection
|
||||
$adminCount = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('role', 'admin')
|
||||
->whereNull('left_at')
|
||||
->count();
|
||||
|
||||
if ($adminCount === 1 && $participant->role === 'admin') {
|
||||
$otherMember = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', '!=', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->first();
|
||||
|
||||
if ($otherMember) {
|
||||
$otherMember->update(['role' => 'admin']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$participant->update(['left_at' => now()]);
|
||||
$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(ManageConversationParticipantRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$conv = $this->findAuthorized($request, $id);
|
||||
$this->requireAdmin($request, $id);
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
|
||||
$data = $request->validated();
|
||||
|
||||
$existing = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $data['user_id'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($existing->left_at) {
|
||||
$existing->update(['left_at' => null, 'joined_at' => now()]);
|
||||
}
|
||||
} else {
|
||||
ConversationParticipant::create([
|
||||
'conversation_id' => $id,
|
||||
'user_id' => $data['user_id'],
|
||||
'role' => 'member',
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$participantUserIds[] = (int) $data['user_id'];
|
||||
$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(ManageConversationParticipantRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$this->requireAdmin($request, $id);
|
||||
$data = $request->validated();
|
||||
|
||||
// Cannot remove the conversation creator
|
||||
$conv = Conversation::findOrFail($id);
|
||||
abort_if($conv->created_by === (int) $data['user_id'], 403, 'Cannot remove the conversation creator.');
|
||||
|
||||
$targetParticipant = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $data['user_id'])
|
||||
->whereNull('left_at')
|
||||
->first();
|
||||
|
||||
if ($targetParticipant && $targetParticipant->role === 'admin') {
|
||||
$adminCount = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('role', 'admin')
|
||||
->whereNull('left_at')
|
||||
->count();
|
||||
|
||||
abort_if($adminCount <= 1, 422, 'Cannot remove the last admin from this conversation.');
|
||||
}
|
||||
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
|
||||
ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $data['user_id'])
|
||||
->whereNull('left_at')
|
||||
->update(['left_at' => now()]);
|
||||
|
||||
$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(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->validated();
|
||||
$conv->update(['title' => $data['title']]);
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||
$this->broadcastConversationUpdate($conv, 'conversation.renamed', $participantUserIds);
|
||||
|
||||
return response()->json(['title' => $conv->title]);
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private function createDirect(Request $request, User $user, array $data): JsonResponse
|
||||
{
|
||||
$recipient = User::findOrFail($data['recipient_id']);
|
||||
|
||||
abort_if($recipient->id === $user->id, 422, 'You cannot message yourself.');
|
||||
|
||||
if (! $recipient->allowsMessagesFrom($user)) {
|
||||
abort(403, 'This user does not accept messages from you.');
|
||||
}
|
||||
|
||||
$this->assertNotBlockedBetween($user, $recipient);
|
||||
|
||||
// Reuse existing conversation if one exists
|
||||
$conv = Conversation::findDirect($user->id, $recipient->id);
|
||||
|
||||
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([
|
||||
['conversation_id' => $conv->id, 'user_id' => $user->id, 'role' => 'admin', 'joined_at' => now()],
|
||||
['conversation_id' => $conv->id, 'user_id' => $recipient->id, 'role' => 'member', 'joined_at' => now()],
|
||||
]);
|
||||
|
||||
return $conv;
|
||||
});
|
||||
}
|
||||
|
||||
$this->sendMessage->execute($conv, $user, [
|
||||
'body' => $data['body'],
|
||||
'client_temp_id' => $data['client_temp_id'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201);
|
||||
}
|
||||
|
||||
private function createGroup(Request $request, User $user, array $data): JsonResponse
|
||||
{
|
||||
$participantIds = array_unique(array_merge([$user->id], $data['participant_ids']));
|
||||
|
||||
$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) => [
|
||||
'conversation_id' => $conv->id,
|
||||
'user_id' => $uid,
|
||||
'role' => $uid === $user->id ? 'admin' : 'member',
|
||||
'joined_at' => now(),
|
||||
], $participantIds);
|
||||
|
||||
ConversationParticipant::insert($rows);
|
||||
|
||||
return $conv;
|
||||
});
|
||||
|
||||
$this->sendMessage->execute($conv, $user, [
|
||||
'body' => $data['body'],
|
||||
'client_temp_id' => $data['client_temp_id'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201);
|
||||
}
|
||||
|
||||
private function findAuthorized(Request $request, int $id): Conversation
|
||||
{
|
||||
$conv = Conversation::findOrFail($id);
|
||||
$this->authorize('view', $conv);
|
||||
return $conv;
|
||||
}
|
||||
|
||||
private function participantRecord(Request $request, int $conversationId): ConversationParticipant
|
||||
{
|
||||
return ConversationParticipant::where('conversation_id', $conversationId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function assertParticipant(Request $request, int $id): void
|
||||
{
|
||||
abort_unless(
|
||||
ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->exists(),
|
||||
403,
|
||||
'You are not a participant of this conversation.'
|
||||
);
|
||||
}
|
||||
|
||||
private function requireAdmin(Request $request, int $id): void
|
||||
{
|
||||
$conversation = Conversation::findOrFail($id);
|
||||
$this->authorize('manageParticipants', $conversation);
|
||||
}
|
||||
|
||||
private function touchConversationCachesForUsers(array $userIds): void
|
||||
{
|
||||
$this->conversationState->touchConversationCachesForUsers($userIds);
|
||||
}
|
||||
|
||||
private function cacheVersionKey(int $userId): string
|
||||
{
|
||||
return "messages:conversations:version:{$userId}";
|
||||
}
|
||||
|
||||
private function conversationListCacheKey(int $userId, int $page, int $version): string
|
||||
{
|
||||
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')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$blocked = false;
|
||||
|
||||
if (Schema::hasColumns('user_blocks', ['user_id', 'blocked_user_id'])) {
|
||||
$blocked = DB::table('user_blocks')
|
||||
->where(function ($q) use ($sender, $recipient) {
|
||||
$q->where('user_id', $sender->id)->where('blocked_user_id', $recipient->id);
|
||||
})
|
||||
->orWhere(function ($q) use ($sender, $recipient) {
|
||||
$q->where('user_id', $recipient->id)->where('blocked_user_id', $sender->id);
|
||||
})
|
||||
->exists();
|
||||
} elseif (Schema::hasColumns('user_blocks', ['blocker_id', 'blocked_id'])) {
|
||||
$blocked = DB::table('user_blocks')
|
||||
->where(function ($q) use ($sender, $recipient) {
|
||||
$q->where('blocker_id', $sender->id)->where('blocked_id', $recipient->id);
|
||||
})
|
||||
->orWhere(function ($q) use ($sender, $recipient) {
|
||||
$q->where('blocker_id', $recipient->id)->where('blocked_id', $sender->id);
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
|
||||
abort_if($blocked, 403, 'Messaging is not available between these users.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Events\ConversationUpdated;
|
||||
use App\Events\MessageDeleted;
|
||||
use App\Events\MessageUpdated;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Messaging\StoreMessageRequest;
|
||||
use App\Http\Requests\Messaging\ToggleMessageReactionRequest;
|
||||
use App\Http\Requests\Messaging\UpdateMessageRequest;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\MessageReaction;
|
||||
use App\Services\Messaging\ConversationDeltaService;
|
||||
use App\Services\Messaging\ConversationStateService;
|
||||
use App\Services\Messaging\MessagingPayloadFactory;
|
||||
use App\Services\Messaging\MessageSearchIndexer;
|
||||
use App\Services\Messaging\SendMessageAction;
|
||||
use App\Services\Messaging\UnreadCounterService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MessageController extends Controller
|
||||
{
|
||||
private const PAGE_SIZE = 30;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConversationDeltaService $conversationDelta,
|
||||
private readonly ConversationStateService $conversationState,
|
||||
private readonly MessagingPayloadFactory $payloadFactory,
|
||||
private readonly SendMessageAction $sendMessage,
|
||||
private readonly UnreadCounterService $unreadCounters,
|
||||
) {}
|
||||
|
||||
// ── GET /api/messages/{conversation_id} ──────────────────────────────────
|
||||
|
||||
public function index(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$conversation = $this->findConversationOrFail($conversationId);
|
||||
$cursor = $request->integer('cursor') ?: $request->integer('before_id');
|
||||
$afterId = $request->integer('after_id');
|
||||
|
||||
if ($afterId) {
|
||||
$messages = $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterId);
|
||||
|
||||
return response()->json([
|
||||
'data' => $messages,
|
||||
'next_cursor' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$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->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id))->values(),
|
||||
'next_cursor' => $nextCursor,
|
||||
]);
|
||||
}
|
||||
|
||||
public function delta(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$conversation = $this->findConversationOrFail($conversationId);
|
||||
$afterMessageId = max(0, (int) $request->integer('after_message_id'));
|
||||
|
||||
abort_if($afterMessageId < 1, 422, 'after_message_id is required.');
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterMessageId),
|
||||
'conversation' => $this->payloadFactory->conversationSummary($conversation->fresh(), (int) $request->user()->id),
|
||||
'summary' => [
|
||||
'unread_total' => $this->unreadCounters->totalUnreadForUser($request->user()),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id} ─────────────────────────────────
|
||||
|
||||
public function store(StoreMessageRequest $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$conversation = $this->findConversationOrFail($conversationId);
|
||||
$data = $request->validated();
|
||||
$data['attachments'] = $request->file('attachments', []);
|
||||
|
||||
$body = trim((string) ($data['body'] ?? ''));
|
||||
abort_if($body === '' && empty($data['attachments']), 422, 'Message body or attachment is required.');
|
||||
|
||||
$message = $this->sendMessage->execute($conversation, $request->user(), $data);
|
||||
|
||||
return response()->json($this->payloadFactory->message($message, (int) $request->user()->id), 201);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/react ───────────────────────────
|
||||
|
||||
public function react(ToggleMessageReactionRequest $request, int $conversationId, int $messageId): JsonResponse
|
||||
{
|
||||
$this->findConversationOrFail($conversationId);
|
||||
$data = $request->validated();
|
||||
$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(ToggleMessageReactionRequest $request, int $conversationId, int $messageId): JsonResponse
|
||||
{
|
||||
$this->findConversationOrFail($conversationId);
|
||||
$data = $request->validated();
|
||||
$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(ToggleMessageReactionRequest $request, int $messageId): JsonResponse
|
||||
{
|
||||
$message = Message::query()->findOrFail($messageId);
|
||||
$this->findConversationOrFail((int) $message->conversation_id);
|
||||
$data = $request->validated();
|
||||
$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(ToggleMessageReactionRequest $request, int $messageId): JsonResponse
|
||||
{
|
||||
$message = Message::query()->findOrFail($messageId);
|
||||
$this->findConversationOrFail((int) $message->conversation_id);
|
||||
$data = $request->validated();
|
||||
$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(UpdateMessageRequest $request, int $messageId): JsonResponse
|
||||
{
|
||||
$message = Message::findOrFail($messageId);
|
||||
|
||||
$this->authorize('update', $message);
|
||||
|
||||
abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted message.');
|
||||
|
||||
$data = $request->validated();
|
||||
|
||||
$message->update([
|
||||
'body' => $data['body'],
|
||||
'edited_at' => now(),
|
||||
]);
|
||||
app(MessageSearchIndexer::class)->updateMessage($message);
|
||||
|
||||
$participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
|
||||
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
DB::afterCommit(function () use ($message, $participantUserIds): void {
|
||||
event(new MessageUpdated($message->fresh(['sender:id,username,name', 'attachments', 'reactions'])));
|
||||
|
||||
$conversation = Conversation::find($message->conversation_id);
|
||||
if ($conversation) {
|
||||
foreach ($participantUserIds as $participantId) {
|
||||
event(new ConversationUpdated((int) $participantId, $conversation, 'message.updated'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json($this->payloadFactory->message($message->fresh(['sender:id,username,name', 'attachments', 'reactions']), (int) $request->user()->id));
|
||||
}
|
||||
|
||||
// ── DELETE /api/messages/message/{messageId} ──────────────────────────────
|
||||
|
||||
public function destroy(Request $request, int $messageId): JsonResponse
|
||||
{
|
||||
$message = Message::findOrFail($messageId);
|
||||
|
||||
$this->authorize('delete', $message);
|
||||
|
||||
$participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
|
||||
app(MessageSearchIndexer::class)->deleteMessage($message);
|
||||
$message->delete();
|
||||
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
DB::afterCommit(function () use ($message, $participantUserIds): void {
|
||||
$message->refresh();
|
||||
event(new MessageDeleted($message));
|
||||
|
||||
$conversation = Conversation::find($message->conversation_id);
|
||||
if ($conversation) {
|
||||
foreach ($participantUserIds as $participantId) {
|
||||
event(new ConversationUpdated((int) $participantId, $conversation, 'message.deleted'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
{
|
||||
$this->conversationState->touchConversationCachesForUsers($userIds);
|
||||
}
|
||||
|
||||
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 findConversationOrFail(int $conversationId): Conversation
|
||||
{
|
||||
$conversation = Conversation::query()->findOrFail($conversationId);
|
||||
$this->authorize('view', $conversation);
|
||||
|
||||
return $conversation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?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());
|
||||
|
||||
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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Manages per-user messaging privacy preference.
|
||||
*
|
||||
* GET /api/messages/settings → return current setting
|
||||
* PATCH /api/messages/settings → update setting
|
||||
*/
|
||||
class MessagingSettingsController extends Controller
|
||||
{
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$realtimeReady = (bool) config('messaging.realtime', false)
|
||||
&& config('broadcasting.default') === 'reverb'
|
||||
&& filled(config('broadcasting.connections.reverb.key'));
|
||||
|
||||
return response()->json([
|
||||
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone',
|
||||
'realtime_enabled' => $realtimeReady,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'allow_messages_from' => 'required|in:everyone,followers,mutual_followers,nobody',
|
||||
]);
|
||||
|
||||
$request->user()->update($data);
|
||||
|
||||
return response()->json([
|
||||
'allow_messages_from' => $request->user()->allow_messages_from,
|
||||
'realtime_enabled' => (bool) config('messaging.realtime', false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Conversation;
|
||||
use App\Services\Messaging\MessagingPresenceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PresenceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessagingPresenceService $presence,
|
||||
) {}
|
||||
|
||||
public function heartbeat(Request $request): JsonResponse
|
||||
{
|
||||
$conversationId = $request->integer('conversation_id') ?: null;
|
||||
|
||||
if ($conversationId) {
|
||||
$conversation = Conversation::query()->findOrFail($conversationId);
|
||||
$this->authorize('view', $conversation);
|
||||
}
|
||||
|
||||
$this->presence->touch($request->user(), $conversationId);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'conversation_id' => $conversationId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Events\TypingStarted;
|
||||
use App\Events\TypingStopped;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use Illuminate\Cache\Repository;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class TypingController extends Controller
|
||||
{
|
||||
public function start(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->findConversationOrFail($conversationId);
|
||||
|
||||
$ttl = max(5, (int) config('messaging.typing.ttl_seconds', 8));
|
||||
$this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl));
|
||||
|
||||
if ((bool) config('messaging.realtime', false)) {
|
||||
event(new TypingStarted($conversationId, $request->user()));
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function stop(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->findConversationOrFail($conversationId);
|
||||
$this->store()->forget($this->key($conversationId, (int) $request->user()->id));
|
||||
|
||||
if ((bool) config('messaging.realtime', false)) {
|
||||
event(new TypingStopped($conversationId, $request->user()));
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function index(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->findConversationOrFail($conversationId);
|
||||
$userId = (int) $request->user()->id;
|
||||
|
||||
$participants = ConversationParticipant::query()
|
||||
->where('conversation_id', $conversationId)
|
||||
->whereNull('left_at')
|
||||
->where('user_id', '!=', $userId)
|
||||
->with('user:id,username')
|
||||
->get();
|
||||
|
||||
$typing = $participants
|
||||
->filter(fn ($p) => $this->store()->has($this->key($conversationId, (int) $p->user_id)))
|
||||
->map(fn ($p) => [
|
||||
'user_id' => (int) $p->user_id,
|
||||
'username' => (string) ($p->user->username ?? ''),
|
||||
])
|
||||
->values();
|
||||
|
||||
return response()->json(['typing' => $typing]);
|
||||
}
|
||||
|
||||
private function assertParticipant(Request $request, int $conversationId): void
|
||||
{
|
||||
abort_unless(
|
||||
ConversationParticipant::query()
|
||||
->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 key(int $conversationId, int $userId): string
|
||||
{
|
||||
return "typing:{$conversationId}:{$userId}";
|
||||
}
|
||||
|
||||
private function store(): Repository
|
||||
{
|
||||
$store = (string) config('messaging.typing.cache_store', 'redis');
|
||||
if ($store === 'redis' && ! class_exists('Redis')) {
|
||||
return Cache::store();
|
||||
}
|
||||
|
||||
try {
|
||||
return Cache::store($store);
|
||||
} catch (\Throwable) {
|
||||
return Cache::store();
|
||||
}
|
||||
}
|
||||
|
||||
private function findConversationOrFail(int $conversationId): Conversation
|
||||
{
|
||||
$conversation = Conversation::query()->findOrFail($conversationId);
|
||||
$this->authorize('view', $conversation);
|
||||
|
||||
return $conversation;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user