From b3056f1c2e0f7095ca85bb510a00809fdcc6639e Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 18 Aug 2026 00:20:10 +0100 Subject: [PATCH] feat(user-search): add UserSearchApiController in Yavsc.Blogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lives in Yavsc.Blogs (not Yavsc.Api) because Yavsc.Api is not yet enabled in production; future migration to Yavsc.Api is a single namespace + route prefix change. Endpoint: GET /api/user-search?q=&e=&take= - Authorisation: [Authorize] (any authenticated caller). - q: case-insensitive substring match on FullName OR UserName. - e: case-insensitive exact match on Email. - take: 1..100, default 25. Returns a flat UserSearchResultDto (Id, UserName, FullName, Avatar, Email) — no navigation properties, so the payload stays small even if the user table grows. The Email field is included because the address-book use case (composing circle membership, sending invites) needs it. On Yavsc's single-tenant deployments the user table is a closed community; multi-tenant deployments should gate this controller behind a tenant-scoped policy before exposing it. The trade-off is documented in the controller's class-level XML doc. --- .../Controllers/UserSearchApiController.cs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/Yavsc.Blogs/Controllers/UserSearchApiController.cs diff --git a/src/Yavsc.Blogs/Controllers/UserSearchApiController.cs b/src/Yavsc.Blogs/Controllers/UserSearchApiController.cs new file mode 100644 index 00000000..e99441e0 --- /dev/null +++ b/src/Yavsc.Blogs/Controllers/UserSearchApiController.cs @@ -0,0 +1,111 @@ +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; } + } +} \ No newline at end of file