Files
SkinbaseNova/app/Http/Controllers/Api/Messaging/ConversationController.php
2026-02-26 21:12:32 +01:00

467 lines
18 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Controller;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
use App\Services\Messaging\MessageNotificationService;
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
{
// ── 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) {
return 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');
})
->addSelect([
'unread_count' => Message::query()
->selectRaw('count(*)')
->whereColumn('messages.conversation_id', 'conversations.id')
->where('messages.sender_id', '!=', $user->id)
->whereNull('messages.deleted_at')
->where(function ($query) {
$query->whereNull('cp_me.last_read_at')
->orWhereColumn('messages.created_at', '>', 'cp_me.last_read_at');
}),
])
->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')
->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);
}
// ── 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(Request $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',
]);
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
{
$participant = $this->participantRecord($request, $id);
$participant->update(['last_read_at' => now()]);
$this->touchConversationCachesForUsers([$request->user()->id]);
return response()->json(['ok' => true]);
}
// ── POST /api/messages/{conversation_id}/archive ─────────────────────────
public function archive(Request $request, int $id): JsonResponse
{
$participant = $this->participantRecord($request, $id);
$participant->update(['is_archived' => ! $participant->is_archived]);
$this->touchConversationCachesForUsers([$request->user()->id]);
return response()->json(['is_archived' => $participant->is_archived]);
}
// ── POST /api/messages/{conversation_id}/mute ────────────────────────────
public function mute(Request $request, int $id): JsonResponse
{
$participant = $this->participantRecord($request, $id);
$participant->update(['is_muted' => ! $participant->is_muted]);
$this->touchConversationCachesForUsers([$request->user()->id]);
return response()->json(['is_muted' => $participant->is_muted]);
}
public function pin(Request $request, int $id): JsonResponse
{
$participant = $this->participantRecord($request, $id);
$participant->update(['is_pinned' => true, 'pinned_at' => now()]);
$this->touchConversationCachesForUsers([$request->user()->id]);
return response()->json(['is_pinned' => true]);
}
public function unpin(Request $request, int $id): JsonResponse
{
$participant = $this->participantRecord($request, $id);
$participant->update(['is_pinned' => false, 'pinned_at' => null]);
$this->touchConversationCachesForUsers([$request->user()->id]);
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->touchConversationCachesForUsers($participantUserIds);
return response()->json(['ok' => true]);
}
// ── POST /api/messages/{conversation_id}/add-user ────────────────────────
public function addUser(Request $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->validate([
'user_id' => 'required|integer|exists:users,id',
]);
$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->touchConversationCachesForUsers($participantUserIds);
return response()->json(['ok' => true]);
}
// ── DELETE /api/messages/{conversation_id}/remove-user ───────────────────
public function removeUser(Request $request, int $id): JsonResponse
{
$this->requireAdmin($request, $id);
$data = $request->validate([
'user_id' => 'required|integer',
]);
// 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->touchConversationCachesForUsers($participantUserIds);
return response()->json(['ok' => true]);
}
// ── POST /api/messages/{conversation_id}/rename ──────────────────────────
public function rename(Request $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']);
$conv->update(['title' => $data['title']]);
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
->whereNull('left_at')
->pluck('user_id')
->all();
$this->touchConversationCachesForUsers($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([
'type' => 'direct',
'created_by' => $user->id,
]);
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;
});
}
// Insert first / next message
$message = $conv->messages()->create([
'sender_id' => $user->id,
'body' => $data['body'],
]);
$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);
}
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([
'type' => 'group',
'title' => $data['title'],
'created_by' => $user->id,
]);
$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);
$message = $conv->messages()->create([
'sender_id' => $user->id,
'body' => $data['body'],
]);
$conv->update(['last_message_at' => $message->created_at]);
return [$conv, $message];
});
[$conversation, $message] = $conv;
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $user);
$this->touchConversationCachesForUsers($participantIds);
return response()->json($conversation->load('allParticipants.user:id,username'), 201);
}
private function findAuthorized(Request $request, int $id): Conversation
{
$conv = Conversation::findOrFail($id);
$this->assertParticipant($request, $id);
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
{
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.'
);
}
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);
}
}
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 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.');
}
}