using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Yavsc.Models; namespace Yavsc.Blogs.Controllers { /// /// Central user search endpoint used by client address books /// (PostIt.Desktop, future PostIt.Browser CLI, etc.). /// /// Live in Yavsc.Blogs rather than Yavsc.Api /// because Yavsc.Api is not yet enabled in production; future /// migration is mechanical (the namespace and route prefix are /// the only ties to the host project). /// /// Authorisation: any authenticated caller can search. /// Results include Email on a best-effort basis — /// the field is included because the address-book use case /// (composing a circle membership, sending an invite) needs /// it. The data set is the entire user table of the /// instance, which on Yavsc's single-tenant deployments is /// a closed community where users already know each other. /// Multi-tenant deployments should gate this controller /// behind a tenant-scoped authorisation policy before /// exposing it. /// [Produces("application/json")] [Route("api/user-search")] [Authorize] public class UserSearchApiController : Controller { private readonly ApplicationDbContext _context; public UserSearchApiController(ApplicationDbContext context) { _context = context; } /// /// Search users by display name and/or email. /// /// Substring filter on /// or /// (case-insensitive, /// contains). Optional. /// Exact filter on /// (case-insensitive /// equality). Optional. /// Maximum number of results, capped at /// 100. Default 25. // GET: api/user-search?q=foo&e=bar@example.com&take=25 [HttpGet] public async Task> SearchAsync( [FromQuery] string? q = null, [FromQuery] string? e = null, [FromQuery] int take = 25) { take = Math.Clamp(take, 1, 100); IQueryable query = _context.Users; if (!string.IsNullOrWhiteSpace(e)) { // Email is treated as an exact match — most address // book callers already know the email they're // searching for and we don't want to surface a // long tail of partial matches. var normalised = e.Trim(); query = query.Where(u => u.Email != null && u.Email.ToLower() == normalised.ToLower()); } if (!string.IsNullOrWhiteSpace(q)) { var needle = q.Trim(); query = query.Where(u => (u.FullName != null && u.FullName.ToLower().Contains(needle.ToLower())) || (u.UserName != null && u.UserName.ToLower().Contains(needle.ToLower()))); } var results = await query .OrderBy(u => u.FullName ?? u.UserName) .Take(take) .Select(u => new UserSearchResultDto { Id = u.Id, UserName = u.UserName ?? string.Empty, FullName = u.FullName, Avatar = u.Avatar, Email = u.Email, }) .ToListAsync(); return results; } } /// /// Search-result shape. Flat DTO with no navigation /// properties so the JSON stays small even if the user /// table grows. /// public sealed class UserSearchResultDto { public string Id { get; set; } = string.Empty; public string UserName { get; set; } = string.Empty; public string? FullName { get; set; } public string? Avatar { get; set; } public string? Email { get; set; } } }