Skip to content
32 changes: 32 additions & 0 deletions backend/src/Taskdeck.Api/Controllers/SearchController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Taskdeck.Api.Extensions;
using Taskdeck.Application.Interfaces;
using Taskdeck.Application.Services;

namespace Taskdeck.Api.Controllers;

[ApiController]
[Authorize]
[Route("api/[controller]")]
public class SearchController : AuthenticatedControllerBase
{
private readonly ISearchService _searchService;

public SearchController(ISearchService searchService, IUserContext userContext) : base(userContext)
{
_searchService = searchService;
}

[HttpGet]
public async Task<IActionResult> Search(
[FromQuery] string? q,
CancellationToken cancellationToken = default)
{
if (!TryGetCurrentUserId(out var userId, out var errorResult))
return errorResult!;

var result = await _searchService.SearchAsync(userId, q ?? string.Empty, cancellationToken: cancellationToken);
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<IKnowledgeService, KnowledgeService>();
services.AddScoped<IWorkspaceService, WorkspaceService>();
services.AddScoped<ISearchService, SearchService>();
services.AddScoped<IStarterPackManifestValidator, StarterPackManifestValidator>();
services.AddScoped<IStarterPackApplyService, StarterPackApplyService>();
services.AddScoped<IStarterPackCatalogService, StarterPackCatalogService>();
Expand Down
23 changes: 23 additions & 0 deletions backend/src/Taskdeck.Application/DTOs/SearchDtos.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Taskdeck.Application.DTOs;

public record GlobalSearchResultDto(
List<SearchBoardHitDto> Boards,
List<SearchCardHitDto> Cards
);

public record SearchBoardHitDto(
Guid Id,
string Name,
string? Description,
bool IsArchived
);

public record SearchCardHitDto(
Guid Id,
Guid BoardId,
string BoardName,
Guid ColumnId,
string ColumnName,
string Title,
string? Description
);
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ public interface ICardRepository : IRepository<Card>
Task<IEnumerable<Card>> GetByColumnIdAsync(Guid columnId, CancellationToken cancellationToken = default);
Task<IEnumerable<Card>> SearchAsync(Guid boardId, string? searchText, Guid? labelId, Guid? columnId, CancellationToken cancellationToken = default);
Task<Card?> GetByIdWithLabelsAsync(Guid id, CancellationToken cancellationToken = default);
Task<IEnumerable<Card>> SearchAcrossBoardsAsync(IEnumerable<Guid> boardIds, string searchText, int maxResults, CancellationToken cancellationToken = default);
}
13 changes: 13 additions & 0 deletions backend/src/Taskdeck.Application/Services/ISearchService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Taskdeck.Application.DTOs;
using Taskdeck.Domain.Common;

namespace Taskdeck.Application.Services;

public interface ISearchService
{
Task<Result<GlobalSearchResultDto>> SearchAsync(
Guid userId,
string query,
int maxResults = 20,
CancellationToken cancellationToken = default);
}
70 changes: 70 additions & 0 deletions backend/src/Taskdeck.Application/Services/SearchService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using Taskdeck.Application.DTOs;
using Taskdeck.Application.Interfaces;
using Taskdeck.Domain.Common;
using Taskdeck.Domain.Exceptions;

namespace Taskdeck.Application.Services;

public class SearchService : ISearchService
{
private const int MaxBoardResults = 10;
private const int MaxCardResults = 20;

private readonly IUnitOfWork _unitOfWork;

public SearchService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}

public async Task<Result<GlobalSearchResultDto>> SearchAsync(
Guid userId,
string query,
int maxResults = 20,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The maxResults parameter is currently defined in the method signature but ignored in the implementation. The service uses hardcoded constants MaxBoardResults (10) and MaxCardResults (20) instead. This makes the API parameter misleading as callers cannot actually control the result set size.

CancellationToken cancellationToken = default)
{
if (userId == Guid.Empty)
return Result.Failure<GlobalSearchResultDto>(ErrorCodes.ValidationError, "User ID cannot be empty");

if (string.IsNullOrWhiteSpace(query))
return Result.Success(new GlobalSearchResultDto([], []));

var trimmedQuery = query.Trim();
if (trimmedQuery.Length < 2)
return Result.Success(new GlobalSearchResultDto([], []));

// Get boards the user can read
var readableBoards = (await _unitOfWork.Boards.GetReadableByUserIdAsync(
userId,
includeArchived: false,
cancellationToken)).ToList();
Comment on lines +37 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Fetching all readable boards into memory via ToList() and then filtering them with Where (line 44) is inefficient, especially for users with access to many boards. This logic should be pushed down to the database level (e.g., by adding a search method to the board repository) to reduce memory overhead and improve query performance.


// Search boards by name/description
var matchingBoards = readableBoards
.Where(b =>
b.Name.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase) ||
(b.Description != null && b.Description.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase)))
.Take(MaxBoardResults)
.Select(b => new SearchBoardHitDto(b.Id, b.Name, b.Description, b.IsArchived))
.ToList();

