// SPDX-License-Identifier: AGPL-3.0-or-later namespace OCA\Forum\Controller; use OCA\Forum\Db\Post; use OCA\Forum\Db\PostMapper; use OCA\Forum\Db\Thread; use OCA\Forum\Db\ThreadMapper; use OCA\Forum\Service\PostEnrichmentService; use OCA\Forum\Service\SearchService; use OCA\Forum\Service\ThreadEnrichmentService; use OCA\Forum\Service\UserService; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\IRequest; use OCP\IUserSession; use Psr\Log\LoggerInterface; class SearchController extends OCSController { public function __construct( string $appName, IRequest $request, private SearchService $searchService, private PostMapper $postMapper, private ThreadMapper $threadMapper, private PostEnrichmentService $postEnrichmentService, private ThreadEnrichmentService $threadEnrichmentService, private UserService $userService, private IUserSession $userSession, private LoggerInterface $logger, ) { parent::__construct($appName, $request); } /** * Search forum threads and posts * * @param string $q Search query (supports quoted phrases, AND/OR operators, parentheses, -exclusions) * @param bool $searchThreads Include threads in search (title + first post content) * @param bool $searchPosts Include reply posts in search * @param int|null $categoryId Optional category ID filter * @param int<1, 200> $limit Maximum results per type * @param int $offset Results offset per type * @return DataResponse, posts: array, threadCount: int, postCount: int, query: string}, array{}> * * 200: Search results returned */ #[NoAdminRequired] #[PublicPage] #[ApiRoute(verb: 'GET', url: '/api/search')] public function index( string $q = '', bool $searchThreads = true, bool $searchPosts = true, ?int $categoryId = null, int $limit = 50, int $offset = 0, ): DataResponse { try { $user = $this->userSession->getUser(); $userId = $user?->getUID(); // Validate query $q = trim($q); if (empty($q)) { return new DataResponse([ 'error' => 'Search query is required' ], Http::STATUS_BAD_REQUEST); } // Validate search scope if (!$searchThreads && !$searchPosts) { return new DataResponse([ 'error' => 'At least one search scope must be selected (threads or posts)' ], Http::STATUS_BAD_REQUEST); } // Perform search $results = $this->searchService->search( $q, $userId, $searchThreads, $searchPosts, $categoryId, $limit, $offset ); // Collect all unique author IDs from threads, last reply authors, and posts $allAuthorIds = []; foreach ($results['threads'] as $thread) { $allAuthorIds[] = $thread->getAuthorId(); if ($thread->getLastReplyAuthorId() !== null) { $allAuthorIds[] = $thread->getLastReplyAuthorId(); } } foreach ($results['posts'] as $post) { $allAuthorIds[] = $post->getAuthorId(); } $allAuthorIds = array_unique($allAuthorIds); // Batch fetch all author data once $authors = $this->userService->enrichMultipleUsers($allAuthorIds); // Enrich threads with pre-fetched author data and last reply info $enrichedThreads = array_map(function ($thread) use ($authors) { $lastReply = null; $lastReplyAuthorId = $thread->getLastReplyAuthorId(); if ($lastReplyAuthorId !== null) { $lastReply = [ 'postId' => $thread->getLastPostId(), 'author' => $authors[$lastReplyAuthorId] ?? null, 'createdAt' => $thread->getLastReplyAt(), ]; } return $this->threadEnrichmentService->enrichThread($thread, $authors[$thread->getAuthorId()], $lastReply); }, $results['threads']); // Enrich posts with pre-fetched author data and thread context $perPage = 20; $enrichedPosts = array_map(function ($post) use ($authors, $perPage) { $enriched = $this->postEnrichmentService->enrichPost($post, [], [], null, $authors[$post->getAuthorId()]); // Add thread info for context try { $thread = $this->threadMapper->find($post->getThreadId()); $enriched['threadTitle'] = $thread->getTitle(); $enriched['threadSlug'] = $thread->getSlug(); } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { // Thread not found (deleted or inaccessible) $enriched['threadTitle'] = null; $enriched['threadSlug'] = null; } // Calculate the page number for direct linking if (!$post->getIsFirstPost()) { try { $position = $this->postMapper->getReplyPosition($post->getThreadId(), $post->getId()); $enriched['page'] = (int)floor($position / $perPage) + 1; } catch (\Exception $e) { // Fallback - page unknown } } return $enriched; }, $results['posts']); return new DataResponse([ 'threads' => $enrichedThreads, 'posts' => $enrichedPosts, 'threadCount' => $results['threadCount'], 'postCount' => $results['postCount'], 'query' => $q, ]); } catch (\Exception $e) { $this->logger->error('Error performing search: ' . $e->getMessage(), [ 'exception' => $e, 'trace' => $e->getTraceAsString(), ]); return new DataResponse([ 'error' => 'Failed to perform search' ], Http::STATUS_INTERNAL_SERVER_ERROR); } } }