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

352 lines
13 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Events\MessageSent;
use App\Http\Controllers\Controller;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\MessageAttachment;
use App\Models\MessageReaction;
use App\Services\Messaging\MessageSearchIndexer;
use App\Services\Messaging\MessageNotificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
class MessageController extends Controller
{
private const PAGE_SIZE = 30;
// ── GET /api/messages/{conversation_id} ──────────────────────────────────
public function index(Request $request, int $conversationId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$cursor = $request->integer('cursor');
$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,
'next_cursor' => $nextCursor,
]);
}
// ── POST /api/messages/{conversation_id} ─────────────────────────────────
public function store(Request $request, int $conversationId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$data = $request->validate([
'body' => 'nullable|string|max:5000',
'attachments' => 'sometimes|array|max:5',
'attachments.*' => 'file|max:25600',
]);
$body = trim((string) ($data['body'] ?? ''));
$files = $request->file('attachments', []);
abort_if($body === '' && empty($files), 422, 'Message body or attachment is required.');
$message = Message::create([
'conversation_id' => $conversationId,
'sender_id' => $request->user()->id,
'body' => $body,
]);
foreach ($files as $file) {
if ($file instanceof UploadedFile) {
$this->storeAttachment($file, $message, (int) $request->user()->id);
}
}
Conversation::where('id', $conversationId)
->update(['last_message_at' => $message->created_at]);
$conversation = Conversation::findOrFail($conversationId);
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $request->user());
app(MessageSearchIndexer::class)->indexMessage($message);
event(new MessageSent($conversationId, $message->id, $request->user()->id));
$participantUserIds = ConversationParticipant::where('conversation_id', $conversationId)
->whereNull('left_at')
->pluck('user_id')
->all();
$this->touchConversationCachesForUsers($participantUserIds);
$message->load(['sender:id,username', 'attachments']);
return response()->json($message, 201);
}
// ── POST /api/messages/{conversation_id}/react ───────────────────────────
public function react(Request $request, int $conversationId, int $messageId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$data = $request->validate(['reaction' => 'required|string|max:32']);
$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(Request $request, int $conversationId, int $messageId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$data = $request->validate(['reaction' => 'required|string|max:32']);
$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(Request $request, int $messageId): JsonResponse
{
$message = Message::query()->findOrFail($messageId);
$this->assertParticipant($request, (int) $message->conversation_id);
$data = $request->validate(['reaction' => 'required|string|max:32']);
$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(Request $request, int $messageId): JsonResponse
{
$message = Message::query()->findOrFail($messageId);
$this->assertParticipant($request, (int) $message->conversation_id);
$data = $request->validate(['reaction' => 'required|string|max:32']);
$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(Request $request, int $messageId): JsonResponse
{
$message = Message::findOrFail($messageId);
abort_unless(
$message->sender_id === $request->user()->id,
403,
'You may only edit your own messages.'
);
abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted message.');
$data = $request->validate(['body' => 'required|string|max:5000']);
$message->update([
'body' => $data['body'],
'edited_at' => now(),
]);
app(MessageSearchIndexer::class)->updateMessage($message);
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id)
->whereNull('left_at')
->pluck('user_id')
->all();
$this->touchConversationCachesForUsers($participantUserIds);
return response()->json($message->fresh());
}
// ── DELETE /api/messages/message/{messageId} ──────────────────────────────
public function destroy(Request $request, int $messageId): JsonResponse
{
$message = Message::findOrFail($messageId);
abort_unless(
$message->sender_id === $request->user()->id || $request->user()->isAdmin(),
403,
'You may only delete your own messages.'
);
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id)
->whereNull('left_at')
->pluck('user_id')
->all();
app(MessageSearchIndexer::class)->deleteMessage($message);
$message->delete();
$this->touchConversationCachesForUsers($participantUserIds);
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
{
foreach (array_unique($userIds) as $userId) {
if (! $userId) {
continue;
}
$versionKey = "messages:conversations:version:{$userId}";
Cache::add($versionKey, 1, now()->addDay());
Cache::increment($versionKey);
}
}
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 storeAttachment(UploadedFile $file, Message $message, int $userId): void
{
$mime = (string) $file->getMimeType();
$finfoMime = (string) finfo_file(finfo_open(FILEINFO_MIME_TYPE), $file->getPathname());
$detectedMime = $finfoMime !== '' ? $finfoMime : $mime;
$allowedImage = (array) config('messaging.attachments.allowed_image_mimes', []);
$allowedFile = (array) config('messaging.attachments.allowed_file_mimes', []);
$type = in_array($detectedMime, $allowedImage, true) ? 'image' : 'file';
$allowed = $type === 'image' ? $allowedImage : $allowedFile;
abort_unless(in_array($detectedMime, $allowed, true), 422, 'Unsupported attachment type.');
$maxBytes = $type === 'image'
? ((int) config('messaging.attachments.max_image_kb', 10240) * 1024)
: ((int) config('messaging.attachments.max_file_kb', 25600) * 1024);
abort_if($file->getSize() > $maxBytes, 422, 'Attachment exceeds allowed size.');
$year = now()->format('Y');
$month = now()->format('m');
$ext = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'bin');
$path = "messages/{$message->conversation_id}/{$year}/{$month}/" . uniqid('att_', true) . ".{$ext}";
$diskName = (string) config('messaging.attachments.disk', 'local');
Storage::disk($diskName)->put($path, file_get_contents($file->getPathname()));
$width = null;
$height = null;
if ($type === 'image') {
$dimensions = @getimagesize($file->getPathname());
$width = isset($dimensions[0]) ? (int) $dimensions[0] : null;
$height = isset($dimensions[1]) ? (int) $dimensions[1] : null;
}
MessageAttachment::query()->create([
'message_id' => $message->id,
'user_id' => $userId,
'type' => $type,
'mime' => $detectedMime,
'size_bytes' => (int) $file->getSize(),
'width' => $width,
'height' => $height,
'sha256' => hash_file('sha256', $file->getPathname()),
'original_name' => substr((string) $file->getClientOriginalName(), 0, 255),
'storage_path' => $path,
'created_at' => now(),
]);
}
}