Files
nextcloud-forum/lib/Controller/SearchController.php

174 lines
5.4 KiB
PHP

<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// 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<Http::STATUS_OK, array{threads: array<string, mixed>, posts: array<string, mixed>, 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);
}
}
}