303 lines
12 KiB
PHP
303 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\ConversationStateService;
|
|
use App\Services\Messaging\MessagingPayloadFactory;
|
|
use App\Services\Messaging\MessageSearchIndexer;
|
|
use App\Services\Messaging\SendMessageAction;
|
|
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 ConversationStateService $conversationState,
|
|
private readonly MessagingPayloadFactory $payloadFactory,
|
|
private readonly SendMessageAction $sendMessage,
|
|
) {}
|
|
|
|
// ── 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 = Message::withTrashed()
|
|
->where('conversation_id', $conversationId)
|
|
->with(['sender:id,username', 'reactions', 'attachments'])
|
|
->where('id', '>', $afterId)
|
|
->orderBy('id')
|
|
->limit(100)
|
|
->get()
|
|
->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id))
|
|
->values();
|
|
|
|
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,
|
|
]);
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
}
|