// Search cards across all readable boards
var readableBoardIds = readableBoards.Select(b => b.Id).ToList();
var matchingCards = (await _unitOfWork.Cards.SearchAcrossBoardsAsync(
readableBoardIds,
trimmedQuery,
MaxCardResults,
cancellationToken))
.Select(c => new SearchCardHitDto(
c.Id,
c.BoardId,
c.Board?.Name ?? "Unknown",
c.ColumnId,
c.Column?.Name ?? "Unknown",
c.Title,
c.Description))
.ToList();

return Result.Success(new GlobalSearchResultDto(matchingBoards, matchingCards));
}
}
22 changes: 22 additions & 0 deletions backend/src/Taskdeck.Infrastructure/Repositories/CardRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,26 @@ public async Task<IEnumerable<Card>> SearchAsync(
.ThenInclude(cl => cl.Label)
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
}

public async Task<IEnumerable<Card>> SearchAcrossBoardsAsync(
IEnumerable<Guid> boardIds,
string searchText,
int maxResults,
CancellationToken cancellationToken = default)
{
var materializedBoardIds = boardIds.Distinct().ToList();
if (materializedBoardIds.Count == 0 || string.IsNullOrWhiteSpace(searchText))
return [];

return await _dbSet
.AsNoTracking()
.Where(c => materializedBoardIds.Contains(c.BoardId))
.Where(c => c.Title.Contains(searchText) || c.Description.Contains(searchText))
.Include(c => c.Board)
.Include(c => c.Column)
.OrderBy(c => c.BoardId)
.ThenBy(c => c.Position)
.Take(maxResults)
.ToListAsync(cancellationToken);
}
}
32 changes: 32 additions & 0 deletions frontend/taskdeck-web/src/api/searchApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import http from './http'

export interface SearchBoardHit {
id: string
name: string
description: string | null
isArchived: boolean
}

export interface SearchCardHit {
id: string
boardId: string
boardName: string
columnId: string
columnName: string
title: string
description: string
}

export interface GlobalSearchResult {
boards: SearchBoardHit[]
cards: SearchCardHit[]
}

export const searchApi = {
async search(query: string, signal?: AbortSignal): Promise<GlobalSearchResult> {
const params = new URLSearchParams()
params.append('q', query)
const { data } = await http.get<GlobalSearchResult>(`/search?${params}`, { signal })
return data
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using a leading slash in the request path (/search) can cause some HTTP clients (like Axios) to ignore the baseURL path suffix (e.g., /api) if the client is configured with one. It is generally safer to use a relative path like search?${params} to ensure it appends correctly to the base API path.

Suggested change
return data
const { data } = await http.get<GlobalSearchResult>(`search?${params}`)

},
}
13 changes: 13 additions & 0 deletions frontend/taskdeck-web/src/components/shell/AppShell.vue
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ function handleCommandActivate(item: CommandItem) {
closeCommandPalette()
}

function handleNavigateToBoard(boardId: string) {
void router.push(`/workspace/boards/${boardId}`)
closeCommandPalette()
}

function handleNavigateToCard(boardId: string, _cardId: string) {
// Navigate to the board containing the card
void router.push(`/workspace/boards/${boardId}`)
closeCommandPalette()
}

// ── Keyboard shortcuts ──

function isTextEntryTarget(target: EventTarget | null): boolean {
Expand Down Expand Up @@ -185,6 +196,8 @@ onUnmounted(() => {
:items="commandItems"
@close="closeCommandPalette"
@activate="handleCommandActivate"
@navigate-to-board="handleNavigateToBoard"
@navigate-to-card="handleNavigateToCard"
/>

<ShellKeyboardHelp
Expand Down
Loading
Loading