From 3019e7109314030a644ec4c64190af098a9627bd Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 10:56:47 -0300 Subject: [PATCH] feat(workorders): filter board search to an exact work-order id set GET /board/search accepts ids=1,2,3 (positive ints, deduplicated, at most 200). When present the result is exactly those work orders inside the caller's tenant and base scope; date, status, dispatcher, facet and text filters are ignored so none of them can hide a listed work order. Malformed or oversized lists are a 400. --- .../WorkOrderIdsFilterTests.cs | 217 ++++++++++++++++++ .../WorkOrderAdvancedSearchDataService.cs | 30 ++- .../WorkOrderAdvancedSearchModels.cs | 3 +- .../DTOs/WorkOrderBoardRequestDTOs.cs | 6 + SeaHaven.Services/Helpers/WorkOrderIdSet.cs | 38 +++ .../WorkOrderAdvancedSearchService.cs | 4 +- 6 files changed, 287 insertions(+), 11 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderIdSet.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs new file mode 100644 index 0000000..74a5882 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs @@ -0,0 +1,217 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// GET /board/search?ids= shows exactly the listed work orders inside the caller's tenant scope, +/// whatever other filters the board sends alongside. +/// +public class WorkOrderIdsFilterTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderAdvancedSearchService NewSearch(ApplicationDbContext ctx) + => new( + new WorkOrderAdvancedSearchDataService(ctx), + new WorkOrderAccountResolver(new AccountDataService(ctx), new LocationDataService(ctx))); + + private static ClaimsPrincipal AccountUser(int accountId) + => new(new ClaimsIdentity(new[] + { + new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), + new Claim(ClaimTypes.NameIdentifier, "admin-1"), + new Claim(ClaimTypes.Role, "Admin") + }, "test")); + + private static WorkOrder Wo( + int id, + DateTime? scheduled, + int accountId = 1, + string? assignTo = "disp-1", + LifecycleStatus? status = LifecycleStatus.Scheduled, + bool? deleted = null, + bool template = false) + => new() + { + Id = id, + AccountId = accountId, + InternalWONumber = $"3000000{id:0000}", + AssignTo = assignTo, + ScheduledDate = scheduled, + LifecycleStatus = status, + IsDeleted = deleted, + istemplate = template + }; + + private static void Seed(ApplicationDbContext ctx) + { + ctx.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 22)), + Wo(2, null), // no schedule date + Wo(3, new DateTime(2025, 1, 6), status: LifecycleStatus.Completed), + Wo(4, new DateTime(2026, 9, 22), assignTo: "disp-2"), + Wo(5, new DateTime(2026, 9, 22)), // not requested + Wo(6, new DateTime(2026, 9, 22), accountId: 2), // another tenant + Wo(7, new DateTime(2026, 9, 22), deleted: true), + Wo(8, new DateTime(2026, 9, 22), template: true)); + ctx.SaveChanges(); + } + + [Fact] + public async Task Ids_ReturnExactlyThoseWorkOrders_IgnoringEveryOtherFilter() + { + await using var ctx = NewContext(); + Seed(ctx); + + var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + Ids = "4,1,2,3", + // Filters the board may still carry; none of them may hide a listed work order. + DatePreset = WorkOrderAdvancedSearchDatePreset.ThisWeek, + Statuses = new List { LifecycleStatus.Scheduled }, + Dispatchers = new List { "disp-1" }, + Search = "no match anywhere", + PageSize = 200 + }, AccountUser(1), "admin-1"); + + result.TotalCount.Should().Be(4); + result.Items.Select(row => row.Id).Should().BeEquivalentTo(new[] { 1, 2, 3, 4 }); + } + + [Fact] + public async Task Ids_NeverWidenTenantOrBaseScope() + { + await using var ctx = NewContext(); + Seed(ctx); + + var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + Ids = "1,6,7,8", + PageSize = 200 + }, AccountUser(1), "admin-1"); + + result.Items.Select(row => row.Id).Should().Equal(1); + result.TotalCount.Should().Be(1); + } + + [Fact] + public async Task Ids_OnlyAnotherTenantsWorkOrders_ReturnsNothing() + { + await using var ctx = NewContext(); + Seed(ctx); + + var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + Ids = "6" + }, AccountUser(1), "admin-1"); + + result.TotalCount.Should().Be(0); + result.Items.Should().BeEmpty(); + } + + [Fact] + public async Task NoIds_KeepsTheExistingFilters() + { + await using var ctx = NewContext(); + Seed(ctx); + + var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2026, 9, 21), + DateTo = new DateOnly(2026, 9, 25), + Dispatchers = new List { "disp-1" }, + PageSize = 200 + }, AccountUser(1), "admin-1"); + + result.Items.Select(row => row.Id).Should().BeEquivalentTo(new[] { 1, 5 }); + } + + [Theory] + [InlineData("1,abc")] + [InlineData("0")] + [InlineData("-3")] + [InlineData("1,,2")] + [InlineData("1.5")] + [InlineData("99999999999")] + public async Task MalformedIds_AreABadRequest(string ids) + { + var controller = NewController(); + + var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto { Ids = ids }); + + var badRequest = result.Should().BeOfType().Subject; + badRequest.Value.Should().BeOfType().Which.Message.Should().Contain("ids"); + } + + [Fact] + public async Task MoreThanTheLimit_IsABadRequest() + { + var controller = NewController(); + var ids = string.Join(",", Enumerable.Range(1, WorkOrderIdSet.MaxCount + 1)); + + var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto { Ids = ids }); + + result.Should().BeOfType() + .Which.Value.Should().BeOfType() + .Which.Message.Should().Contain("200"); + } + + [Fact] + public void Parse_DeduplicatesBeforeCountingAndKeepsFirstSeenOrder() + { + var withDuplicates = string.Join(",", Enumerable.Range(1, WorkOrderIdSet.MaxCount).Concat(new[] { 5, 7 })); + + WorkOrderIdSet.ParseOrThrow(withDuplicates).Should().HaveCount(WorkOrderIdSet.MaxCount); + WorkOrderIdSet.ParseOrThrow(" 9, 3 ,9 ").Should().Equal(9, 3); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Parse_AbsentOrBlank_IsNoFilter(string? ids) + { + WorkOrderIdSet.ParseOrThrow(ids).Should().BeNull(); + } + + private static WorkOrderBoardController NewController() + { + var search = new WorkOrderAdvancedSearchService( + Mock.Of(), + Mock.Of()); + return new WorkOrderBoardController( + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + search) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = AccountUser(1) } + } + }; + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs index 74b5fb2..d6c5fec 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -19,6 +19,26 @@ namespace SeaHaven.DataServices.Implementation var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); if (query.AccountId.HasValue) baseQuery = WorkOrderBoardQueryFilters.ApplyAccountScope(baseQuery, query.AccountId.Value); + + // An exact id set (a link from an alert or a linked-work-orders list) shows those work + // orders whatever their date, status or owner; only the base and tenant scope still apply. + baseQuery = query.Ids is { Count: > 0 } ids + ? baseQuery.Where(w => ids.Contains(w.Id)) + : ApplyFilters(baseQuery, query); + + var totalCount = await baseQuery.CountAsync(); + + var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir); + var paged = sorted + .Skip(query.Page * query.PageSize) + .Take(query.PageSize); + + var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, paged, isUnscheduled: false); + return new WorkOrderAdvancedSearchResult(rows, totalCount); + } + + private IQueryable ApplyFilters(IQueryable baseQuery, WorkOrderAdvancedSearchQuery query) + { if (query.UnscheduledOnly) baseQuery = WorkOrderBoardQueryFilters.ApplyUnscheduledOnlyFilter(baseQuery); else @@ -47,15 +67,7 @@ namespace SeaHaven.DataServices.Implementation if (normalizedSearch != null) baseQuery = WorkOrderBoardSearchFilter.Apply(baseQuery, normalizedSearch); - var totalCount = await baseQuery.CountAsync(); - - var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir); - var paged = sorted - .Skip(query.Page * query.PageSize) - .Take(query.PageSize); - - var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, paged, isUnscheduled: false); - return new WorkOrderAdvancedSearchResult(rows, totalCount); + return baseQuery; } private static IQueryable ApplySort(IQueryable query, string sortBy, string sortDir) diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs index 4ee175a..db5cff7 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -33,7 +33,8 @@ namespace SeaHaven.DataServices.Interfaces string SortBy, string SortDir, int? AccountId = null, - bool IncludeDateless = false); + bool IncludeDateless = false, + IReadOnlyList? Ids = null); public record WorkOrderAdvancedSearchResult( IReadOnlyList Rows, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 38b4c24..4b47e9a 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -116,6 +116,12 @@ namespace SeaHaven.Services.DTOs public bool HasUplift { get; set; } public List? UpliftStatuses { get; set; } public bool MyWorkOrders { get; set; } + /// + /// Comma-separated work-order ids, at most distinct. + /// When present the result is exactly those work orders inside the caller's tenant scope, + /// and every other filter (dates, statuses, dispatchers, facets, search) is ignored. + /// + public string? Ids { get; set; } public string? SortBy { get; set; } public string? SortDir { get; set; } } diff --git a/SeaHaven.Services/Helpers/WorkOrderIdSet.cs b/SeaHaven.Services/Helpers/WorkOrderIdSet.cs new file mode 100644 index 0000000..73874b0 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderIdSet.cs @@ -0,0 +1,38 @@ +using System.Globalization; + +namespace SeaHaven.Services.Helpers +{ + /// + /// The board's exact work-order id filter: comma-separated positive integers, deduplicated, + /// at most distinct ids. + /// + public static class WorkOrderIdSet + { + public const int MaxCount = 200; + + /// + /// Null when is absent or blank. Throws + /// (a 400 on the board) for any token that is not a positive integer, or too many ids. + /// + public static IReadOnlyList? ParseOrThrow(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var ids = new List(); + var seen = new HashSet(); + foreach (var token in raw.Split(',')) + { + if (!int.TryParse(token.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0) + throw new ArgumentException("ids must be a comma-separated list of positive work order ids."); + if (seen.Add(id)) + ids.Add(id); + } + + if (ids.Count > MaxCount) + throw new ArgumentException($"ids accepts at most {MaxCount} work orders."); + + return ids; + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs index 39f63dc..7ebb544 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -42,6 +42,7 @@ namespace SeaHaven.Services.Implementation query.DateTo); var sortBy = WorkOrderBoardSortFields.NormalizeOrThrow(query.SortBy); + var ids = WorkOrderIdSet.ParseOrThrow(query.Ids); var dataQuery = new WorkOrderAdvancedSearchQuery( WorkOrderBoardSearchFilter.NormalizeSearch(query.Search), @@ -77,7 +78,8 @@ namespace SeaHaven.Services.Implementation query.IncludeDateless, query.DatePreset, query.DateFrom, - query.DateTo)); + query.DateTo), + ids); var result = await _searchDataService.SearchAsync(dataQuery); var utcNow = DateTime.UtcNow;