withoutTrashed()->with([ 'user' => fn ($query) => $query ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived']) ->with(['rank', 'roles']), 'attachments.extension', 'attachments.group', ]); $threadParam = $request->query('thread'); if (is_string($threadParam)) { $threadId = $this->parseIriId($threadParam); if ($threadId !== null) { $query->where('thread_id', $threadId); } } $posts = $query ->oldest('created_at') ->get() ->map(fn (Post $post) => $this->serializePost($post)); return response()->json($posts); } public function store(Request $request): JsonResponse { $data = $request->validate([ 'body' => ['required', 'string'], 'thread' => ['required', 'string'], ]); $threadId = $this->parseIriId($data['thread']); $thread = Thread::findOrFail($threadId); $post = Post::create([ 'thread_id' => $thread->id, 'user_id' => $request->user()?->id, 'body' => $data['body'], ]); $post->loadMissing([ 'user' => fn ($query) => $query ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived']) ->with(['rank', 'roles']), 'attachments.extension', 'attachments.group', ]); return response()->json($this->serializePost($post), 201); } public function destroy(Request $request, Post $post): JsonResponse { $post->deleted_by = $request->user()?->id; $post->save(); $post->delete(); return response()->json(null, 204); } private function parseIriId(?string $value): ?int { if (!$value) { return null; } if (preg_match('#/threads/(\d+)$#', $value, $matches)) { return (int) $matches[1]; } if (is_numeric($value)) { return (int) $value; } return null; } private function serializePost(Post $post): array { $attachments = $post->relationLoaded('attachments') ? $post->attachments : collect(); $bodyHtml = $this->renderBody($post->body, $attachments); return [ 'id' => $post->id, 'body' => $post->body, 'body_html' => $bodyHtml, 'thread' => "/api/threads/{$post->thread_id}", 'user_id' => $post->user_id, 'user_name' => $post->user?->name, 'user_avatar_url' => $post->user?->avatar_path ? Storage::url($post->user->avatar_path) : null, 'user_posts_count' => ($post->user?->posts_count ?? 0) + ($post->user?->threads_count ?? 0), 'user_created_at' => $post->user?->created_at?->toIso8601String(), 'user_location' => $post->user?->location, 'user_thanks_given_count' => $post->user?->thanks_given_count ?? 0, 'user_thanks_received_count' => $post->user?->thanks_received_count ?? 0, 'user_rank_name' => $post->user?->rank?->name, 'user_rank_badge_type' => $post->user?->rank?->badge_type, 'user_rank_badge_text' => $post->user?->rank?->badge_text, 'user_rank_badge_url' => $post->user?->rank?->badge_image_path ? Storage::url($post->user->rank->badge_image_path) : null, 'user_rank_color' => $post->user?->rank?->color, 'user_group_color' => $this->resolveGroupColor($post->user), 'created_at' => $post->created_at?->toIso8601String(), 'updated_at' => $post->updated_at?->toIso8601String(), 'attachments' => $post->relationLoaded('attachments') ? $attachments ->map(fn ($attachment) => [ 'id' => $attachment->id, 'group' => $attachment->group ? [ 'id' => $attachment->group->id, 'name' => $attachment->group->name, ] : null, 'original_name' => $attachment->original_name, 'extension' => $attachment->extension, 'mime_type' => $attachment->mime_type, 'size_bytes' => $attachment->size_bytes, 'download_url' => "/api/attachments/{$attachment->id}/download", 'created_at' => $attachment->created_at?->toIso8601String(), ]) ->values() : [], ]; } private function renderBody(string $body, $attachments): string { $replaced = $this->replaceAttachmentTags($body, $attachments); return BbcodeFormatter::format($replaced); } private function replaceAttachmentTags(string $body, $attachments): string { if (!$attachments || count($attachments) === 0) { return $body; } $map = []; foreach ($attachments as $attachment) { $name = strtolower($attachment->original_name ?? ''); if ($name !== '') { $map[$name] = [ 'url' => "/api/attachments/{$attachment->id}/download", 'mime' => $attachment->mime_type ?? '', ]; } } if (!$map) { return $body; } return preg_replace_callback('/\\[attachment\\](.+?)\\[\\/attachment\\]/i', function ($matches) use ($map) { $rawName = trim($matches[1]); $key = strtolower($rawName); if (!array_key_exists($key, $map)) { return $matches[0]; } $entry = $map[$key]; $url = $entry['url']; $mime = $entry['mime'] ?? ''; if (str_starts_with($mime, 'image/')) { return "[img]{$url}[/img]"; } return "[url={$url}]{$rawName}[/url]"; }, $body) ?? $body; } private function resolveGroupColor(?\App\Models\User $user): ?string { if (!$user) { return null; } $roles = $user->roles; if (!$roles) { return null; } foreach ($roles->sortBy('name') as $role) { if (!empty($role->color)) { return $role->color; } } return null; } }