release/1.0.7 #34

Merged
notazof merged 40 commits from release/1.0.7 into main 2026-08-18 19:05:50 +01:00
Showing only changes of commit b3056f1c2e - Show all commits

feat(user-search): add UserSearchApiController in Yavsc.Blogs
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 26s
Dotnet build and test / build (pull_request) Successful in 14m59s

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=<name>&e=<email>&take=<n>
- 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.
Paul Schneider 2026-08-18 00:20:10 +01:00
Signed by: notazof
GPG key ID: 1DD5D838E5343B06

View file

@ -0,0 +1,111 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
namespace Yavsc.Blogs.Controllers
{
/// <summary>
/// Central user search endpoint used by client address books
/// (PostIt.Desktop, future PostIt.Browser CLI, etc.).
///
/// <para>Live in <c>Yavsc.Blogs</c> rather than <c>Yavsc.Api</c>
/// 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).</para>
///
/// <para>Authorisation: any authenticated caller can search.
/// Results include <c>Email</c> 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.</para>
/// </summary>
[Produces("application/json")]
[Route("api/user-search")]
[Authorize]
public class UserSearchApiController : Controller
{
private readonly ApplicationDbContext _context;
public UserSearchApiController(ApplicationDbContext context)
{
_context = context;
}
/// <summary>
/// Search users by display name and/or email.
/// </summary>
/// <param name="q">Substring filter on
/// <see cref="ApplicationUser.FullName"/> or
/// <see cref="ApplicationUser.UserName"/> (case-insensitive,
/// contains). Optional.</param>
/// <param name="e">Exact filter on
/// <see cref="ApplicationUser.Email"/> (case-insensitive
/// equality). Optional.</param>
/// <param name="take">Maximum number of results, capped at
/// 100. Default 25.</param>
// GET: api/user-search?q=foo&e=bar@example.com&take=25
[HttpGet]
public async Task<IEnumerable<UserSearchResultDto>> SearchAsync(
[FromQuery] string? q = null,
[FromQuery] string? e = null,
[FromQuery] int take = 25)
{
take = Math.Clamp(take, 1, 100);
IQueryable<ApplicationUser> 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;
}
}
/// <summary>
/// Search-result shape. Flat DTO with no navigation
/// properties so the JSON stays small even if the user
/// table grows.
/// </summary>
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; }
}
}