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;