Files
SkinbaseNova/app/Http/Controllers/Api/Messaging/MessageController.php
2026-03-28 19:15:39 +01:00

315 lines
12 KiB
PHP

<?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;
}
}