messages implemented

This commit is contained in:
2026-02-26 21:12:32 +01:00
parent d0aefc5ddc
commit 15b7b77d20
168 changed files with 14728 additions and 6786 deletions

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\Report;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class ModerationReportQueueController extends Controller
{
public function index(Request $request): JsonResponse
{
$status = (string) $request->query('status', 'open');
$status = in_array($status, ['open', 'reviewing', 'closed'], true) ? $status : 'open';
$items = Report::query()
->with('reporter:id,username')
->where('status', $status)
->orderByDesc('id')
->paginate(30);
return response()->json($items);
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Services\ContentSanitizer;
use App\Services\LegacySmileyMapper;
use App\Support\AvatarUrl;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
/**
* Artwork comment CRUD.
*
* POST /api/artworks/{artworkId}/comments store
* PUT /api/artworks/{artworkId}/comments/{id} update (own comment)
* DELETE /api/artworks/{artworkId}/comments/{id} delete (own or admin)
* GET /api/artworks/{artworkId}/comments list (paginated)
*/
class ArtworkCommentController extends Controller
{
private const MAX_LENGTH = 10_000;
// ─────────────────────────────────────────────────────────────────────────
// List
// ─────────────────────────────────────────────────────────────────────────
public function index(Request $request, int $artworkId): JsonResponse
{
$artwork = Artwork::public()->published()->findOrFail($artworkId);
$page = max(1, (int) $request->query('page', 1));
$perPage = 20;
$comments = ArtworkComment::with(['user', 'user.profile'])
->where('artwork_id', $artwork->id)
->where('is_approved', true)
->orderByDesc('created_at')
->paginate($perPage, ['*'], 'page', $page);
$userId = $request->user()?->id;
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId));
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $comments->currentPage(),
'last_page' => $comments->lastPage(),
'total' => $comments->total(),
'per_page' => $comments->perPage(),
],
]);
}
// ─────────────────────────────────────────────────────────────────────────
// Store
// ─────────────────────────────────────────────────────────────────────────
public function store(Request $request, int $artworkId): JsonResponse
{
$artwork = Artwork::public()->published()->findOrFail($artworkId);
$request->validate([
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
]);
$raw = $request->input('content');
// Validate markdown-lite content
$errors = ContentSanitizer::validate($raw);
if ($errors) {
return response()->json(['errors' => ['content' => $errors]], 422);
}
$rendered = ContentSanitizer::render($raw);
$comment = ArtworkComment::create([
'artwork_id' => $artwork->id,
'user_id' => $request->user()->id,
'content' => $raw, // legacy column (plain text fallback)
'raw_content' => $raw,
'rendered_content' => $rendered,
'is_approved' => true, // auto-approve; extend with moderation as needed
]);
// Bust the comments cache for this user's 'all' feed
Cache::forget('comments.latest.all.page1');
$comment->load(['user', 'user.profile']);
return response()->json(['data' => $this->formatComment($comment, $request->user()->id)], 201);
}
// ─────────────────────────────────────────────────────────────────────────
// Update
// ─────────────────────────────────────────────────────────────────────────
public function update(Request $request, int $artworkId, int $commentId): JsonResponse
{
$comment = ArtworkComment::where('artwork_id', $artworkId)
->findOrFail($commentId);
Gate::authorize('update', $comment);
$request->validate([
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
]);
$raw = $request->input('content');
$errors = ContentSanitizer::validate($raw);
if ($errors) {
return response()->json(['errors' => ['content' => $errors]], 422);
}
$rendered = ContentSanitizer::render($raw);
$comment->update([
'content' => $raw,
'raw_content' => $raw,
'rendered_content' => $rendered,
]);
Cache::forget('comments.latest.all.page1');
$comment->load(['user', 'user.profile']);
return response()->json(['data' => $this->formatComment($comment, $request->user()->id)]);
}
// ─────────────────────────────────────────────────────────────────────────
// Delete
// ─────────────────────────────────────────────────────────────────────────
public function destroy(Request $request, int $artworkId, int $commentId): JsonResponse
{
$comment = ArtworkComment::where('artwork_id', $artworkId)->findOrFail($commentId);
Gate::authorize('delete', $comment);
$comment->delete();
Cache::forget('comments.latest.all.page1');
return response()->json(['message' => 'Comment deleted.'], 200);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private function formatComment(ArtworkComment $c, ?int $currentUserId): array
{
$user = $c->user;
$userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null;
return [
'id' => $c->id,
'raw_content' => $c->raw_content ?? $c->content,
'rendered_content' => $c->rendered_content ?? e(strip_tags($c->content ?? '')),
'created_at' => $c->created_at?->toIso8601String(),
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
'can_edit' => $currentUserId === $userId,
'can_delete' => $currentUserId === $userId,
'user' => [
'id' => $userId,
'username' => $user?->username,
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
],
];
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\FollowService;
use App\Services\UserStatsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -14,6 +16,8 @@ final class ArtworkInteractionController extends Controller
{
public function favorite(Request $request, int $artworkId): JsonResponse
{
$state = $request->boolean('state', true);
$this->toggleSimple(
request: $request,
table: 'user_favorites',
@@ -25,6 +29,18 @@ final class ArtworkInteractionController extends Controller
$this->syncArtworkStats($artworkId);
// Update creator's favorites_received_count
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
if ($creatorId) {
$svc = app(UserStatsService::class);
if ($state) {
$svc->incrementFavoritesReceived($creatorId);
$svc->setLastActiveAt((int) $request->user()->id);
} else {
$svc->decrementFavoritesReceived($creatorId);
}
}
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
}
@@ -72,41 +88,25 @@ final class ArtworkInteractionController extends Controller
public function follow(Request $request, int $userId): JsonResponse
{
if (! Schema::hasTable('friends_list')) {
return response()->json(['message' => 'Follow unavailable'], 422);
}
$actorId = (int) $request->user()->id;
if ($actorId === $userId) {
return response()->json(['message' => 'Cannot follow yourself'], 422);
}
$svc = app(FollowService::class);
$state = $request->boolean('state', true);
$query = DB::table('friends_list')
->where('user_id', $actorId)
->where('friend_id', $userId);
if ($state) {
if (! $query->exists()) {
DB::table('friends_list')->insert([
'user_id' => $actorId,
'friend_id' => $userId,
'date_added' => now(),
]);
}
$svc->follow($actorId, $userId);
} else {
$query->delete();
$svc->unfollow($actorId, $userId);
}
$followersCount = (int) DB::table('friends_list')
->where('friend_id', $userId)
->count();
return response()->json([
'ok' => true,
'is_following' => $state,
'followers_count' => $followersCount,
'ok' => true,
'is_following' => $state,
'followers_count' => $svc->followersCount($userId),
]);
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\FollowService;
use App\Support\AvatarUrl;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
/**
* API endpoints for the follow system.
*
* POST /api/user/{username}/follow follow a user
* DELETE /api/user/{username}/follow unfollow a user
* GET /api/user/{username}/followers paginated followers list
* GET /api/user/{username}/following paginated following list
*/
final class FollowController extends Controller
{
public function __construct(private readonly FollowService $followService) {}
// ─── POST /api/user/{username}/follow ────────────────────────────────────
public function follow(Request $request, string $username): JsonResponse
{
$target = $this->resolveUser($username);
$actor = Auth::user();
if ($actor->id === $target->id) {
return response()->json(['error' => 'Cannot follow yourself.'], 422);
}
try {
$this->followService->follow((int) $actor->id, (int) $target->id);
} catch (\InvalidArgumentException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}
return response()->json([
'following' => true,
'followers_count' => $this->followService->followersCount((int) $target->id),
]);
}
// ─── DELETE /api/user/{username}/follow ──────────────────────────────────
public function unfollow(Request $request, string $username): JsonResponse
{
$target = $this->resolveUser($username);
$actor = Auth::user();
$this->followService->unfollow((int) $actor->id, (int) $target->id);
return response()->json([
'following' => false,
'followers_count' => $this->followService->followersCount((int) $target->id),
]);
}
// ─── GET /api/user/{username}/followers ──────────────────────────────────
public function followers(Request $request, string $username): JsonResponse
{
$target = $this->resolveUser($username);
$perPage = min((int) $request->query('per_page', 24), 100);
$rows = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.follower_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where('uf.user_id', $target->id)
->whereNull('u.deleted_at')
->orderByDesc('uf.created_at')
->select([
'u.id', 'u.username', 'u.name',
'up.avatar_hash',
'uf.created_at as followed_at',
])
->paginate($perPage)
->through(fn ($row) => [
'id' => $row->id,
'username' => $row->username,
'display_name'=> $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'followed_at' => $row->followed_at,
]);
return response()->json($rows);
}
// ─── GET /api/user/{username}/following ──────────────────────────────────
public function following(Request $request, string $username): JsonResponse
{
$target = $this->resolveUser($username);
$perPage = min((int) $request->query('per_page', 24), 100);
$rows = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where('uf.follower_id', $target->id)
->whereNull('u.deleted_at')
->orderByDesc('uf.created_at')
->select([
'u.id', 'u.username', 'u.name',
'up.avatar_hash',
'uf.created_at as followed_at',
])
->paginate($perPage)
->through(fn ($row) => [
'id' => $row->id,
'username' => $row->username,
'display_name'=> $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'followed_at' => $row->followed_at,
]);
return response()->json($rows);
}
// ─── Private helpers ─────────────────────────────────────────────────────
private function resolveUser(string $username): User
{
$normalized = UsernamePolicy::normalize($username);
return User::query()
->whereRaw('LOWER(username) = ?', [$normalized])
->firstOrFail();
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Models\ArtworkComment;
use App\Support\AvatarUrl;
use App\Services\ThumbnailPresenter;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Carbon\Carbon;
class LatestCommentsApiController extends Controller
{
private const PER_PAGE = 20;
public function index(Request $request): JsonResponse
{
$type = $request->query('type', 'all');
// Validate filter type
if (! in_array($type, ['all', 'following', 'mine'], true)) {
$type = 'all';
}
// 'mine' and 'following' require auth
if (in_array($type, ['mine', 'following'], true) && ! $request->user()) {
return response()->json(['error' => 'Unauthenticated'], 401);
}
$query = ArtworkComment::with(['user', 'user.profile', 'artwork'])
->whereHas('artwork', function ($q) {
$q->public()->published()->whereNull('deleted_at');
})
->orderByDesc('artwork_comments.created_at');
switch ($type) {
case 'mine':
$query->where('artwork_comments.user_id', $request->user()->id);
break;
case 'following':
$followingIds = $request->user()
->following()
->pluck('users.id');
$query->whereIn('artwork_comments.user_id', $followingIds);
break;
default:
// 'all' — cache the first page only
if ((int) $request->query('page', 1) === 1) {
$cacheKey = 'comments.latest.all.page1';
$ttl = 120; // 2 minutes
$paginator = Cache::remember($cacheKey, $ttl, fn () => $query->paginate(self::PER_PAGE));
} else {
$paginator = $query->paginate(self::PER_PAGE);
}
break;
}
if (! isset($paginator)) {
$paginator = $query->paginate(self::PER_PAGE);
}
$items = $paginator->getCollection()->map(function (ArtworkComment $c) {
$art = $c->artwork;
$user = $c->user;
$present = $art ? ThumbnailPresenter::present($art, 'md') : null;
$thumb = $present ? ($present['url'] ?? null) : null;
$userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null;
return [
'comment_id' => $c->getKey(),
'comment_text' => e(strip_tags($c->content ?? '')),
'created_at' => $c->created_at?->toIso8601String(),
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
'commenter' => [
'id' => $userId,
'username' => $user?->username ?? null,
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
],
'artwork' => $art ? [
'id' => $art->id,
'title' => $art->title,
'slug' => $art->slug ?? Str::slug($art->title ?? ''),
'url' => '/art/' . $art->id . '/' . ($art->slug ?? Str::slug($art->title ?? '')),
'thumb' => $thumb,
] : null,
];
});
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'has_more' => $paginator->hasMorePages(),
],
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Controller;
use App\Models\MessageAttachment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AttachmentController extends Controller
{
public function show(Request $request, int $id)
{
$attachment = MessageAttachment::query()
->with('message:id,conversation_id')
->findOrFail($id);
$conversationId = (int) ($attachment->message?->conversation_id ?? 0);
abort_if($conversationId <= 0, 404, 'Attachment not available.');
$authorized = \App\Models\ConversationParticipant::query()
->where('conversation_id', $conversationId)
->where('user_id', $request->user()->id)
->whereNull('left_at')
->exists();
abort_unless($authorized, 403, 'You are not allowed to access this attachment.');
$diskName = (string) config('messaging.attachments.disk', 'local');
$disk = Storage::disk($diskName);
return new StreamedResponse(function () use ($disk, $attachment): void {
echo $disk->get($attachment->storage_path);
}, 200, [
'Content-Type' => $attachment->mime,
'Content-Disposition' => 'inline; filename="' . addslashes($attachment->original_name) . '"',
'Content-Length' => (string) $attachment->size_bytes,
]);
}
}

View File

@@ -0,0 +1,466 @@
<?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.');
}
}

View File

@@ -0,0 +1,351 @@
<?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(),
]);
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Controller;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Services\Messaging\MessageSearchIndexer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Meilisearch\Client;
class MessageSearchController extends Controller
{
public function __construct(
private readonly MessageSearchIndexer $indexer,
) {}
public function index(Request $request): JsonResponse
{
$user = $request->user();
$data = $request->validate([
'q' => 'required|string|min:1|max:200',
'conversation_id' => 'nullable|integer|exists:conversations,id',
'cursor' => 'nullable|integer|min:0',
]);
$allowedConversationIds = ConversationParticipant::query()
->where('user_id', $user->id)
->whereNull('left_at')
->pluck('conversation_id')
->map(fn ($id) => (int) $id)
->all();
$conversationId = isset($data['conversation_id']) ? (int) $data['conversation_id'] : null;
if ($conversationId !== null && ! in_array($conversationId, $allowedConversationIds, true)) {
abort(403, 'You are not a participant of this conversation.');
}
if (empty($allowedConversationIds)) {
return response()->json(['data' => [], 'next_cursor' => null]);
}
$limit = max(1, (int) config('messaging.search.page_size', 20));
$offset = max(0, (int) ($data['cursor'] ?? 0));
$hits = collect();
$estimated = 0;
try {
$client = new Client(
config('scout.meilisearch.host'),
config('scout.meilisearch.key')
);
$prefix = (string) config('scout.prefix', '');
$indexName = $prefix . (string) config('messaging.search.index', 'messages');
$conversationFilter = $conversationId !== null
? "conversation_id = {$conversationId}"
: 'conversation_id IN [' . implode(',', $allowedConversationIds) . ']';
$result = $client
->index($indexName)
->search((string) $data['q'], [
'limit' => $limit,
'offset' => $offset,
'sort' => ['created_at:desc'],
'filter' => $conversationFilter,
]);
$hits = collect($result->getHits() ?? []);
$estimated = (int) ($result->getEstimatedTotalHits() ?? $hits->count());
} catch (\Throwable) {
$query = Message::query()
->select('id')
->whereNull('deleted_at')
->whereIn('conversation_id', $allowedConversationIds)
->when($conversationId !== null, fn ($q) => $q->where('conversation_id', $conversationId))
->where('body', 'like', '%' . (string) $data['q'] . '%')
->orderByDesc('created_at')
->orderByDesc('id');
$estimated = (clone $query)->count();
$hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]);
}
$messageIds = $hits->pluck('id')->map(fn ($id) => (int) $id)->all();
$messages = Message::query()
->whereIn('id', $messageIds)
->whereIn('conversation_id', $allowedConversationIds)
->whereNull('deleted_at')
->with(['sender:id,username', 'attachments'])
->get()
->keyBy('id');
$ordered = $hits
->map(function (array $hit) use ($messages) {
$message = $messages->get((int) ($hit['id'] ?? 0));
if (! $message) {
return null;
}
return [
'id' => $message->id,
'conversation_id' => $message->conversation_id,
'sender_id' => $message->sender_id,
'sender' => $message->sender,
'body' => $message->body,
'created_at' => optional($message->created_at)?->toISOString(),
'has_attachments' => $message->attachments->isNotEmpty(),
];
})
->filter()
->values();
$nextCursor = ($offset + $limit) < $estimated ? ($offset + $limit) : null;
return response()->json([
'data' => $ordered,
'next_cursor' => $nextCursor,
]);
}
public function rebuild(Request $request): JsonResponse
{
abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.');
$conversationId = $request->integer('conversation_id');
if ($conversationId > 0) {
$this->indexer->rebuildConversation($conversationId);
return response()->json(['queued' => true, 'scope' => 'conversation']);
}
$this->indexer->rebuildAll();
return response()->json(['queued' => true, 'scope' => 'all']);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Manages per-user messaging privacy preference.
*
* GET /api/messages/settings return current setting
* PATCH /api/messages/settings update setting
*/
class MessagingSettingsController extends Controller
{
public function show(Request $request): JsonResponse
{
return response()->json([
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone',
]);
}
public function update(Request $request): JsonResponse
{
$data = $request->validate([
'allow_messages_from' => 'required|in:everyone,followers,mutual_followers,nobody',
]);
$request->user()->update($data);
return response()->json([
'allow_messages_from' => $request->user()->allow_messages_from,
]);
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Events\TypingStarted;
use App\Events\TypingStopped;
use App\Http\Controllers\Controller;
use App\Models\ConversationParticipant;
use Illuminate\Cache\Repository;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class TypingController extends Controller
{
public function start(Request $request, int $conversationId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$ttl = max(5, (int) config('messaging.typing.ttl_seconds', 8));
$this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl));
if ((bool) config('messaging.realtime', false)) {
event(new TypingStarted($conversationId, (int) $request->user()->id));
}
return response()->json(['ok' => true]);
}
public function stop(Request $request, int $conversationId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$this->store()->forget($this->key($conversationId, (int) $request->user()->id));
if ((bool) config('messaging.realtime', false)) {
event(new TypingStopped($conversationId, (int) $request->user()->id));
}
return response()->json(['ok' => true]);
}
public function index(Request $request, int $conversationId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$userId = (int) $request->user()->id;
$participants = ConversationParticipant::query()
->where('conversation_id', $conversationId)
->whereNull('left_at')
->where('user_id', '!=', $userId)
->with('user:id,username')
->get();
$typing = $participants
->filter(fn ($p) => $this->store()->has($this->key($conversationId, (int) $p->user_id)))
->map(fn ($p) => [
'user_id' => (int) $p->user_id,
'username' => (string) ($p->user->username ?? ''),
])
->values();
return response()->json(['typing' => $typing]);
}
private function assertParticipant(Request $request, int $conversationId): void
{
abort_unless(
ConversationParticipant::query()
->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 key(int $conversationId, int $userId): string
{
return "typing:{$conversationId}:{$userId}";
}
private function store(): Repository
{
$store = (string) config('messaging.typing.cache_store', 'redis');
if ($store === 'redis' && ! class_exists('Redis')) {
return Cache::store();
}
try {
return Cache::store($store);
} catch (\Throwable) {
return Cache::store();
}
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\ReactionType;
use App\Http\Controllers\Controller;
use App\Models\ArtworkComment;
use App\Models\ArtworkReaction;
use App\Models\CommentReaction;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Handles reaction toggling for artworks and comments.
*
* POST /api/artworks/{id}/reactions toggle artwork reaction
* POST /api/comments/{id}/reactions toggle comment reaction
* GET /api/artworks/{id}/reactions list artwork reactions
* GET /api/comments/{id}/reactions list comment reactions
*/
class ReactionController extends Controller
{
// ─────────────────────────────────────────────────────────────────────────
// Artwork reactions
// ─────────────────────────────────────────────────────────────────────────
public function artworkReactions(Request $request, int $artworkId): JsonResponse
{
return $this->listReactions('artwork', $artworkId, $request->user()?->id);
}
public function toggleArtworkReaction(Request $request, int $artworkId): JsonResponse
{
$this->validateExists('artworks', $artworkId);
$slug = $this->validateReactionSlug($request);
return $this->toggle(
model: new ArtworkReaction(),
where: ['artwork_id' => $artworkId, 'user_id' => $request->user()->id, 'reaction' => $slug],
countWhere: ['artwork_id' => $artworkId],
entityId: $artworkId,
entityType: 'artwork',
userId: $request->user()->id,
slug: $slug,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Comment reactions
// ─────────────────────────────────────────────────────────────────────────
public function commentReactions(Request $request, int $commentId): JsonResponse
{
return $this->listReactions('comment', $commentId, $request->user()?->id);
}
public function toggleCommentReaction(Request $request, int $commentId): JsonResponse
{
// Make sure comment exists and belongs to a public artwork
$comment = ArtworkComment::with('artwork')
->where('id', $commentId)
->whereHas('artwork', fn ($q) => $q->public()->published())
->firstOrFail();
$slug = $this->validateReactionSlug($request);
return $this->toggle(
model: new CommentReaction(),
where: ['comment_id' => $commentId, 'user_id' => $request->user()->id, 'reaction' => $slug],
countWhere: ['comment_id' => $commentId],
entityId: $commentId,
entityType: 'comment',
userId: $request->user()->id,
slug: $slug,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Shared internals
// ─────────────────────────────────────────────────────────────────────────
private function toggle(
\Illuminate\Database\Eloquent\Model $model,
array $where,
array $countWhere,
int $entityId,
string $entityType,
int $userId,
string $slug,
): JsonResponse {
$table = $model->getTable();
$existing = DB::table($table)->where($where)->first();
if ($existing) {
// Toggle off
DB::table($table)->where($where)->delete();
$active = false;
} else {
// Toggle on
DB::table($table)->insertOrIgnore(array_merge($where, [
'created_at' => now(),
]));
$active = true;
}
// Return fresh totals per reaction type
$totals = $this->getTotals($table, $countWhere, $userId);
return response()->json([
'entity_type' => $entityType,
'entity_id' => $entityId,
'reaction' => $slug,
'active' => $active,
'totals' => $totals,
]);
}
private function listReactions(string $entityType, int $entityId, ?int $userId): JsonResponse
{
if ($entityType === 'artwork') {
$table = 'artwork_reactions';
$where = ['artwork_id' => $entityId];
} else {
$table = 'comment_reactions';
$where = ['comment_id' => $entityId];
}
$totals = $this->getTotals($table, $where, $userId);
return response()->json([
'entity_type' => $entityType,
'entity_id' => $entityId,
'totals' => $totals,
]);
}
/**
* Return per-slug totals and whether the current user has each reaction.
*/
private function getTotals(string $table, array $where, ?int $userId): array
{
$rows = DB::table($table)
->where($where)
->selectRaw('reaction, COUNT(*) as total')
->groupBy('reaction')
->get()
->keyBy('reaction');
$totals = [];
foreach (ReactionType::cases() as $type) {
$slug = $type->value;
$count = (int) ($rows[$slug]->total ?? 0);
// Check if current user has this reaction
$mine = false;
if ($userId && $count > 0) {
$mine = DB::table($table)
->where($where)
->where('reaction', $slug)
->where('user_id', $userId)
->exists();
}
$totals[$slug] = [
'emoji' => $type->emoji(),
'label' => $type->label(),
'count' => $count,
'mine' => $mine,
];
}
return $totals;
}
private function validateReactionSlug(Request $request): string
{
$request->validate([
'reaction' => ['required', 'string', 'in:' . implode(',', ReactionType::values())],
]);
return $request->input('reaction');
}
private function validateExists(string $table, int $id): void
{
if (! DB::table($table)->where('id', $id)->exists()) {
throw new ModelNotFoundException("No [{$table}] record found with id [{$id}].");
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\Report;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ReportController extends Controller
{
public function store(Request $request): JsonResponse
{
$user = $request->user();
$data = $request->validate([
'target_type' => 'required|in:message,conversation,user',
'target_id' => 'required|integer|min:1',
'reason' => 'required|string|max:120',
'details' => 'nullable|string|max:4000',
]);
$targetType = $data['target_type'];
$targetId = (int) $data['target_id'];
if ($targetType === 'message') {
$message = Message::query()->findOrFail($targetId);
$allowed = ConversationParticipant::query()
->where('conversation_id', $message->conversation_id)
->where('user_id', $user->id)
->whereNull('left_at')
->exists();
abort_unless($allowed, 403, 'You are not allowed to report this message.');
}
if ($targetType === 'conversation') {
$allowed = ConversationParticipant::query()
->where('conversation_id', $targetId)
->where('user_id', $user->id)
->whereNull('left_at')
->exists();
abort_unless($allowed, 403, 'You are not allowed to report this conversation.');
}
if ($targetType === 'user') {
User::query()->findOrFail($targetId);
}
$report = Report::query()->create([
'reporter_id' => $user->id,
'target_type' => $targetType,
'target_id' => $targetId,
'reason' => $data['reason'],
'details' => $data['details'] ?? null,
'status' => 'open',
]);
return response()->json(['id' => $report->id, 'status' => $report->status], 201);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Api\Search;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class UserSearchController extends Controller
{
/**
* GET /api/search/users?q=gregor&per_page=4
*
* Public, rate-limited. Strips a leading @ from the query so that
* typing "@gregor" and "gregor" both work.
*/
public function __invoke(Request $request): JsonResponse
{
$raw = trim((string) $request->query('q', ''));
$q = ltrim($raw, '@');
if (strlen($q) < 2) {
return response()->json(['data' => []]);
}
$perPage = min((int) $request->query('per_page', 4), 8);
$users = User::query()
->where('is_active', 1)
->whereNull('deleted_at')
->where(function ($qb) use ($q) {
$qb->whereRaw('LOWER(username) LIKE ?', ['%' . strtolower($q) . '%']);
})
->with(['profile', 'statistics'])
->orderByRaw('LOWER(username) = ? DESC', [strtolower($q)]) // exact match first
->orderBy('username')
->limit($perPage)
->get(['id', 'username']);
$data = $users->map(function (User $user) {
$username = strtolower((string) ($user->username ?? ''));
$avatarHash = $user->profile?->avatar_hash;
$uploadsCount = (int) ($user->statistics?->uploads_count ?? 0);
return [
'id' => $user->id,
'type' => 'user',
'username' => $username,
'avatar_url' => AvatarUrl::forUser((int) $user->id, $avatarHash, 64),
'uploads_count' => $uploadsCount,
'profile_url' => '/@' . $username,
];
});
return response()->json(['data' => $data]);
}
}