469 lines
18 KiB
PHP
469 lines
18 KiB
PHP
<?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.');
|
|
}
|
|
}
|