mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
Merge remote-tracking branch 'origin/dev' into feat/ab/sh-348-dashboard-region
This commit is contained in:
commit
dab39e78be
67 changed files with 11471 additions and 92 deletions
54
Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs
Normal file
54
Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class DashboardControllerTests
|
||||||
|
{
|
||||||
|
private static object Prop(object source, string name) =>
|
||||||
|
source.GetType().GetProperty(name)!.GetValue(source)!;
|
||||||
|
|
||||||
|
private static DashboardController NewController(Mock<IDashboardService> service) =>
|
||||||
|
new(service.Object)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Guards the Stats wire object, not the service DTO. GetKpiCountsAsync and
|
||||||
|
// DashboardStatsDTO already carried these three counts; the regression this
|
||||||
|
// pins is the controller's anonymous response silently dropping them, which
|
||||||
|
// is what left the tiles with nothing to read.
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStats_SerialisesKpiCountsOnWireObject()
|
||||||
|
{
|
||||||
|
var service = new Mock<IDashboardService>();
|
||||||
|
service.Setup(s => s.GetStatsAsync(
|
||||||
|
It.IsAny<ClaimsPrincipal>(),
|
||||||
|
It.IsAny<DashboardStatsQueryDTO>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new DashboardStatsDTO
|
||||||
|
{
|
||||||
|
ScheduledTomorrow = 3,
|
||||||
|
PendingUplifts = 5,
|
||||||
|
AvetaPending = 7
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await NewController(service).GetStats(
|
||||||
|
new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||||
|
|
||||||
|
var body = result.Should().BeOfType<OkObjectResult>().Subject.Value!;
|
||||||
|
((int)Prop(body, "scheduledTomorrow")).Should().Be(3);
|
||||||
|
((int)Prop(body, "pendingUplifts")).Should().Be(5);
|
||||||
|
((int)Prop(body, "avetaPending")).Should().Be(7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -99,6 +99,55 @@ public class DashboardServiceTests
|
||||||
stats.Completed.Should().Be(1);
|
stats.Completed.Should().Be(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_ExposesKpiCounts_PendingUpliftsCountsPendingStatusOnly()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var today = DashboardBusinessTime.Today();
|
||||||
|
var tomorrow = today.AddDays(1);
|
||||||
|
|
||||||
|
// Scheduled-tomorrow tile: one account-1 order scheduled for tomorrow.
|
||||||
|
ctx.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = tomorrow.ToDateTime(TimeOnly.MinValue)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aveta-pending tile: Aveta-required order in the today/tomorrow window
|
||||||
|
// with no Aveta attachment. Its Dispatch anchors the uplift requests below.
|
||||||
|
var avetaOrder = new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
AvetaRequired = true,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = today.ToDateTime(TimeOnly.MinValue)
|
||||||
|
};
|
||||||
|
ctx.workOrders.Add(avetaOrder);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var dispatch = new Dispatch { WorkOrderId = avetaOrder.Id, IsDeleted = false };
|
||||||
|
ctx.Set<Dispatch>().Add(dispatch);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
// Pending-uplifts tile: only Status == "Pending" is an outstanding uplift
|
||||||
|
// awaiting an approval decision. This test pins the behaviour the code
|
||||||
|
// ships today: a "ChangesRequested" request has been sent back to the
|
||||||
|
// vendor, so it is NOT counted. Whether pendingUplifts should also include
|
||||||
|
// ChangesRequested is an open product call, not a settled review decision.
|
||||||
|
ctx.Set<DispatchUpliftRequest>().AddRange(
|
||||||
|
new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "Pending", IsDeleted = false },
|
||||||
|
new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "ChangesRequested", IsDeleted = false });
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var stats = await NewService(ctx).GetStatsAsync(
|
||||||
|
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||||
|
|
||||||
|
stats.ScheduledTomorrow.Should().Be(1);
|
||||||
|
stats.AvetaPending.Should().Be(1);
|
||||||
|
stats.PendingUplifts.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetStatsAsync_MissingScopeFailsClosed()
|
public async Task GetStatsAsync_MissingScopeFailsClosed()
|
||||||
{
|
{
|
||||||
|
|
@ -351,4 +400,345 @@ public class DashboardServiceTests
|
||||||
$"the full-name storage form of {code} must resolve to the same region as the code");
|
$"the full-name storage form of {code} must resolve to the same region as the code");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorInsightsAsync_IsCompanyWideAndUsesVendorMetrics()
|
||||||
|
{
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
ctx.VendorCompanies.Add(new VendorCompany { Id = 10, Name = "Acme Services", IsDeleted = false });
|
||||||
|
ctx.Vendors.Add(new Vendor
|
||||||
|
{
|
||||||
|
Id = 20,
|
||||||
|
CompanyId = 10,
|
||||||
|
IsActive = true,
|
||||||
|
CompanyName = "Acme Services"
|
||||||
|
});
|
||||||
|
ctx.Dispatches.Add(new Dispatch { Id = 30, VendorId = 20 });
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
PrimaryDispatchId = 30,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
||||||
|
ScheduledDate = DateTime.UtcNow.Date.AddDays(-3),
|
||||||
|
CompletedDate = DateTime.UtcNow.Date.AddDays(-1),
|
||||||
|
RescheduleCount = 1
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
PrimaryDispatchId = 30,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
||||||
|
ScheduledDate = DateTime.UtcNow.Date.AddDays(-2),
|
||||||
|
CompletedDate = DateTime.UtcNow.Date.AddDays(-1)
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await NewService(ctx).GetVendorInsightsAsync(
|
||||||
|
AccountUser(1), CancellationToken.None);
|
||||||
|
|
||||||
|
result.TotalVendors.Should().Be(1);
|
||||||
|
result.Items.Should().ContainSingle();
|
||||||
|
result.Items[0].TotalJobs.Should().Be(2);
|
||||||
|
result.Items[0].CompletionRate.Should().Be(100);
|
||||||
|
result.Items[0].RescheduleRate.Should().Be(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
// ScheduledDate is a stored calendar value (board writes persist `.Date`), so a work
|
||||||
|
// order buckets on its own calendar day regardless of the time-of-day component. The
|
||||||
|
// UTC hours below were previously shifted a full day by an America/New_York conversion
|
||||||
|
// (03:00 UTC -> prior evening), so they double as a regression guard: each row must now
|
||||||
|
// stay on the calendar date it was scheduled for.
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 1, 15, 3, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled,
|
||||||
|
ScheduledDate = new DateTime(2026, 1, 14, 15, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
||||||
|
ScheduledDate = new DateTime(2026, 1, 15, 17, 0, 0, DateTimeKind.Utc),
|
||||||
|
CompletedDate = new DateTime(2026, 1, 16, 15, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 1, 16, 3, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 1, 17, 3, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 1, 13, 20, 0, 0, DateTimeKind.Utc)
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var query = new DashboardTrendQueryDTO
|
||||||
|
{
|
||||||
|
DateFrom = new DateOnly(2026, 1, 14),
|
||||||
|
DateTo = new DateOnly(2026, 1, 16)
|
||||||
|
};
|
||||||
|
var trend = await NewService(ctx).GetTrendAsync(
|
||||||
|
AccountUser(1), query, CancellationToken.None);
|
||||||
|
|
||||||
|
trend.Granularity.Should().Be("day");
|
||||||
|
trend.Buckets.Select(bucket => bucket.Date).Should().Equal(
|
||||||
|
new DateOnly(2026, 1, 14), new DateOnly(2026, 1, 15), new DateOnly(2026, 1, 16));
|
||||||
|
trend.Buckets.Select(bucket => bucket.Label).Should().Equal(
|
||||||
|
"2026-01-14", "2026-01-15", "2026-01-16");
|
||||||
|
|
||||||
|
// 01-14: only the canceled row scheduled that calendar day.
|
||||||
|
trend.Buckets[0].Total.Should().Be(1);
|
||||||
|
trend.Buckets[0].Open.Should().Be(0);
|
||||||
|
trend.Buckets[0].Canceled.Should().Be(1);
|
||||||
|
trend.Buckets[0].Completed.Should().Be(0);
|
||||||
|
trend.Buckets[0].Overdue.Should().Be(0);
|
||||||
|
|
||||||
|
// 01-15: the 03:00 UTC scheduled row (no longer shifted to 01-14) plus the completed row.
|
||||||
|
trend.Buckets[1].Total.Should().Be(2);
|
||||||
|
trend.Buckets[1].Open.Should().Be(1);
|
||||||
|
trend.Buckets[1].Completed.Should().Be(1);
|
||||||
|
trend.Buckets[1].Canceled.Should().Be(0);
|
||||||
|
trend.Buckets[1].Overdue.Should().Be(1);
|
||||||
|
|
||||||
|
// 01-16: the 03:00 UTC scheduled row that stays on its own day.
|
||||||
|
trend.Buckets[2].Total.Should().Be(1);
|
||||||
|
trend.Buckets[2].Open.Should().Be(1);
|
||||||
|
trend.Buckets[2].Overdue.Should().Be(1);
|
||||||
|
|
||||||
|
trend.Buckets.Should().OnlyContain(bucket => !bucket.IsCurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTrendAsync_MidnightScheduledDateStaysInTodayBucketAndIsNotOverdue()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var today = DashboardBusinessTime.Today();
|
||||||
|
// A board-scheduled work order for today is stored as midnight (`.Date`). It must land
|
||||||
|
// in the Today bucket and count as open, not roll back to yesterday and read as overdue.
|
||||||
|
ctx.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = today.ToDateTime(TimeOnly.MinValue)
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var query = new DashboardTrendQueryDTO
|
||||||
|
{
|
||||||
|
DateFrom = today.AddDays(-1),
|
||||||
|
DateTo = today.AddDays(1)
|
||||||
|
};
|
||||||
|
var trend = await NewService(ctx).GetTrendAsync(
|
||||||
|
AccountUser(1), query, CancellationToken.None);
|
||||||
|
|
||||||
|
var todayBucket = trend.Buckets.Single(bucket => bucket.Date == today);
|
||||||
|
todayBucket.Total.Should().Be(1);
|
||||||
|
todayBucket.Open.Should().Be(1);
|
||||||
|
todayBucket.Overdue.Should().Be(0);
|
||||||
|
|
||||||
|
var yesterdayBucket = trend.Buckets.Single(bucket => bucket.Date == today.AddDays(-1));
|
||||||
|
yesterdayBucket.Total.Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTrendAsync_ThreeMonthRangeUsesWeeklyMondayBuckets()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 16, 3, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
||||||
|
ScheduledDate = new DateTime(2026, 9, 16, 12, 0, 0, DateTimeKind.Utc),
|
||||||
|
CompletedDate = new DateTime(2026, 9, 16, 18, 0, 0, DateTimeKind.Utc)
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var query = new DashboardTrendQueryDTO
|
||||||
|
{
|
||||||
|
Range = "3m",
|
||||||
|
DateFrom = new DateOnly(2026, 6, 17),
|
||||||
|
DateTo = new DateOnly(2026, 9, 16)
|
||||||
|
};
|
||||||
|
var trend = await NewService(ctx).GetTrendAsync(
|
||||||
|
AccountUser(1), query, CancellationToken.None);
|
||||||
|
|
||||||
|
trend.Granularity.Should().Be("week");
|
||||||
|
trend.Buckets.Should().HaveCount(14);
|
||||||
|
trend.Buckets.Select(bucket => bucket.Date.DayOfWeek)
|
||||||
|
.Should().OnlyContain(day => day == DayOfWeek.Monday);
|
||||||
|
trend.Buckets[0].Date.Should().Be(new DateOnly(2026, 6, 15));
|
||||||
|
trend.Buckets[0].Total.Should().Be(2);
|
||||||
|
trend.Buckets[0].Open.Should().Be(2);
|
||||||
|
trend.Buckets[^1].Date.Should().Be(new DateOnly(2026, 9, 14));
|
||||||
|
trend.Buckets[^1].Total.Should().Be(1);
|
||||||
|
trend.Buckets[^1].Completed.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTrendAsync_YearFilterUsesMonthlyBuckets()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
||||||
|
ScheduledDate = new DateTime(2026, 3, 20, 4, 0, 0, DateTimeKind.Utc),
|
||||||
|
CompletedDate = new DateTime(2026, 3, 21, 4, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2025, 12, 31, 20, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
// 2027-01-01 by calendar: outside the 2026 window. Previously an
|
||||||
|
// America/New_York conversion pulled it back to 2026-12-31 and into December.
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2027, 1, 1, 3, 0, 0, DateTimeKind.Utc)
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var trend = await NewService(ctx).GetTrendAsync(
|
||||||
|
AccountUser(1), new DashboardTrendQueryDTO { Year = 2026 }, CancellationToken.None);
|
||||||
|
|
||||||
|
trend.Granularity.Should().Be("month");
|
||||||
|
trend.Buckets.Should().HaveCount(12);
|
||||||
|
trend.Buckets[0].Label.Should().Be("2026-01-01");
|
||||||
|
trend.Buckets[0].Total.Should().Be(1);
|
||||||
|
trend.Buckets[0].Open.Should().Be(1);
|
||||||
|
trend.Buckets[0].Overdue.Should().Be(1);
|
||||||
|
trend.Buckets[2].Label.Should().Be("2026-03-01");
|
||||||
|
trend.Buckets[2].Total.Should().Be(1);
|
||||||
|
trend.Buckets[2].Completed.Should().Be(1);
|
||||||
|
// December stays empty: the 2027-01-01 row is no longer pulled inside the window.
|
||||||
|
trend.Buckets[11].Label.Should().Be("2026-12-01");
|
||||||
|
trend.Buckets[11].Total.Should().Be(0);
|
||||||
|
trend.Buckets[11].Open.Should().Be(0);
|
||||||
|
trend.Today.Should().Be(DashboardBusinessTime.Today());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTrendAsync_MarksTodayBucketAsCurrent()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var today = DashboardBusinessTime.Today();
|
||||||
|
ctx.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = today.AddDays(-2).ToDateTime(new TimeOnly(12, 0))
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var query = new DashboardTrendQueryDTO
|
||||||
|
{
|
||||||
|
DateFrom = today.AddDays(-2),
|
||||||
|
DateTo = today.AddDays(2)
|
||||||
|
};
|
||||||
|
var trend = await NewService(ctx).GetTrendAsync(
|
||||||
|
AccountUser(1), query, CancellationToken.None);
|
||||||
|
|
||||||
|
trend.Today.Should().Be(today);
|
||||||
|
trend.Buckets.Should().HaveCount(5);
|
||||||
|
trend.Buckets.Count(bucket => bucket.IsCurrent).Should().Be(1);
|
||||||
|
trend.Buckets.Single(bucket => bucket.IsCurrent).Date.Should().Be(today);
|
||||||
|
trend.Buckets[0].Total.Should().Be(1);
|
||||||
|
trend.Buckets.Single(bucket => bucket.Date == today).Total.Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTrendAsync_DispatcherRoleAndTenantScopeMatchStats()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
AssignTo = "dispatcher-1",
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
AssignTo = "dispatcher-2",
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 2,
|
||||||
|
AssignTo = "dispatcher-1",
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc)
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var query = new DashboardTrendQueryDTO
|
||||||
|
{
|
||||||
|
DateFrom = new DateOnly(2026, 2, 2),
|
||||||
|
DateTo = new DateOnly(2026, 2, 3)
|
||||||
|
};
|
||||||
|
|
||||||
|
var dispatcherTrend = await NewService(ctx).GetTrendAsync(
|
||||||
|
AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None);
|
||||||
|
|
||||||
|
dispatcherTrend.Buckets.Should().HaveCount(2);
|
||||||
|
dispatcherTrend.Buckets[0].Total.Should().Be(1);
|
||||||
|
dispatcherTrend.Buckets[1].Total.Should().Be(0);
|
||||||
|
|
||||||
|
var adminTrend = await NewService(ctx).GetTrendAsync(
|
||||||
|
AccountUser(1), query, CancellationToken.None);
|
||||||
|
|
||||||
|
adminTrend.Buckets[0].Total.Should().Be(2);
|
||||||
|
|
||||||
|
var otherAccountTrend = await NewService(ctx).GetTrendAsync(
|
||||||
|
AccountUser(2), query, CancellationToken.None);
|
||||||
|
|
||||||
|
otherAccountTrend.Buckets[0].Total.Should().Be(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ public class LocationControllerTests
|
||||||
public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData()
|
public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData()
|
||||||
{
|
{
|
||||||
var service = new Mock<ILocationService>();
|
var service = new Mock<ILocationService>();
|
||||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny<CancellationToken>()))
|
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||||
.ReturnsAsync(new PagedResult<LocationDTO>
|
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||||
{
|
{
|
||||||
Items = new List<LocationDTO> { new() { Id = 1, Name = "Site" } },
|
Items = new List<LocationDTO> { new() { Id = 1, Name = "Site" } },
|
||||||
|
|
@ -49,7 +49,7 @@ public class LocationControllerTests
|
||||||
public async Task GetLocationList_PassesStatesFilterToService()
|
public async Task GetLocationList_PassesStatesFilterToService()
|
||||||
{
|
{
|
||||||
var service = new Mock<ILocationService>();
|
var service = new Mock<ILocationService>();
|
||||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny<CancellationToken>()))
|
service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||||
.ReturnsAsync(new PagedResult<LocationDTO>
|
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||||
{
|
{
|
||||||
Items = new List<LocationDTO>(),
|
Items = new List<LocationDTO>(),
|
||||||
|
|
@ -58,7 +58,7 @@ public class LocationControllerTests
|
||||||
PageSize = 10
|
PageSize = 10
|
||||||
});
|
});
|
||||||
|
|
||||||
var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", CancellationToken.None);
|
var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
result.Should().BeOfType<OkObjectResult>();
|
result.Should().BeOfType<OkObjectResult>();
|
||||||
service.VerifyAll();
|
service.VerifyAll();
|
||||||
|
|
@ -68,13 +68,13 @@ public class LocationControllerTests
|
||||||
public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape()
|
public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape()
|
||||||
{
|
{
|
||||||
var service = new Mock<ILocationService>();
|
var service = new Mock<ILocationService>();
|
||||||
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||||
.ThrowsAsync(new ValidationException(new[]
|
.ThrowsAsync(new ValidationException(new[]
|
||||||
{
|
{
|
||||||
new ValidationFailure("states", "states must be valid US postal abbreviations: ZZ.")
|
new ValidationFailure("states", "states must be valid US postal abbreviations: ZZ.")
|
||||||
}));
|
}));
|
||||||
|
|
||||||
var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", CancellationToken.None);
|
var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
|
@ -200,4 +200,90 @@ public class LocationControllerTests
|
||||||
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
||||||
notFound.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Location not found");
|
notFound.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Location not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationList_ForwardsSortParamsToService()
|
||||||
|
{
|
||||||
|
var service = new Mock<ILocationService>();
|
||||||
|
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), "city", "desc"))
|
||||||
|
.ReturnsAsync(new PagedResult<LocationDTO> { Items = new List<LocationDTO>(), TotalCount = 0, Page = 1, PageSize = 10 });
|
||||||
|
|
||||||
|
await NewController(service).GetLocationList(sortBy: "city", sortDirection: "desc", cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
service.Verify(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), "city", "desc"), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationList_WhenSortInvalid_ReturnsExistingValidationErrorShape()
|
||||||
|
{
|
||||||
|
var service = new Mock<ILocationService>();
|
||||||
|
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||||
|
.ThrowsAsync(new ValidationException(new[]
|
||||||
|
{
|
||||||
|
new ValidationFailure("sortBy", "sortBy must be one of: code, client, address, city, state, poc.")
|
||||||
|
}));
|
||||||
|
|
||||||
|
var result = await NewController(service).GetLocationList(sortBy: "nope", cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
response.Status.Should().Be("Validation Error");
|
||||||
|
response.Message.Should().Contain("sortBy");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationList_ProjectsClientAccountNameAndSiteFields()
|
||||||
|
{
|
||||||
|
var service = new Mock<ILocationService>();
|
||||||
|
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), null, null))
|
||||||
|
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||||
|
{
|
||||||
|
Items = new List<LocationDTO>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Name = "STL8",
|
||||||
|
Address1 = "9 River Rd",
|
||||||
|
City = "St. Louis",
|
||||||
|
State = "MO",
|
||||||
|
Zip = "63101",
|
||||||
|
PhoneNumber = "555-0100",
|
||||||
|
Email = "poc@example.com",
|
||||||
|
AccountId = 3,
|
||||||
|
AccountName = "Acme Co",
|
||||||
|
Contacts = new List<SiteContactResponseDTO>
|
||||||
|
{
|
||||||
|
new() { Id = 10, Name = "Cara Lane", Phone = "555-0100" },
|
||||||
|
new() { Id = 11, Name = "Alan Ford", Phone = "555-0101" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TotalCount = 1,
|
||||||
|
Page = 1,
|
||||||
|
PageSize = 10
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await NewController(service).GetLocationList(cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
using var json = System.Text.Json.JsonSerializer.SerializeToDocument(ok.Value);
|
||||||
|
var row = json.RootElement.GetProperty("Data").EnumerateArray().Single();
|
||||||
|
|
||||||
|
row.GetProperty("Name").GetString().Should().Be("STL8", "the site code is the legacy Name field");
|
||||||
|
row.GetProperty("Address").GetString().Should().Be("9 River Rd");
|
||||||
|
row.GetProperty("City").GetString().Should().Be("St. Louis");
|
||||||
|
row.GetProperty("State").GetString().Should().Be("MO");
|
||||||
|
row.GetProperty("ZipCode").GetString().Should().Be("63101");
|
||||||
|
row.GetProperty("Phone").GetString().Should().Be("555-0100");
|
||||||
|
row.GetProperty("ContactEmail").GetString().Should().Be("poc@example.com");
|
||||||
|
row.GetProperty("Contact").GetString().Should().Be("Cara Lane", "the first ordered contact is the legacy Contact field");
|
||||||
|
row.GetProperty("AccountId").GetInt32().Should().Be(3);
|
||||||
|
row.GetProperty("ClientName").GetString().Should().Be("Acme Co");
|
||||||
|
row.GetProperty("AccountName").GetString().Should().Be("Acme Co");
|
||||||
|
var contacts = row.GetProperty("Contacts");
|
||||||
|
contacts.GetArrayLength().Should().Be(2);
|
||||||
|
contacts[0].GetProperty("Name").GetString().Should().Be("Cara Lane");
|
||||||
|
contacts[1].GetProperty("Name").GetString().Should().Be("Alan Ford");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,4 +86,129 @@ public class LocationDataServiceTests
|
||||||
totalCount.Should().Be(2);
|
totalCount.Should().Be(2);
|
||||||
items.Select(l => l.State).Should().BeEquivalentTo("indiana", "MO");
|
items.Select(l => l.State).Should().BeEquivalentTo("indiana", "MO");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task SeedRegistryAsync(ApplicationDbContext ctx)
|
||||||
|
{
|
||||||
|
ctx.Accounts.AddRange(
|
||||||
|
new Accounts { Id = 1, Name = "Acme Co", IsDeleted = false },
|
||||||
|
new Accounts { Id = 2, Name = "Borealis LLC", IsDeleted = false });
|
||||||
|
ctx.Locations.AddRange(
|
||||||
|
new Locations { Id = 10, Name = "STL8", Address1 = "9 River Rd", City = "St. Louis", State = "MO", AccountId = 1 },
|
||||||
|
new Locations { Id = 11, Name = "DFW8", Address1 = "2 Prairie Ave", City = "Dallas", State = "TX", AccountId = 2 },
|
||||||
|
new Locations { Id = 12, Name = "IND9", Address1 = "5 Circle Blvd", City = "Indianapolis", State = "IN", AccountId = 1 },
|
||||||
|
new Locations { Id = 13, Name = "MIA2", Address1 = "1 Bay Dr", City = "Miami", State = "FL", AccountId = 2 });
|
||||||
|
ctx.Contacts.AddRange(
|
||||||
|
new Contacts { Id = 100, LocationId = 10, SiteContactOrder = 0, FirstName = "Cara Lane", PhoneNumber = "555-0001" },
|
||||||
|
new Contacts { Id = 101, LocationId = 10, SiteContactOrder = 1, FirstName = "Alan Ford", PhoneNumber = "555-0002" },
|
||||||
|
new Contacts { Id = 102, LocationId = 11, SiteContactOrder = 1, FirstName = "Zoe Park", PhoneNumber = "555-0003" },
|
||||||
|
new Contacts { Id = 103, LocationId = 11, SiteContactOrder = 0, FirstName = "Removed Poc", PhoneNumber = "555-0004", IsDeleted = true },
|
||||||
|
new Contacts { Id = 104, LocationId = 12, SiteContactOrder = 0, FirstName = "Bob Quinn", PhoneNumber = "555-0005" });
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("STL", new[] { 10 }, "site code")]
|
||||||
|
[InlineData("Borealis", new[] { 11, 13 }, "client name")]
|
||||||
|
[InlineData("Prairie", new[] { 11 }, "street address")]
|
||||||
|
[InlineData("Miami", new[] { 13 }, "city")]
|
||||||
|
public async Task GetListPagedAsync_SearchMatchesCodeClientAddressAndCity(string term, int[] expectedIds, string becauseField)
|
||||||
|
{
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
await SeedRegistryAsync(ctx);
|
||||||
|
|
||||||
|
var (items, totalCount) = await new LocationDataService(ctx)
|
||||||
|
.GetListPagedAsync(1, 10, term, null, CancellationToken.None);
|
||||||
|
|
||||||
|
totalCount.Should().Be(expectedIds.Length, $"search must match the {becauseField}");
|
||||||
|
items.Select(l => l.Id).Should().BeEquivalentTo(expectedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("code", 11, 12, 13, 10)]
|
||||||
|
[InlineData("client", 10, 12, 11, 13)]
|
||||||
|
[InlineData("address", 13, 11, 12, 10)]
|
||||||
|
[InlineData("city", 11, 12, 13, 10)]
|
||||||
|
[InlineData("state", 13, 12, 10, 11)]
|
||||||
|
[InlineData("poc", 13, 12, 10, 11)]
|
||||||
|
public async Task GetListPagedAsync_SortByAllowlistedColumn_OrdersAscending_WithIdTiebreak(
|
||||||
|
string sortBy, params int[] expectedIds)
|
||||||
|
{
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
await SeedRegistryAsync(ctx);
|
||||||
|
|
||||||
|
var (items, _) = await new LocationDataService(ctx)
|
||||||
|
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, sortBy, "asc");
|
||||||
|
|
||||||
|
items.Select(l => l.Id).Should().ContainInOrder(expectedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetListPagedAsync_SortByPoc_IgnoresDeletedContacts_AndSortsByFirstActiveContact()
|
||||||
|
{
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
await SeedRegistryAsync(ctx);
|
||||||
|
|
||||||
|
var (items, _) = await new LocationDataService(ctx)
|
||||||
|
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "poc", "asc");
|
||||||
|
|
||||||
|
// DFW8's order-0 row is soft deleted, so its POC is "Zoe Park" (order 1) and it must
|
||||||
|
// sort last; including the deleted row ("Removed Poc") would instead place it second.
|
||||||
|
items.Select(l => l.Id).Should().ContainInOrder(13, 12, 10, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetListPagedAsync_SortByClient_Desc_ReversesAccountOrder()
|
||||||
|
{
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
await SeedRegistryAsync(ctx);
|
||||||
|
|
||||||
|
var (items, _) = await new LocationDataService(ctx)
|
||||||
|
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "client", "desc");
|
||||||
|
|
||||||
|
items.Select(l => l.Id).Should().ContainInOrder(new[] { 11, 13, 10, 12 });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetListPagedAsync_UnknownSortColumn_FallsBackToLegacyCodeOrder()
|
||||||
|
{
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
await SeedRegistryAsync(ctx);
|
||||||
|
|
||||||
|
var (items, _) = await new LocationDataService(ctx)
|
||||||
|
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "not-a-column", "sideways");
|
||||||
|
|
||||||
|
items.Select(l => l.Name).Should().ContainInOrder("DFW8", "IND9", "MIA2", "STL8");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetListPagedAsync_EqualSortValues_BreakTiesById_AcrossPages()
|
||||||
|
{
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
ctx.Locations.AddRange(
|
||||||
|
new Locations { Id = 20, Name = "HOU1", City = "Dallas" },
|
||||||
|
new Locations { Id = 21, Name = "AUS3", City = "Dallas" },
|
||||||
|
new Locations { Id = 22, Name = "ELP4", City = "Dallas" });
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = new LocationDataService(ctx);
|
||||||
|
|
||||||
|
var (page1, totalCount) = await service.GetListPagedAsync(1, 2, null, null, CancellationToken.None, "city", "asc");
|
||||||
|
var (page2, _) = await service.GetListPagedAsync(2, 2, null, null, CancellationToken.None, "city", "asc");
|
||||||
|
|
||||||
|
totalCount.Should().Be(3);
|
||||||
|
page1.Select(l => l.Id).Should().ContainInOrder(20, 21);
|
||||||
|
page2.Select(l => l.Id).Should().ContainInOrder(22);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetListPagedAsync_ListLoadIncludesAccountNameForProjection()
|
||||||
|
{
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
await SeedRegistryAsync(ctx);
|
||||||
|
|
||||||
|
var (items, _) = await new LocationDataService(ctx)
|
||||||
|
.GetListPagedAsync(1, 10, null, null, CancellationToken.None);
|
||||||
|
|
||||||
|
items.Single(l => l.Id == 10).Account!.Name.Should().Be("Acme Co");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,8 @@ public class LocationServiceTests
|
||||||
{
|
{
|
||||||
var data = new Mock<ILocationDataService>();
|
var data = new Mock<ILocationDataService>();
|
||||||
IReadOnlyCollection<string>? captured = default;
|
IReadOnlyCollection<string>? captured = default;
|
||||||
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken>((_, _, _, states, _) => captured = states)
|
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, states, _, _, _) => captured = states)
|
||||||
.ReturnsAsync((new List<Locations>(), 0));
|
.ReturnsAsync((new List<Locations>(), 0));
|
||||||
|
|
||||||
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, " tx , Mo ,,TX, ", CancellationToken.None);
|
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, " tx , Mo ,,TX, ", CancellationToken.None);
|
||||||
|
|
@ -149,8 +149,8 @@ public class LocationServiceTests
|
||||||
{
|
{
|
||||||
var data = new Mock<ILocationDataService>();
|
var data = new Mock<ILocationDataService>();
|
||||||
IReadOnlyCollection<string>? captured = default;
|
IReadOnlyCollection<string>? captured = default;
|
||||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken>((_, _, _, stateFilter, _) => captured = stateFilter)
|
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, stateFilter, _, _, _) => captured = stateFilter)
|
||||||
.ReturnsAsync((new List<Locations>(), 0));
|
.ReturnsAsync((new List<Locations>(), 0));
|
||||||
|
|
||||||
var page = await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, "search", states, CancellationToken.None);
|
var page = await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, "search", states, CancellationToken.None);
|
||||||
|
|
@ -173,14 +173,14 @@ public class LocationServiceTests
|
||||||
&& e.ErrorMessage.Contains("ZZ")
|
&& e.ErrorMessage.Contains("ZZ")
|
||||||
&& e.ErrorMessage.Contains("QQ")
|
&& e.ErrorMessage.Contains("QQ")
|
||||||
&& !e.ErrorMessage.Contains("TX"));
|
&& !e.ErrorMessage.Contains("TX"));
|
||||||
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()), Times.Never);
|
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes()
|
public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes()
|
||||||
{
|
{
|
||||||
var data = new Mock<ILocationDataService>();
|
var data = new Mock<ILocationDataService>();
|
||||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||||
.ReturnsAsync((new List<Locations>(), 0));
|
.ReturnsAsync((new List<Locations>(), 0));
|
||||||
|
|
||||||
var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All);
|
var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All);
|
||||||
|
|
@ -533,4 +533,97 @@ public class LocationServiceTests
|
||||||
again.Should().BeFalse();
|
again.Should().BeFalse();
|
||||||
ctx.Locations.Should().BeEmpty();
|
ctx.Locations.Should().BeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService()
|
||||||
|
{
|
||||||
|
var data = new Mock<ILocationDataService>();
|
||||||
|
string? capturedSort = "sentinel";
|
||||||
|
string? capturedDirection = "sentinel";
|
||||||
|
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||||
|
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, _, _, sortBy, sortDirection) =>
|
||||||
|
{
|
||||||
|
capturedSort = sortBy;
|
||||||
|
capturedDirection = sortDirection;
|
||||||
|
})
|
||||||
|
.ReturnsAsync((new List<Locations>(), 0));
|
||||||
|
|
||||||
|
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: " POC ", sortDirection: " DESC ");
|
||||||
|
|
||||||
|
capturedSort.Should().Be("poc");
|
||||||
|
capturedDirection.Should().Be("desc");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, null)]
|
||||||
|
[InlineData("", " ")]
|
||||||
|
public async Task GetLocationListPagedAsync_BlankOrDefaultSort_PassesNormalizedDefaults(
|
||||||
|
string? sortBy,
|
||||||
|
string? sortDirection)
|
||||||
|
{
|
||||||
|
var data = new Mock<ILocationDataService>();
|
||||||
|
string? capturedSort = "sentinel";
|
||||||
|
string? capturedDirection = "sentinel";
|
||||||
|
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||||
|
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, _, _, s, d) =>
|
||||||
|
{
|
||||||
|
capturedSort = s;
|
||||||
|
capturedDirection = d;
|
||||||
|
})
|
||||||
|
.ReturnsAsync((new List<Locations>(), 0));
|
||||||
|
|
||||||
|
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy, sortDirection: sortDirection);
|
||||||
|
|
||||||
|
capturedSort.Should().BeNull("a blank sort column must keep the legacy ordering");
|
||||||
|
capturedDirection.Should().Be("asc");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("rating")]
|
||||||
|
[InlineData("password; drop table locations")]
|
||||||
|
public async Task GetLocationListPagedAsync_InvalidSortBy_ThrowsValidationAndNeverQueries(string sortBy)
|
||||||
|
{
|
||||||
|
var data = new Mock<ILocationDataService>();
|
||||||
|
|
||||||
|
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
||||||
|
.Which.Errors.Should().ContainSingle(e =>
|
||||||
|
e.PropertyName == "sortBy"
|
||||||
|
&& e.ErrorMessage.Contains("code")
|
||||||
|
&& e.ErrorMessage.Contains("poc"));
|
||||||
|
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("ascending")]
|
||||||
|
[InlineData("DESC; drop table locations")]
|
||||||
|
public async Task GetLocationListPagedAsync_InvalidSortDirection_ThrowsValidationAndNeverQueries(string sortDirection)
|
||||||
|
{
|
||||||
|
var data = new Mock<ILocationDataService>();
|
||||||
|
|
||||||
|
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: "city", sortDirection: sortDirection);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
||||||
|
.Which.Errors.Should().ContainSingle(e =>
|
||||||
|
e.PropertyName == "sortDirection"
|
||||||
|
&& e.ErrorMessage.Contains("asc")
|
||||||
|
&& e.ErrorMessage.Contains("desc"));
|
||||||
|
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationListPagedAsync_MapsClientAccountNameIntoRows()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
SeedAccount(ctx, 9, "Acme Co");
|
||||||
|
SeedLocation(ctx, "Alpha Site", "Austin").AccountId = 9;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
var row = page.Items.Should().ContainSingle().Subject;
|
||||||
|
row.AccountId.Should().Be(9);
|
||||||
|
row.AccountName.Should().Be("Acme Co");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -430,7 +430,7 @@ public class LocationSiteContactsTests
|
||||||
public async Task List_LoadsPageContactsInSingleBatchedRead()
|
public async Task List_LoadsPageContactsInSingleBatchedRead()
|
||||||
{
|
{
|
||||||
var data = new Mock<ILocationDataService>();
|
var data = new Mock<ILocationDataService>();
|
||||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||||
.ReturnsAsync((new List<Locations>
|
.ReturnsAsync((new List<Locations>
|
||||||
{
|
{
|
||||||
new() { Id = 1, Name = "One" },
|
new() { Id = 1, Name = "One" },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public sealed class TeamPermissionOverrideDataServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUserAsync_ReturnsIdentityRoleAndStoredOverrides()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
SeedUserWithRole(context, "u1", "Dispatcher");
|
||||||
|
context.UserPermissionOverrides.Add(new UserPermissionOverride
|
||||||
|
{
|
||||||
|
UserId = "u1",
|
||||||
|
PermissionKey = "deleteSites",
|
||||||
|
State = UserPermissionState.Deny
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await new TeamPermissionOverrideDataService(context)
|
||||||
|
.GetUserAsync("u1", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.RoleName.Should().Be("Dispatcher");
|
||||||
|
result.Overrides["deleteSites"].Should().Be(UserPermissionState.Deny);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetOverrideAsync_UpsertsOneCompositeKey()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
SeedUserWithRole(context, "u1", "Scheduler");
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = new TeamPermissionOverrideDataService(context);
|
||||||
|
|
||||||
|
await service.SetOverrideAsync("u1", "deleteSites", UserPermissionState.Allow, CancellationToken.None);
|
||||||
|
await service.SetOverrideAsync("u1", "deleteSites", UserPermissionState.Deny, CancellationToken.None);
|
||||||
|
|
||||||
|
var rows = await context.UserPermissionOverrides.ToListAsync();
|
||||||
|
rows.Should().ContainSingle();
|
||||||
|
rows[0].State.Should().Be(UserPermissionState.Deny);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApplicationDbContext NewContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SeedUserWithRole(ApplicationDbContext context, string userId, string roleName)
|
||||||
|
{
|
||||||
|
context.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = userId,
|
||||||
|
UserName = userId,
|
||||||
|
NormalizedUserName = userId.ToUpperInvariant(),
|
||||||
|
Email = userId + "@example.com",
|
||||||
|
NormalizedEmail = (userId + "@example.com").ToUpperInvariant()
|
||||||
|
});
|
||||||
|
context.Roles.Add(new IdentityRole
|
||||||
|
{
|
||||||
|
Id = "role-1",
|
||||||
|
Name = roleName,
|
||||||
|
NormalizedName = roleName.ToUpperInvariant()
|
||||||
|
});
|
||||||
|
context.UserRoles.Add(new IdentityUserRole<string>
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
RoleId = "role-1"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -105,14 +105,14 @@ public class VendorCompanyRosterControllerTests
|
||||||
var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
||||||
result.Should().BeOfType<UnauthorizedObjectResult>();
|
result.Should().BeOfType<UnauthorizedObjectResult>();
|
||||||
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Create_Returns200()
|
public async Task Create_Returns200()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(SampleRoster());
|
.ReturnsAsync(SampleRoster());
|
||||||
|
|
||||||
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
@ -125,7 +125,7 @@ public class VendorCompanyRosterControllerTests
|
||||||
public async Task Create_ValidationException_Returns400()
|
public async Task Create_ValidationException_Returns400()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new ValidationException("At least one company phone or email is required."));
|
.ThrowsAsync(new ValidationException("At least one company phone or email is required."));
|
||||||
|
|
||||||
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme" }, CancellationToken.None);
|
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme" }, CancellationToken.None);
|
||||||
|
|
@ -137,7 +137,7 @@ public class VendorCompanyRosterControllerTests
|
||||||
public async Task Create_DuplicateName_ReturnsStable409()
|
public async Task Create_DuplicateName_ReturnsStable409()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new VendorRosterDuplicateNameException(
|
.ThrowsAsync(new VendorRosterDuplicateNameException(
|
||||||
"database detail",
|
"database detail",
|
||||||
new InvalidOperationException("IX_VendorCompanies_NormalizedName")));
|
new InvalidOperationException("IX_VendorCompanies_NormalizedName")));
|
||||||
|
|
@ -164,14 +164,14 @@ public class VendorCompanyRosterControllerTests
|
||||||
var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
||||||
result.Should().BeOfType<UnauthorizedObjectResult>();
|
result.Should().BeOfType<UnauthorizedObjectResult>();
|
||||||
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reconcile_ValidationException_Returns400()
|
public async Task Reconcile_ValidationException_Returns400()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new ValidationException("Duplicate technician ids are not allowed."));
|
.ThrowsAsync(new ValidationException("Duplicate technician ids are not allowed."));
|
||||||
|
|
||||||
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
@ -183,7 +183,7 @@ public class VendorCompanyRosterControllerTests
|
||||||
public async Task Reconcile_CompanyNotFound_Returns404()
|
public async Task Reconcile_CompanyNotFound_Returns404()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new KeyNotFoundException("not found"));
|
.ThrowsAsync(new KeyNotFoundException("not found"));
|
||||||
|
|
||||||
var result = await NewController(service).Reconcile(404, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
var result = await NewController(service).Reconcile(404, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
@ -199,7 +199,7 @@ public class VendorCompanyRosterControllerTests
|
||||||
new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled }
|
new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled }
|
||||||
};
|
};
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new VendorRosterConflictException("blocked", blocked));
|
.ThrowsAsync(new VendorRosterConflictException("blocked", blocked));
|
||||||
|
|
||||||
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
@ -212,7 +212,7 @@ public class VendorCompanyRosterControllerTests
|
||||||
public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText()
|
public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
|
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
|
||||||
|
|
||||||
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
@ -251,14 +251,14 @@ public class VendorCompanyRosterControllerTests
|
||||||
var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None);
|
var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None);
|
||||||
|
|
||||||
result.Should().BeOfType<UnauthorizedObjectResult>();
|
result.Should().BeOfType<UnauthorizedObjectResult>();
|
||||||
service.Verify(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
service.Verify(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Patch_AddsTechnicians_Returns200WithRoster()
|
public async Task Patch_AddsTechnicians_Returns200WithRoster()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(SampleRoster());
|
.ReturnsAsync(SampleRoster());
|
||||||
|
|
||||||
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
||||||
|
|
@ -272,7 +272,7 @@ public class VendorCompanyRosterControllerTests
|
||||||
public async Task Patch_ValidationException_Returns400()
|
public async Task Patch_ValidationException_Returns400()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new ValidationException("Technician ids are not allowed when adding technicians."));
|
.ThrowsAsync(new ValidationException("Technician ids are not allowed when adding technicians."));
|
||||||
|
|
||||||
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
||||||
|
|
@ -285,7 +285,7 @@ public class VendorCompanyRosterControllerTests
|
||||||
public async Task Patch_CompanyNotFound_Returns404()
|
public async Task Patch_CompanyNotFound_Returns404()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new KeyNotFoundException("not found"));
|
.ThrowsAsync(new KeyNotFoundException("not found"));
|
||||||
|
|
||||||
var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None);
|
var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None);
|
||||||
|
|
@ -297,7 +297,7 @@ public class VendorCompanyRosterControllerTests
|
||||||
public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText()
|
public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorCompanyRosterService>();
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
|
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
|
||||||
|
|
||||||
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
||||||
|
|
@ -306,4 +306,29 @@ public class VendorCompanyRosterControllerTests
|
||||||
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
|
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
|
||||||
conflict.Value!.ToString()!.Should().NotContain("internal provider detail");
|
conflict.Value!.ToString()!.Should().NotContain("internal provider detail");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AreaAssignmentForbidden_Returns403OnEveryMutation()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
|
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
|
||||||
|
service.Setup(x => x.ReconcileRosterAsync(7, It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
|
||||||
|
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
|
||||||
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
var results = new[]
|
||||||
|
{
|
||||||
|
await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 }, CancellationToken.None),
|
||||||
|
await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", AreaId = 1 }, CancellationToken.None),
|
||||||
|
await controller.AddTechnicians(7, new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }, CancellationToken.None)
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var result in results)
|
||||||
|
result.Should().BeOfType<ObjectResult>().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
service.Verify(x => x.ReconcileRosterAsync(7, It.IsAny<ReconcileVendorRosterDTO>(), "42", controller.User, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -835,4 +835,43 @@ public class VendorCompanyRosterDataServiceTests
|
||||||
|
|
||||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveRoster_AppliesAreaOnlyWhenProvidedAndReadsAreaName()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
using var context = NewContext(dbName);
|
||||||
|
context.Areas.AddRange(
|
||||||
|
new Area { Id = 1, Name = "East", NormalizedName = "east" },
|
||||||
|
new Area { Id = 2, Name = "Central", NormalizedName = "central" });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var (companyId, _) = await SeedCompanyWithTechnicianAsync(context, "Area Co", "Tech");
|
||||||
|
var service = new VendorCompanyRosterDataService(context);
|
||||||
|
|
||||||
|
VendorCompanyRosterWriteModel Snapshot(bool provided, int? areaId) => new()
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
Name = "Area Co",
|
||||||
|
Email = "area@example.com",
|
||||||
|
AreaIdProvided = provided,
|
||||||
|
AreaId = areaId,
|
||||||
|
Technicians = context.Vendors.Where(v => v.CompanyId == companyId)
|
||||||
|
.Select(v => new RosterTechnicianWriteModel { Id = v.Id, ContactName = v.ContactName })
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
var assigned = await service.SaveRosterAsync(Snapshot(true, 2), CancellationToken.None);
|
||||||
|
assigned.AreaId.Should().Be(2);
|
||||||
|
assigned.AreaName.Should().Be("Central");
|
||||||
|
|
||||||
|
var untouched = await service.SaveRosterAsync(Snapshot(false, null), CancellationToken.None);
|
||||||
|
untouched.AreaId.Should().Be(2);
|
||||||
|
|
||||||
|
var cleared = await service.SaveRosterAsync(Snapshot(true, null), CancellationToken.None);
|
||||||
|
cleared.AreaId.Should().BeNull();
|
||||||
|
cleared.AreaName.Should().BeNull();
|
||||||
|
|
||||||
|
(await service.IsActiveAreaAsync(1, CancellationToken.None)).Should().BeTrue();
|
||||||
|
(await service.IsActiveAreaAsync(42, CancellationToken.None)).Should().BeFalse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using FluentAssertions;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
using System.Security.Claims;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.DataServices.Models;
|
using SeaHaven.DataServices.Models;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
@ -13,6 +14,13 @@ namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
public class VendorCompanyRosterServiceTests
|
public class VendorCompanyRosterServiceTests
|
||||||
{
|
{
|
||||||
|
private static readonly ClaimsPrincipal Dispatcher = Principal("User");
|
||||||
|
private static readonly ClaimsPrincipal Admin = Principal("Admin");
|
||||||
|
|
||||||
|
private static ClaimsPrincipal Principal(string role) => new(new ClaimsIdentity(
|
||||||
|
new[] { new Claim(ClaimTypes.NameIdentifier, "42"), new Claim(ClaimTypes.Role, role) },
|
||||||
|
"Test"));
|
||||||
|
|
||||||
private static VendorCompanyRosterService NewService(Mock<IVendorCompanyRosterDataService> data) => new(data.Object);
|
private static VendorCompanyRosterService NewService(Mock<IVendorCompanyRosterDataService> data) => new(data.Object);
|
||||||
|
|
||||||
private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new()
|
private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new()
|
||||||
|
|
@ -48,7 +56,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
var dto = new CreateVendorRosterDTO { Name = "Acme" };
|
var dto = new CreateVendorRosterDTO { Name = "Acme" };
|
||||||
|
|
||||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ValidationException>();
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
|
@ -60,7 +68,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" };
|
var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" };
|
||||||
|
|
||||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone));
|
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone));
|
||||||
|
|
@ -78,7 +86,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 7, ContactName = "Riley" } }
|
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 7, ContactName = "Riley" } }
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ValidationException>();
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
|
@ -98,7 +106,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Name = "Acme",
|
Name = "Acme",
|
||||||
Email = "acme@example.com",
|
Email = "acme@example.com",
|
||||||
Technicians = new List<RosterTechnicianInputDTO>()
|
Technicians = new List<RosterTechnicianInputDTO>()
|
||||||
}, "42", CancellationToken.None);
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured.Should().NotBeNull();
|
captured.Should().NotBeNull();
|
||||||
captured!.Technicians.Should().BeEmpty();
|
captured!.Technicians.Should().BeEmpty();
|
||||||
|
|
@ -120,7 +128,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Name = "Acme",
|
Name = "Acme",
|
||||||
Email = "acme@example.com",
|
Email = "acme@example.com",
|
||||||
Notes = notes
|
Notes = notes
|
||||||
}, "42", CancellationToken.None);
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured!.Notes.Should().Be(notes);
|
captured!.Notes.Should().Be(notes);
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +144,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Notes = new string('n', 501)
|
Notes = new string('n', 501)
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().ContainSingle(error =>
|
.Which.Errors.Should().ContainSingle(error =>
|
||||||
|
|
@ -163,7 +171,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ValidationException>();
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
|
@ -188,7 +196,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ValidationException>();
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
|
@ -205,7 +213,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
GoogleMapsUrl = "http://maps.google.com/acme"
|
GoogleMapsUrl = "http://maps.google.com/acme"
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl));
|
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl));
|
||||||
|
|
@ -228,7 +236,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
{
|
{
|
||||||
new() { ContactName = "Riley", Phone = "+1 312 555 0100" }
|
new() { ContactName = "Riley", Phone = "+1 312 555 0100" }
|
||||||
}
|
}
|
||||||
}, "42", CancellationToken.None);
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100");
|
captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100");
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +255,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
|
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
|
||||||
|
|
@ -270,7 +278,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Name = "Acme Co",
|
Name = "Acme Co",
|
||||||
Email = "acme@example.com",
|
Email = "acme@example.com",
|
||||||
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
||||||
}, "42", CancellationToken.None);
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured.Should().NotBeNull();
|
captured.Should().NotBeNull();
|
||||||
captured!.CompanyId.Should().Be(7);
|
captured!.CompanyId.Should().Be(7);
|
||||||
|
|
@ -294,7 +302,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Name = "Acme",
|
Name = "Acme",
|
||||||
Email = "acme@example.com",
|
Email = "acme@example.com",
|
||||||
Notes = notes
|
Notes = notes
|
||||||
}, "42", CancellationToken.None);
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured!.Notes.Should().Be(notes);
|
captured!.Notes.Should().Be(notes);
|
||||||
}
|
}
|
||||||
|
|
@ -311,7 +319,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Notes = new string('n', 501)
|
Notes = new string('n', 501)
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().ContainSingle(error =>
|
.Which.Errors.Should().ContainSingle(error =>
|
||||||
|
|
@ -336,7 +344,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Email = "acme@example.com"
|
Email = "acme@example.com"
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
|
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
|
||||||
|
|
@ -354,7 +362,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Email = "acme@example.com"
|
Email = "acme@example.com"
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
|
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
|
||||||
|
|
@ -376,7 +384,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Technicians = null!
|
Technicians = null!
|
||||||
};
|
};
|
||||||
|
|
||||||
await NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
await NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured!.Technicians.Should().BeEmpty();
|
captured!.Technicians.Should().BeEmpty();
|
||||||
}
|
}
|
||||||
|
|
@ -397,7 +405,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ValidationException>();
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
|
@ -420,7 +428,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
{
|
{
|
||||||
new() { ContactName = "", Phone = "", PreferredContact = "Phone" }
|
new() { ContactName = "", Phone = "", PreferredContact = "Phone" }
|
||||||
}
|
}
|
||||||
}, "42", CancellationToken.None);
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured!.Technicians.Should().ContainSingle();
|
captured!.Technicians.Should().ContainSingle();
|
||||||
captured.Technicians.Single().ContactName.Should().BeEmpty();
|
captured.Technicians.Single().ContactName.Should().BeEmpty();
|
||||||
|
|
@ -442,7 +450,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
await act.Should().ThrowAsync<ValidationException>();
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -462,7 +470,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
Name = "Acme",
|
Name = "Acme",
|
||||||
Email = "acme@example.com",
|
Email = "acme@example.com",
|
||||||
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
||||||
}, "42", cts.Token);
|
}, "42", Dispatcher, cts.Token);
|
||||||
|
|
||||||
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), cts.Token), Times.Once);
|
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), cts.Token), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
@ -477,7 +485,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
public async Task AddTechnicians_WithoutAuthenticatedUser_Throws()
|
public async Task AddTechnicians_WithoutAuthenticatedUser_Throws()
|
||||||
{
|
{
|
||||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", CancellationToken.None);
|
var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", Dispatcher, CancellationToken.None);
|
||||||
await act.Should().ThrowAsync<UnauthorizedAccessException>();
|
await act.Should().ThrowAsync<UnauthorizedAccessException>();
|
||||||
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
@ -488,7 +496,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" });
|
var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" });
|
||||||
|
|
||||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians));
|
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians));
|
||||||
|
|
@ -501,7 +509,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" };
|
var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" };
|
||||||
|
|
||||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ValidationException>();
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
|
@ -517,7 +525,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
AddTechnicians = new List<RosterTechnicianInputDTO> { new() { ContactName = "T" } }
|
AddTechnicians = new List<RosterTechnicianInputDTO> { new() { ContactName = "T" } }
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion));
|
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion));
|
||||||
|
|
@ -530,7 +538,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
var dto = AddDto(new RosterTechnicianInputDTO { ContactName = "T", Phone = "555-1234" });
|
var dto = AddDto(new RosterTechnicianInputDTO { ContactName = "T", Phone = "555-1234" });
|
||||||
|
|
||||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
|
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
|
||||||
|
|
@ -560,7 +568,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured.Should().NotBeNull();
|
captured.Should().NotBeNull();
|
||||||
captured!.CompanyId.Should().Be(7);
|
captured!.CompanyId.Should().Be(7);
|
||||||
|
|
@ -590,7 +598,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
{
|
{
|
||||||
RowVersion = "AAAAAAAAD8I=",
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = notes }
|
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = notes }
|
||||||
}, "42", CancellationToken.None);
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured!.CompanyFields!.Notes.Should().Be(notes);
|
captured!.CompanyFields!.Notes.Should().Be(notes);
|
||||||
}
|
}
|
||||||
|
|
@ -605,7 +613,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = new string('n', 501) }
|
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = new string('n', 501) }
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
(await act.Should().ThrowAsync<ValidationException>())
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
.Which.Errors.Should().ContainSingle(error =>
|
.Which.Errors.Should().ContainSingle(error =>
|
||||||
|
|
@ -632,7 +640,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
|
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
|
||||||
};
|
};
|
||||||
|
|
||||||
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
// SH-250: every field is blank, so there is no company change to apply. Forwarding a
|
// SH-250: every field is blank, so there is no company change to apply. Forwarding a
|
||||||
// non-null write model made the data layer bump RowVersion and rewrite every
|
// non-null write model made the data layer bump RowVersion and rewrite every
|
||||||
|
|
@ -652,7 +660,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
|
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
|
||||||
};
|
};
|
||||||
|
|
||||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ValidationException>();
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
data.Verify(
|
data.Verify(
|
||||||
|
|
@ -676,7 +684,7 @@ public class VendorCompanyRosterServiceTests
|
||||||
CompanyFields = new VendorRosterCompanyFieldsDTO { City = "Norfolk" }
|
CompanyFields = new VendorRosterCompanyFieldsDTO { City = "Norfolk" }
|
||||||
};
|
};
|
||||||
|
|
||||||
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
captured!.CompanyFields.Should().NotBeNull();
|
captured!.CompanyFields.Should().NotBeNull();
|
||||||
captured.CompanyFields!.City.Should().Be("Norfolk");
|
captured.CompanyFields!.City.Should().Be("Norfolk");
|
||||||
|
|
@ -690,8 +698,182 @@ public class VendorCompanyRosterServiceTests
|
||||||
.ReturnsAsync(SampleReadModel(7, 1));
|
.ReturnsAsync(SampleReadModel(7, 1));
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
using var cts = new CancellationTokenSource();
|
||||||
await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", cts.Token);
|
await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", Dispatcher, cts.Token);
|
||||||
|
|
||||||
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), cts.Token), Times.Once);
|
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), cts.Token), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static VendorCompanyRosterReadModel ReadModelWithArea(int? areaId)
|
||||||
|
{
|
||||||
|
var model = SampleReadModel(7, 1);
|
||||||
|
model.AreaId = areaId;
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_NonAdminAssigningArea_IsForbiddenAndNotPersisted()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 };
|
||||||
|
|
||||||
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
|
||||||
|
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_AdminAssigningUnknownOrArchivedArea_ThrowsValidation()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.IsActiveAreaAsync(99, It.IsAny<CancellationToken>())).ReturnsAsync(false);
|
||||||
|
var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 99 };
|
||||||
|
|
||||||
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", Admin, CancellationToken.None);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
|
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.AreaId));
|
||||||
|
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_AdminAssigningActiveArea_PersistsAreaId()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.IsActiveAreaAsync(2, cts.Token)).ReturnsAsync(true);
|
||||||
|
VendorCompanyRosterWriteModel? captured = null;
|
||||||
|
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
|
||||||
|
.ReturnsAsync(ReadModelWithArea(2));
|
||||||
|
|
||||||
|
var result = await NewService(data).CreateRosterAsync(
|
||||||
|
new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 2 },
|
||||||
|
"42",
|
||||||
|
Admin,
|
||||||
|
cts.Token);
|
||||||
|
|
||||||
|
captured!.AreaId.Should().Be(2);
|
||||||
|
result.AreaId.Should().Be(2);
|
||||||
|
data.Verify(x => x.IsActiveAreaAsync(2, cts.Token), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_NonAdminChangingOrClearingArea_IsForbiddenAndNotPersisted()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(ReadModelWithArea(1));
|
||||||
|
|
||||||
|
foreach (int? requested in new int?[] { 3, null })
|
||||||
|
{
|
||||||
|
var dto = new ReconcileVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
AreaId = requested
|
||||||
|
};
|
||||||
|
|
||||||
|
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_NonAdminRoundTripOrOmittedArea_SavesOtherFields()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(ReadModelWithArea(1));
|
||||||
|
var captured = new List<VendorCompanyRosterWriteModel>();
|
||||||
|
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured.Add(model))
|
||||||
|
.ReturnsAsync(ReadModelWithArea(1));
|
||||||
|
|
||||||
|
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
Name = "Acme Renamed",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
AreaId = 1
|
||||||
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
|
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
Name = "Acme Renamed",
|
||||||
|
Email = "acme@example.com"
|
||||||
|
}, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
|
captured.Should().HaveCount(2);
|
||||||
|
captured[0].AreaIdProvided.Should().BeTrue();
|
||||||
|
captured[0].AreaId.Should().Be(1);
|
||||||
|
captured[1].AreaIdProvided.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_AdminClearsAreaToUnassigned()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(ReadModelWithArea(1));
|
||||||
|
VendorCompanyRosterWriteModel? captured = null;
|
||||||
|
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
|
||||||
|
.ReturnsAsync(ReadModelWithArea(null));
|
||||||
|
|
||||||
|
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
AreaId = null
|
||||||
|
}, "42", Admin, CancellationToken.None);
|
||||||
|
|
||||||
|
captured!.AreaIdProvided.Should().BeTrue();
|
||||||
|
captured.AreaId.Should().BeNull();
|
||||||
|
data.Verify(x => x.IsActiveAreaAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTechnicians_NonAdminAreaChange_IsForbidden()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(ReadModelWithArea(null));
|
||||||
|
var dto = new AddTechniciansVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
CompanyFields = new VendorRosterCompanyFieldsDTO { AreaId = 2 }
|
||||||
|
};
|
||||||
|
|
||||||
|
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
|
||||||
|
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReconcileDto_DistinguishesOmittedAreaFromExplicitNull()
|
||||||
|
{
|
||||||
|
var omitted = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
|
||||||
|
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme"}""",
|
||||||
|
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
|
||||||
|
var cleared = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
|
||||||
|
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":null}""",
|
||||||
|
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
|
||||||
|
var assigned = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
|
||||||
|
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":3}""",
|
||||||
|
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
|
||||||
|
|
||||||
|
omitted.AreaIdProvided.Should().BeFalse();
|
||||||
|
cleared.AreaIdProvided.Should().BeTrue();
|
||||||
|
cleared.AreaId.Should().BeNull();
|
||||||
|
assigned.AreaIdProvided.Should().BeTrue();
|
||||||
|
assigned.AreaId.Should().Be(3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ public class VendorControllerTests
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<CancellationToken>()), Times.Never);
|
It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,6 +156,7 @@ public class VendorControllerTests
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
token))
|
token))
|
||||||
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
|
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
|
||||||
{
|
{
|
||||||
|
|
@ -203,6 +205,7 @@ public class VendorControllerTests
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
token), Times.Once);
|
token), Times.Once);
|
||||||
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||||
It.IsAny<int>(),
|
It.IsAny<int>(),
|
||||||
|
|
|
||||||
|
|
@ -470,12 +470,14 @@ public class VendorDataServiceTests
|
||||||
search: "alice",
|
search: "alice",
|
||||||
trades: new[] { "Plumbing" },
|
trades: new[] { "Plumbing" },
|
||||||
locations: new[] { "Chicago, IL" },
|
locations: new[] { "Chicago, IL" },
|
||||||
jobBuckets: new[] { "under-50" }).ToQueryString();
|
jobBuckets: new[] { "under-50" },
|
||||||
|
areaFilter: new VendorAreaFilter { AreaIds = new[] { 1, 2 }, IncludeUnassigned = true }).ToQueryString();
|
||||||
|
|
||||||
sql.Should().Contain("GROUP BY");
|
sql.Should().Contain("GROUP BY");
|
||||||
sql.Should().Contain("LEFT JOIN");
|
sql.Should().Contain("LEFT JOIN");
|
||||||
sql.Should().Contain("TradeSpecialties");
|
sql.Should().Contain("TradeSpecialties");
|
||||||
sql.Should().Contain("TotalJobs");
|
sql.Should().Contain("TotalJobs");
|
||||||
|
sql.Should().Contain("AreaId");
|
||||||
Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase)
|
Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase)
|
||||||
.Should().BeFalse("company totals must sum pre-aggregated scalar job counts");
|
.Should().BeFalse("company totals must sum pre-aggregated scalar job counts");
|
||||||
}
|
}
|
||||||
|
|
@ -1044,4 +1046,69 @@ public class VendorDataServiceTests
|
||||||
sql.Should().Contain("SELECT");
|
sql.Should().Contain("SELECT");
|
||||||
sql.Should().Contain("VendorCompanies");
|
sql.Should().Contain("VendorCompanies");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCompanyDirectoryPagedAsync_AreaFilterOrsWithinFacetAndUnassignedIncludesLegacyRows()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
var east = new Area { Id = 1, Name = "East", NormalizedName = "east" };
|
||||||
|
var west = new Area { Id = 3, Name = "West", NormalizedName = "west" };
|
||||||
|
context.Areas.AddRange(east, west);
|
||||||
|
var eastCo = new VendorCompany { Name = "East Co", NormalizedName = "east co", Area = east };
|
||||||
|
var westCo = new VendorCompany { Name = "West Co", NormalizedName = "west co", Area = west };
|
||||||
|
var unassignedCo = new VendorCompany { Name = "Unassigned Co", NormalizedName = "unassigned co" };
|
||||||
|
var eastOnly = new VendorCompany { Name = "East Only", NormalizedName = "east only", Area = east };
|
||||||
|
context.VendorCompanies.AddRange(eastCo, westCo, unassignedCo, eastOnly);
|
||||||
|
context.Vendors.AddRange(
|
||||||
|
new Vendor { Company = eastCo, CompanyName = "East Co", ContactName = "E", City = "Boston", State = "MA", IsActive = true, IsDeleted = false },
|
||||||
|
new Vendor { Company = westCo, CompanyName = "West Co", ContactName = "W", IsActive = true, IsDeleted = false },
|
||||||
|
new Vendor { Company = unassignedCo, CompanyName = "Unassigned Co", ContactName = "U", IsActive = true, IsDeleted = false },
|
||||||
|
new Vendor { CompanyName = "Legacy Co", ContactName = "L", IsActive = true, IsDeleted = false });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
|
||||||
|
var eastResult = await service.GetCompanyDirectoryPagedAsync(
|
||||||
|
1, 50, isActive: true, areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } });
|
||||||
|
eastResult.Items.Select(item => item.CompanyName)
|
||||||
|
.Should().BeEquivalentTo("East Co", "East Only");
|
||||||
|
eastResult.TotalCount.Should().Be(2);
|
||||||
|
var eastRow = eastResult.Items.Single(item => item.CompanyName == "East Co");
|
||||||
|
eastRow.AreaId.Should().Be(east.Id);
|
||||||
|
eastRow.AreaName.Should().Be("East");
|
||||||
|
eastResult.Items.Single(item => item.CompanyName == "East Only").AreaName.Should().Be("East");
|
||||||
|
|
||||||
|
var eastOrUnassigned = await service.GetCompanyDirectoryPagedAsync(
|
||||||
|
1, 50, isActive: true,
|
||||||
|
areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id }, IncludeUnassigned = true });
|
||||||
|
eastOrUnassigned.Items.Select(item => item.CompanyName)
|
||||||
|
.Should().BeEquivalentTo("East Co", "East Only", "Unassigned Co", "Legacy Co");
|
||||||
|
eastOrUnassigned.Items.Single(item => item.CompanyName == "Legacy Co").AreaId.Should().BeNull();
|
||||||
|
|
||||||
|
var eastAndBoston = await service.GetCompanyDirectoryPagedAsync(
|
||||||
|
1, 50, isActive: true, locations: new[] { "Boston, MA" },
|
||||||
|
areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } });
|
||||||
|
eastAndBoston.Items.Should().ContainSingle(item => item.CompanyName == "East Co");
|
||||||
|
|
||||||
|
var matchesNobody = await service.GetCompanyDirectoryPagedAsync(
|
||||||
|
1, 50, isActive: true, areaFilter: new VendorAreaFilter());
|
||||||
|
matchesNobody.TotalCount.Should().Be(0);
|
||||||
|
matchesNobody.Items.Should().BeEmpty();
|
||||||
|
|
||||||
|
var unfiltered = await service.GetCompanyDirectoryPagedAsync(1, 50, isActive: true);
|
||||||
|
unfiltered.TotalCount.Should().Be(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveAreasAsync_ExcludesArchivedAreas()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
context.Areas.AddRange(
|
||||||
|
new Area { Id = 1, Name = "East", NormalizedName = "east" },
|
||||||
|
new Area { Id = 4, Name = "California", NormalizedName = "california", IsActive = false });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var areas = await new VendorDataService(context).GetActiveAreasAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
areas.Select(area => area.Name).Should().Equal("East");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ public class VendorServiceTests
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<VendorAreaFilter?>(),
|
||||||
token))
|
token))
|
||||||
.ReturnsAsync((new[]
|
.ReturnsAsync((new[]
|
||||||
{
|
{
|
||||||
|
|
@ -212,6 +213,7 @@ public class VendorServiceTests
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<VendorAreaFilter?>(),
|
||||||
token), Times.Once);
|
token), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,6 +230,7 @@ public class VendorServiceTests
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<VendorAreaFilter?>(),
|
||||||
It.IsAny<CancellationToken>()))
|
It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
|
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
|
||||||
|
|
||||||
|
|
@ -756,6 +759,8 @@ public class VendorServiceTests
|
||||||
});
|
});
|
||||||
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Trade>());
|
.ReturnsAsync(new List<Trade>());
|
||||||
|
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Area>());
|
||||||
|
|
||||||
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
|
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
|
||||||
|
|
||||||
|
|
@ -794,6 +799,8 @@ public class VendorServiceTests
|
||||||
});
|
});
|
||||||
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Trade>());
|
.ReturnsAsync(new List<Trade>());
|
||||||
|
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Area>());
|
||||||
|
|
||||||
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
|
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
|
||||||
|
|
||||||
|
|
@ -822,6 +829,8 @@ public class VendorServiceTests
|
||||||
});
|
});
|
||||||
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Trade>());
|
.ReturnsAsync(new List<Trade>());
|
||||||
|
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Area>());
|
||||||
|
|
||||||
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
|
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
|
||||||
|
|
||||||
|
|
@ -849,6 +858,8 @@ public class VendorServiceTests
|
||||||
SortOrder = index + 1
|
SortOrder = index + 1
|
||||||
})
|
})
|
||||||
.ToList());
|
.ToList());
|
||||||
|
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Area>());
|
||||||
|
|
||||||
var facets = await NewService(data).GetFacetsAsync(true, cts.Token);
|
var facets = await NewService(data).GetFacetsAsync(true, cts.Token);
|
||||||
|
|
||||||
|
|
@ -898,4 +909,96 @@ public class VendorServiceTests
|
||||||
TradeCatalog.CanonicalTrades,
|
TradeCatalog.CanonicalTrades,
|
||||||
opts => opts.WithStrictOrdering());
|
opts => opts.WithStrictOrdering());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Mock<IVendorDataService> DirectoryDataWithAreas(Action<VendorAreaFilter?> capture)
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Area>
|
||||||
|
{
|
||||||
|
new() { Id = 1, Name = "East", NormalizedName = "east" },
|
||||||
|
new() { Id = 3, Name = "West", NormalizedName = "west" }
|
||||||
|
});
|
||||||
|
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
|
||||||
|
It.IsAny<int>(),
|
||||||
|
It.IsAny<int>(),
|
||||||
|
It.IsAny<string?>(),
|
||||||
|
It.IsAny<bool?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<VendorAreaFilter?>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<int, int, string?, bool?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, VendorAreaFilter?, CancellationToken>(
|
||||||
|
(_, _, _, _, _, _, _, _, filter, _) => capture(filter))
|
||||||
|
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorCompanyDirectoryPaged_ResolvesAreaIdsAndUnassignedSentinel()
|
||||||
|
{
|
||||||
|
VendorAreaFilter? captured = null;
|
||||||
|
var data = DirectoryDataWithAreas(filter => captured = filter);
|
||||||
|
|
||||||
|
await NewService(data).GetVendorCompanyDirectoryPagedAsync(
|
||||||
|
1, 10, areas: new[] { " 1 ", "__unassigned__", "", "1" });
|
||||||
|
|
||||||
|
captured.Should().NotBeNull();
|
||||||
|
captured!.AreaIds.Should().Equal(1);
|
||||||
|
captured.IncludeUnassigned.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorCompanyDirectoryPaged_UnknownArchivedOrLabelAreaValuesMatchNobody()
|
||||||
|
{
|
||||||
|
VendorAreaFilter? captured = null;
|
||||||
|
var data = DirectoryDataWithAreas(filter => captured = filter);
|
||||||
|
|
||||||
|
// 4 is not in the active catalogue (unknown or archived); "West" is a label.
|
||||||
|
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(
|
||||||
|
1, 10, areas: new[] { "4", "West", "-3" });
|
||||||
|
|
||||||
|
captured.Should().NotBeNull();
|
||||||
|
captured!.AreaIds.Should().BeEmpty();
|
||||||
|
captured.IncludeUnassigned.Should().BeFalse();
|
||||||
|
result.Items.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorCompanyDirectoryPaged_BlankAreaValuesApplyNoAreaFilter()
|
||||||
|
{
|
||||||
|
VendorAreaFilter? captured = new();
|
||||||
|
var data = DirectoryDataWithAreas(filter => captured = filter);
|
||||||
|
|
||||||
|
await NewService(data).GetVendorCompanyDirectoryPagedAsync(1, 10, areas: new[] { " ", "" });
|
||||||
|
|
||||||
|
captured.Should().BeNull();
|
||||||
|
data.Verify(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFacetsAsync_AreasComeFromActiveCatalogueOrderedByName()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<VendorCompany>());
|
||||||
|
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Trade>());
|
||||||
|
data.Setup(x => x.GetActiveAreasAsync(cts.Token))
|
||||||
|
.ReturnsAsync(new List<Area>
|
||||||
|
{
|
||||||
|
new() { Id = 3, Name = "West", NormalizedName = "west" },
|
||||||
|
new() { Id = 1, Name = "East", NormalizedName = "east" },
|
||||||
|
new() { Id = 4, Name = "california", NormalizedName = "california" }
|
||||||
|
});
|
||||||
|
|
||||||
|
var facets = await NewService(data).GetFacetsAsync(null, cts.Token);
|
||||||
|
|
||||||
|
facets.Areas.Select(area => (area.Id, area.Name)).Should().Equal(
|
||||||
|
(4, "california"), (1, "East"), (3, "West"));
|
||||||
|
data.Verify(x => x.GetActiveAreasAsync(cts.Token), Times.Once);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
106
Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs
Normal file
106
Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SH-374: GET api/WorkOrder/GetWorkorderById?id= must read the id from the query string, while
|
||||||
|
/// GET api/WorkOrder/{id} keeps reading it from the route. Both must return the same work order,
|
||||||
|
/// and an unknown id keeps the existing "ID not found!" response.
|
||||||
|
/// </summary>
|
||||||
|
public class WorkOrderGetByIdBindingTests
|
||||||
|
{
|
||||||
|
private const int ExistingId = 374;
|
||||||
|
private const int UnknownId = 999_999;
|
||||||
|
|
||||||
|
private static ControllerActionDescriptor ActionForTemplate(string relativeTemplate)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddMvcCore().AddApplicationPart(typeof(WorkOrderController).Assembly);
|
||||||
|
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
return provider.GetRequiredService<IActionDescriptorCollectionProvider>().ActionDescriptors.Items
|
||||||
|
.OfType<ControllerActionDescriptor>()
|
||||||
|
.Where(cad => cad.ControllerTypeInfo == typeof(WorkOrderController))
|
||||||
|
.Where(cad => cad.ActionConstraints!
|
||||||
|
.OfType<Microsoft.AspNetCore.Mvc.ActionConstraints.HttpMethodActionConstraint>()
|
||||||
|
.Any(c => c.HttpMethods.Contains("GET")))
|
||||||
|
.Single(cad => string.Equals(
|
||||||
|
cad.AttributeRouteInfo?.Template?.Trim('/'),
|
||||||
|
$"api/WorkOrder/{relativeTemplate}",
|
||||||
|
StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("GetWorkorderById", "Query")]
|
||||||
|
[InlineData("{id:int}", "Path")]
|
||||||
|
public void Id_binds_from_the_source_its_route_provides(string relativeTemplate, string expectedSource)
|
||||||
|
{
|
||||||
|
var action = ActionForTemplate(relativeTemplate);
|
||||||
|
|
||||||
|
var id = action.Parameters.Single(p => p.Name == "id");
|
||||||
|
id.BindingInfo.Should().NotBeNull();
|
||||||
|
id.BindingInfo!.BindingSource!.Id.Should().Be(expectedSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (WorkOrderController Controller, WorkOrderDetailReadModel Existing) NewController()
|
||||||
|
{
|
||||||
|
var existing = new WorkOrderDetailReadModel { Id = ExistingId, Title = "Leaking roof" };
|
||||||
|
var service = new Mock<IWorkOrderService>();
|
||||||
|
service.Setup(s => s.GetWorkOrderDetailAsync(ExistingId, It.IsAny<int?>())).ReturnsAsync(existing);
|
||||||
|
service.Setup(s => s.GetWorkOrderDetailAsync(UnknownId, It.IsAny<int?>()))
|
||||||
|
.ReturnsAsync((WorkOrderDetailReadModel?)null);
|
||||||
|
|
||||||
|
var controller = new WorkOrderController(
|
||||||
|
service.Object,
|
||||||
|
Mock.Of<IWorkOrderAccountResolver>(),
|
||||||
|
Mock.Of<ILogger<WorkOrderController>>())
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||||
|
};
|
||||||
|
return (controller, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Existing_id_returns_the_same_work_order_through_both_routes()
|
||||||
|
{
|
||||||
|
var (controller, existing) = NewController();
|
||||||
|
|
||||||
|
var byQuery = await controller.GetWorkorderById(ExistingId);
|
||||||
|
var byRoute = await controller.GetWorkorderByRouteId(ExistingId);
|
||||||
|
|
||||||
|
byQuery.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeSameAs(existing);
|
||||||
|
byRoute.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeSameAs(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_id_keeps_the_not_found_response_through_both_routes()
|
||||||
|
{
|
||||||
|
var (controller, _) = NewController();
|
||||||
|
|
||||||
|
foreach (var result in new[]
|
||||||
|
{
|
||||||
|
await controller.GetWorkorderById(UnknownId),
|
||||||
|
await controller.GetWorkorderByRouteId(UnknownId),
|
||||||
|
})
|
||||||
|
{
|
||||||
|
var body = result.Should().BeOfType<BadRequestObjectResult>().Which.Value
|
||||||
|
.Should().BeOfType<Response>().Subject;
|
||||||
|
body.Status.Should().Be("Error");
|
||||||
|
body.Message.Should().Be("ID not found!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,8 +17,8 @@ namespace Api.SeaHavenIndustries.Tests;
|
||||||
/// combination is registered more than once by different actions.
|
/// combination is registered more than once by different actions.
|
||||||
///
|
///
|
||||||
/// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the
|
/// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the
|
||||||
/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder,
|
/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder)
|
||||||
/// GetWorkorderById) and the two shared controller-level base routes.
|
/// and the two shared controller-level base routes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class WorkOrderRouteContractTests
|
public class WorkOrderRouteContractTests
|
||||||
{
|
{
|
||||||
|
|
@ -39,7 +39,7 @@ public class WorkOrderRouteContractTests
|
||||||
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
|
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
|
||||||
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
|
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
|
||||||
/// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes
|
/// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes
|
||||||
/// come from 50 actions (Editworkorder and GetWorkorderById each bind two routes).
|
/// come from 51 actions (Editworkorder binds two routes).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
open = stats.Open,
|
open = stats.Open,
|
||||||
notDispatched = stats.NotDispatched,
|
notDispatched = stats.NotDispatched,
|
||||||
completed = stats.Completed,
|
completed = stats.Completed,
|
||||||
|
scheduledTomorrow = stats.ScheduledTomorrow,
|
||||||
|
pendingUplifts = stats.PendingUplifts,
|
||||||
|
avetaPending = stats.AvetaPending,
|
||||||
breakdown = stats.Breakdown,
|
breakdown = stats.Breakdown,
|
||||||
dueCount = stats.DueCount,
|
dueCount = stats.DueCount,
|
||||||
completedDueCount = stats.CompletedDueCount,
|
completedDueCount = stats.CompletedDueCount,
|
||||||
|
|
@ -109,5 +112,64 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("Trend")]
|
||||||
|
public async Task<IActionResult> GetTrend(
|
||||||
|
[FromQuery] DashboardTrendQueryDTO query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var trend = await _dashboardService.GetTrendAsync(User, query, cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
granularity = trend.Granularity,
|
||||||
|
today = trend.Today,
|
||||||
|
buckets = trend.Buckets.Select(bucket => new
|
||||||
|
{
|
||||||
|
date = bucket.Date,
|
||||||
|
label = bucket.Label,
|
||||||
|
total = bucket.Total,
|
||||||
|
open = bucket.Open,
|
||||||
|
completed = bucket.Completed,
|
||||||
|
canceled = bucket.Canceled,
|
||||||
|
overdue = bucket.Overdue,
|
||||||
|
isCurrent = bucket.IsCurrent
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
|
||||||
|
when (ex.Code == "Forbidden")
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||||
|
{
|
||||||
|
code = ex.Code,
|
||||||
|
message = ex.Message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("VendorInsights")]
|
||||||
|
public async Task<IActionResult> GetVendorInsights(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Ok(await _dashboardService.GetVendorInsightsAsync(User, cancellationToken));
|
||||||
|
}
|
||||||
|
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
|
||||||
|
when (ex.Code == "Forbidden")
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||||
|
{
|
||||||
|
code = ex.Code,
|
||||||
|
message = ex.Message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,11 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("GetLocationList")]
|
[HttpGet("GetLocationList")]
|
||||||
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, string? sortBy = null, string? sortDirection = null, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken);
|
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection);
|
||||||
|
|
||||||
var data = pagedResult.Items.Select(l => new
|
var data = pagedResult.Items.Select(l => new
|
||||||
{
|
{
|
||||||
|
|
@ -58,6 +58,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
ContactEmail = l.Email,
|
ContactEmail = l.Email,
|
||||||
Status = l.Status,
|
Status = l.Status,
|
||||||
AccountId = l.AccountId,
|
AccountId = l.AccountId,
|
||||||
|
ClientName = l.AccountName,
|
||||||
|
AccountName = l.AccountName,
|
||||||
Contacts = l.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
|
Contacts = l.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/User/{userId}/Permissions")]
|
||||||
|
public sealed class TeamPermissionController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ITeamPermissionService _permissionService;
|
||||||
|
|
||||||
|
public TeamPermissionController(ITeamPermissionService permissionService)
|
||||||
|
{
|
||||||
|
_permissionService = permissionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetProfile(
|
||||||
|
string userId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _permissionService.GetProfileAsync(userId, User, cancellationToken);
|
||||||
|
return ToActionResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{permissionKey}")]
|
||||||
|
public async Task<IActionResult> SetOverride(
|
||||||
|
string userId,
|
||||||
|
string permissionKey,
|
||||||
|
[FromBody] SetTeamPermissionOverrideDTO request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Enum.IsDefined(request.State))
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = "Invalid permission state." });
|
||||||
|
|
||||||
|
var result = await _permissionService.SetOverrideAsync(
|
||||||
|
userId,
|
||||||
|
permissionKey,
|
||||||
|
request.State,
|
||||||
|
User,
|
||||||
|
cancellationToken);
|
||||||
|
return ToActionResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IActionResult ToActionResult(
|
||||||
|
TeamPermissionResult<TeamPermissionProfileDTO> result)
|
||||||
|
{
|
||||||
|
return result.Status switch
|
||||||
|
{
|
||||||
|
TeamPermissionResultStatus.Success => new OkObjectResult(result.Value),
|
||||||
|
TeamPermissionResultStatus.Forbidden => new ForbidResult(),
|
||||||
|
TeamPermissionResultStatus.NotFound => new NotFoundObjectResult(
|
||||||
|
new Response { Status = "Error", Message = "User not found." }),
|
||||||
|
TeamPermissionResultStatus.InvalidPermissionKey => new BadRequestObjectResult(
|
||||||
|
new Response { Status = "Error", Message = "Unknown permission key." }),
|
||||||
|
_ => new BadRequestObjectResult(
|
||||||
|
new Response { Status = "Error", Message = "Unable to update permissions." })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ using Api.SeaHavenIndustries.Helper;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
@ -70,7 +71,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var roster = await _rosterService.CreateRosterAsync(model, userId, cancellationToken);
|
var roster = await _rosterService.CreateRosterAsync(model, userId, User, cancellationToken);
|
||||||
return Ok(roster);
|
return Ok(roster);
|
||||||
}
|
}
|
||||||
catch (ValidationException vex)
|
catch (ValidationException vex)
|
||||||
|
|
@ -82,6 +83,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
}
|
}
|
||||||
|
catch (VendorAreaAssignmentForbiddenException)
|
||||||
|
{
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status403Forbidden,
|
||||||
|
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
|
||||||
|
}
|
||||||
catch (VendorRosterDuplicateNameException dup)
|
catch (VendorRosterDuplicateNameException dup)
|
||||||
{
|
{
|
||||||
return Conflict(new
|
return Conflict(new
|
||||||
|
|
@ -109,7 +116,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, cancellationToken);
|
var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, User, cancellationToken);
|
||||||
return Ok(roster);
|
return Ok(roster);
|
||||||
}
|
}
|
||||||
catch (ValidationException vex)
|
catch (ValidationException vex)
|
||||||
|
|
@ -121,6 +128,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
}
|
}
|
||||||
|
catch (VendorAreaAssignmentForbiddenException)
|
||||||
|
{
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status403Forbidden,
|
||||||
|
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
|
||||||
|
}
|
||||||
catch (KeyNotFoundException)
|
catch (KeyNotFoundException)
|
||||||
{
|
{
|
||||||
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
|
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
|
||||||
|
|
@ -164,7 +177,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, cancellationToken);
|
var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, User, cancellationToken);
|
||||||
return Ok(roster);
|
return Ok(roster);
|
||||||
}
|
}
|
||||||
catch (ValidationException vex)
|
catch (ValidationException vex)
|
||||||
|
|
@ -176,6 +189,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
}
|
}
|
||||||
|
catch (VendorAreaAssignmentForbiddenException)
|
||||||
|
{
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status403Forbidden,
|
||||||
|
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
|
||||||
|
}
|
||||||
catch (KeyNotFoundException)
|
catch (KeyNotFoundException)
|
||||||
{
|
{
|
||||||
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
|
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[FromQuery] string[]? trades = null,
|
[FromQuery] string[]? trades = null,
|
||||||
[FromQuery] string[]? locations = null,
|
[FromQuery] string[]? locations = null,
|
||||||
[FromQuery] string[]? jobBuckets = null,
|
[FromQuery] string[]? jobBuckets = null,
|
||||||
|
[FromQuery] string[]? areas = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync(
|
var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync(
|
||||||
|
|
@ -108,6 +109,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
trades,
|
trades,
|
||||||
locations,
|
locations,
|
||||||
jobBuckets,
|
jobBuckets,
|
||||||
|
areas,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
var data = pagedResult.Items.Select(v => new
|
var data = pagedResult.Items.Select(v => new
|
||||||
|
|
@ -129,6 +131,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
v.Notes,
|
v.Notes,
|
||||||
v.IsActive,
|
v.IsActive,
|
||||||
v.TotalJobs,
|
v.TotalJobs,
|
||||||
|
v.AreaId,
|
||||||
|
v.AreaName,
|
||||||
Technicians = v.Technicians.Select(t => new
|
Technicians = v.Technicians.Select(t => new
|
||||||
{
|
{
|
||||||
t.Id,
|
t.Id,
|
||||||
|
|
|
||||||
|
|
@ -208,9 +208,15 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:int}")]
|
// SH-374: two actions, one per route, so each binds id from the source its route provides.
|
||||||
|
// A single action carrying both routes inferred id as [FromRoute] and the query route got 0.
|
||||||
[HttpGet("GetWorkorderById")]
|
[HttpGet("GetWorkorderById")]
|
||||||
public async Task<IActionResult> GetWorkorderById(int id)
|
public Task<IActionResult> GetWorkorderById([FromQuery] int id) => GetWorkorderDetail(id);
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public Task<IActionResult> GetWorkorderByRouteId([FromRoute] int id) => GetWorkorderDetail(id);
|
||||||
|
|
||||||
|
private async Task<IActionResult> GetWorkorderDetail(int id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,51 @@ namespace Data.SeaHavenIndustries
|
||||||
builder.Entity<Trade>()
|
builder.Entity<Trade>()
|
||||||
.HasIndex(t => t.NormalizedName)
|
.HasIndex(t => t.NormalizedName)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
// SH-278 Area catalogue: bounded names so the unique key is indexable.
|
||||||
|
builder.Entity<Area>()
|
||||||
|
.Property(a => a.Name)
|
||||||
|
.HasMaxLength(128);
|
||||||
|
|
||||||
|
builder.Entity<Area>()
|
||||||
|
.Property(a => a.NormalizedName)
|
||||||
|
.HasMaxLength(128);
|
||||||
|
|
||||||
|
builder.Entity<Area>()
|
||||||
|
.HasIndex(a => a.NormalizedName)
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
builder.Entity<VendorCompany>()
|
||||||
|
.HasOne(c => c.Area)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.AreaId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// Per-person team permission overrides. Composite primary key
|
||||||
|
// (UserId + PermissionKey) plus an explicitly named unique composite
|
||||||
|
// index; missing rows behave as Unset.
|
||||||
|
builder.Entity<UserPermissionOverride>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(o => new { o.UserId, o.PermissionKey });
|
||||||
|
|
||||||
|
entity.Property(o => o.UserId)
|
||||||
|
.HasMaxLength(450);
|
||||||
|
|
||||||
|
entity.Property(o => o.PermissionKey)
|
||||||
|
.HasMaxLength(64);
|
||||||
|
|
||||||
|
entity.Property(o => o.State)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
entity.HasOne(o => o.User)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(o => o.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
entity.HasIndex(o => new { o.UserId, o.PermissionKey })
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
public DbSet<Category> Categories { get; set; }
|
public DbSet<Category> Categories { get; set; }
|
||||||
public DbSet<Locations> Locations { get; set; }
|
public DbSet<Locations> Locations { get; set; }
|
||||||
|
|
@ -228,6 +273,7 @@ namespace Data.SeaHavenIndustries
|
||||||
public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; }
|
public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; }
|
||||||
public DbSet<DropdownOption> DropdownOptions { get; set; }
|
public DbSet<DropdownOption> DropdownOptions { get; set; }
|
||||||
public DbSet<Trade> Trades { get; set; }
|
public DbSet<Trade> Trades { get; set; }
|
||||||
|
public DbSet<Area> Areas { get; set; }
|
||||||
public DbSet<Vendor> Vendors { get; set; }
|
public DbSet<Vendor> Vendors { get; set; }
|
||||||
public DbSet<VendorCompany> VendorCompanies { get; set; }
|
public DbSet<VendorCompany> VendorCompanies { get; set; }
|
||||||
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }
|
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }
|
||||||
|
|
@ -259,6 +305,7 @@ namespace Data.SeaHavenIndustries
|
||||||
public DbSet<Department> Departments { get; set; }
|
public DbSet<Department> Departments { get; set; }
|
||||||
public DbSet<JobTitle> JobTitles { get; set; }
|
public DbSet<JobTitle> JobTitles { get; set; }
|
||||||
public DbSet<Region> Regions { get; set; }
|
public DbSet<Region> Regions { get; set; }
|
||||||
|
public DbSet<UserPermissionOverride> UserPermissionOverrides { get; set; }
|
||||||
|
|
||||||
public override int SaveChanges()
|
public override int SaveChanges()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
18
Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs
Normal file
18
Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Per-person team permission override, keyed by user and canonical
|
||||||
|
/// permission key. Explicit Allow/Deny rows take precedence over role defaults;
|
||||||
|
/// <see cref="UserPermissionState.Unset"/> (and a missing row) falls back to
|
||||||
|
/// the role default.
|
||||||
|
/// </summary>
|
||||||
|
public class UserPermissionOverride
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = null!;
|
||||||
|
public string PermissionKey { get; set; } = null!;
|
||||||
|
public UserPermissionState State { get; set; } = UserPermissionState.Unset;
|
||||||
|
public virtual ApplicationUser? User { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Data.SeaHavenIndustries/Enums/PermissionEnums.cs
Normal file
13
Data.SeaHavenIndustries/Enums/PermissionEnums.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
namespace Data.SeaHavenIndustries.Enums
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Tri-state state of a per-person team permission override.
|
||||||
|
/// A missing row behaves the same as <see cref="Unset"/>.
|
||||||
|
/// </summary>
|
||||||
|
public enum UserPermissionState
|
||||||
|
{
|
||||||
|
Unset = 0,
|
||||||
|
Allow = 1,
|
||||||
|
Deny = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
3977
Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.Designer.cs
generated
Normal file
3977
Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,88 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SH278_VendorCompanyArea : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "AreaId",
|
||||||
|
table: "VendorCompanies",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Areas",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||||
|
NormalizedName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "bit", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Areas", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_VendorCompanies_AreaId",
|
||||||
|
table: "VendorCompanies",
|
||||||
|
column: "AreaId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Areas_NormalizedName",
|
||||||
|
table: "Areas",
|
||||||
|
column: "NormalizedName",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
// SH-278 v1 seed of the four dispatcher territories confirmed by Product.
|
||||||
|
// Names match the merged SH-273 Sites mapping (East / Central / West /
|
||||||
|
// California). Ids are stable; later renames change only the display name.
|
||||||
|
// Existing vendor companies stay Unassigned (no backfill).
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "Areas",
|
||||||
|
columns: new[] { "Id", "Name", "NormalizedName", "IsActive" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 1, "East", "east", true },
|
||||||
|
{ 2, "Central", "central", true },
|
||||||
|
{ 3, "West", "west", true },
|
||||||
|
{ 4, "California", "california", true }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_VendorCompanies_Areas_AreaId",
|
||||||
|
table: "VendorCompanies",
|
||||||
|
column: "AreaId",
|
||||||
|
principalTable: "Areas",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_VendorCompanies_Areas_AreaId",
|
||||||
|
table: "VendorCompanies");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Areas");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_VendorCompanies_AreaId",
|
||||||
|
table: "VendorCompanies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AreaId",
|
||||||
|
table: "VendorCompanies");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3966
Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.Designer.cs
generated
Normal file
3966
Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,46 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SH327_UserPermissionOverrides : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "UserPermissionOverrides",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
|
||||||
|
PermissionKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
State = table.Column<int>(type: "int", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_UserPermissionOverrides", x => new { x.UserId, x.PermissionKey });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_UserPermissionOverrides_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UserPermissionOverrides_UserId_PermissionKey",
|
||||||
|
table: "UserPermissionOverrides",
|
||||||
|
columns: new[] { "UserId", "PermissionKey" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "UserPermissionOverrides");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -286,6 +286,35 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.ToTable("AspNetUsers", (string)null);
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Data.SeaHavenIndustries.Area", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Areas");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -2043,6 +2072,28 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.ToTable("Trades");
|
b.ToTable("Trades");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("PermissionKey")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<int>("State")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "PermissionKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "PermissionKey")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey");
|
||||||
|
|
||||||
|
b.ToTable("UserPermissionOverrides");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -2237,6 +2288,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Property<string>("Address")
|
b.Property<string>("Address")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("AreaId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("City")
|
b.Property<string>("City")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
|
@ -2294,6 +2348,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AreaId");
|
||||||
|
|
||||||
b.HasIndex("NormalizedName")
|
b.HasIndex("NormalizedName")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
|
@ -3582,6 +3638,17 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Navigation("POC");
|
b.Navigation("POC");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company")
|
b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company")
|
||||||
|
|
@ -3614,6 +3681,16 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Navigation("Vendor");
|
b.Navigation("Vendor");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Data.SeaHavenIndustries.Area", "Area")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AreaId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("Area");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")
|
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")
|
||||||
|
|
|
||||||
15
Data.SeaHavenIndustries/Models/Area.cs
Normal file
15
Data.SeaHavenIndustries/Models/Area.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
namespace Data.SeaHavenIndustries
|
||||||
|
{
|
||||||
|
// SH-278: dispatcher territory catalogue for this Sea Haven deployment. Vendor
|
||||||
|
// companies carry zero or one Area by stable id; the display name is separate and
|
||||||
|
// may be renamed without changing assignments. Area is selected independently and
|
||||||
|
// is never derived from a company's State. Rows are organization-wide: vendor
|
||||||
|
// companies are not scoped to a customer Account.
|
||||||
|
public class Area
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string NormalizedName { get; set; } = "";
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,11 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
// SH-278: optional dispatcher territory (0 or 1 per company), admin-assigned.
|
||||||
|
public int? AreaId { get; set; }
|
||||||
|
[ForeignKey(nameof(AreaId))]
|
||||||
|
public Area? Area { get; set; }
|
||||||
|
|
||||||
[Timestamp]
|
[Timestamp]
|
||||||
public byte[]? RowVersion { get; set; }
|
public byte[]? RowVersion { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
11
SeaHaven.DataServices/Dto/TeamPermissionReadModels.cs
Normal file
11
SeaHaven.DataServices/Dto/TeamPermissionReadModels.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Dto;
|
||||||
|
|
||||||
|
public sealed class TeamPermissionUserData
|
||||||
|
{
|
||||||
|
public required string UserId { get; init; }
|
||||||
|
public string? RoleName { get; init; }
|
||||||
|
public IReadOnlyDictionary<string, UserPermissionState> Overrides { get; init; }
|
||||||
|
= new Dictionary<string, UserPermissionState>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
@ -71,10 +71,87 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
w.ScheduledDate,
|
w.ScheduledDate,
|
||||||
w.OriginalDate,
|
w.OriginalDate,
|
||||||
w.CompletedDate,
|
w.CompletedDate,
|
||||||
w.Locations == null ? null : w.Locations.State))
|
w.Locations == null ? null : w.Locations.State,
|
||||||
|
w.RescheduleCount,
|
||||||
|
w.PrimaryDispatch == null || w.PrimaryDispatch.Vendor == null
|
||||||
|
? null
|
||||||
|
: w.PrimaryDispatch.Vendor.CompanyId,
|
||||||
|
w.PrimaryDispatch == null || w.PrimaryDispatch.Vendor == null
|
||||||
|
|| w.PrimaryDispatch.Vendor.Company == null
|
||||||
|
? null
|
||||||
|
: w.PrimaryDispatch.Vendor.Company.Name,
|
||||||
|
w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||||
|
&& w.PrimaryDispatch.Vendor.IsActive,
|
||||||
|
w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||||
|
&& w.PrimaryDispatch.Vendor.Company != null
|
||||||
|
&& w.PrimaryDispatch.Vendor.Company.IsDeleted == true))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<DashboardKpiCounts> GetKpiCountsAsync(
|
||||||
|
int? accountId,
|
||||||
|
string? dispatcherId,
|
||||||
|
DateOnly today,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tomorrow = today.AddDays(1);
|
||||||
|
var dayAfterTomorrow = tomorrow.AddDays(1);
|
||||||
|
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||||
|
if (accountId is int scopedAccountId)
|
||||||
|
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId);
|
||||||
|
if (dispatcherId != null)
|
||||||
|
workOrders = workOrders.Where(w => w.AssignTo == dispatcherId);
|
||||||
|
|
||||||
|
var scheduledTomorrow = await workOrders
|
||||||
|
.Where(w => w.ScheduledDate >= tomorrow.ToDateTime(TimeOnly.MinValue)
|
||||||
|
&& w.ScheduledDate < dayAfterTomorrow.ToDateTime(TimeOnly.MinValue))
|
||||||
|
.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
var avetaPending = await workOrders
|
||||||
|
.Where(w => w.ScheduledDate >= today.ToDateTime(TimeOnly.MinValue)
|
||||||
|
&& w.ScheduledDate < dayAfterTomorrow.ToDateTime(TimeOnly.MinValue)
|
||||||
|
&& w.AvetaRequired
|
||||||
|
&& w.LifecycleStatus != LifecycleStatus.Completed
|
||||||
|
&& w.LifecycleStatus != LifecycleStatus.Canceled
|
||||||
|
&& (w.LifecycleStatus != null
|
||||||
|
|| ((w.LegacyStatus ?? w.Status) != "Done"
|
||||||
|
&& (w.LegacyStatus ?? w.Status) != "Completed"
|
||||||
|
&& (w.LegacyStatus ?? w.Status) != "Complete"
|
||||||
|
&& (w.LegacyStatus ?? w.Status) != "Closed"
|
||||||
|
&& (w.LegacyStatus ?? w.Status) != "Canceled"
|
||||||
|
&& (w.LegacyStatus ?? w.Status) != "Cancelled"))
|
||||||
|
&& !w.workOrderAttachments!.Any(attachment =>
|
||||||
|
(attachment.IsDeleted == null || attachment.IsDeleted == false)
|
||||||
|
&& attachment.Category == WorkOrderMediaCategory.Aveta))
|
||||||
|
.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
var pendingUplifts = _context.DispatchUpliftRequests
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(request => request.Status == "Pending"
|
||||||
|
&& (request.IsDeleted == null || request.IsDeleted == false)
|
||||||
|
&& request.Dispatch != null
|
||||||
|
&& (request.Dispatch.IsDeleted == null || request.Dispatch.IsDeleted == false));
|
||||||
|
if (accountId is int account)
|
||||||
|
{
|
||||||
|
pendingUplifts = pendingUplifts.Where(request =>
|
||||||
|
(request.Dispatch!.WorkOrder != null && request.Dispatch.WorkOrder.AccountId == account)
|
||||||
|
|| request.Dispatch.DispatchWorkOrders!.Any(link =>
|
||||||
|
link.WorkOrder != null && link.WorkOrder.AccountId == account));
|
||||||
|
}
|
||||||
|
if (dispatcherId != null)
|
||||||
|
{
|
||||||
|
pendingUplifts = pendingUplifts.Where(request =>
|
||||||
|
(request.Dispatch!.WorkOrder != null && request.Dispatch.WorkOrder.AssignTo == dispatcherId)
|
||||||
|
|| request.Dispatch.DispatchWorkOrders!.Any(link =>
|
||||||
|
link.WorkOrder != null && link.WorkOrder.AssignTo == dispatcherId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DashboardKpiCounts(
|
||||||
|
scheduledTomorrow,
|
||||||
|
await pendingUplifts.CountAsync(cancellationToken),
|
||||||
|
avetaPending);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<DashboardDispatcherWorkload>> GetDispatcherWorkloadAsync(
|
public async Task<IReadOnlyList<DashboardDispatcherWorkload>> GetDispatcherWorkloadAsync(
|
||||||
int? accountId,
|
int? accountId,
|
||||||
string? dispatcherId,
|
string? dispatcherId,
|
||||||
|
|
|
||||||
|
|
@ -136,15 +136,21 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
int pageSize,
|
int pageSize,
|
||||||
string? search,
|
string? search,
|
||||||
IReadOnlyCollection<string>? states,
|
IReadOnlyCollection<string>? states,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken,
|
||||||
|
string? sortBy = null,
|
||||||
|
string? sortDirection = null)
|
||||||
{
|
{
|
||||||
var query = _context.Locations.AsNoTracking();
|
IQueryable<Locations> query = _context.Locations
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(l => l.Account);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
{
|
{
|
||||||
query = query.Where(l =>
|
query = query.Where(l =>
|
||||||
l.Name!.Contains(search) ||
|
l.Name!.Contains(search) ||
|
||||||
l.City!.Contains(search));
|
l.Address1!.Contains(search) ||
|
||||||
|
l.City!.Contains(search) ||
|
||||||
|
l.Account != null && l.Account.Name!.Contains(search));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (states is { Count: > 0 })
|
if (states is { Count: > 0 })
|
||||||
|
|
@ -152,9 +158,10 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
query = query.Where(l => l.State != null && states.Contains(l.State.Trim().ToUpper()));
|
query = query.Where(l => l.State != null && states.Contains(l.State.Trim().ToUpper()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query = ApplyListSort(query, sortBy, sortDirection);
|
||||||
|
|
||||||
var totalCount = await query.CountAsync(cancellationToken);
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
var items = await query
|
var items = await query
|
||||||
.OrderBy(l => l.Name)
|
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
@ -162,6 +169,33 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return (items, totalCount);
|
return (items, totalCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IQueryable<Locations> ApplyListSort(IQueryable<Locations> query, string? sortBy, string? sortDirection)
|
||||||
|
{
|
||||||
|
var descending = string.Equals(sortDirection?.Trim(), "desc", StringComparison.OrdinalIgnoreCase);
|
||||||
|
IOrderedQueryable<Locations> ordered = (sortBy?.Trim().ToLowerInvariant()) switch
|
||||||
|
{
|
||||||
|
"code" => descending ? query.OrderByDescending(l => l.Name) : query.OrderBy(l => l.Name),
|
||||||
|
"client" => descending ? query.OrderByDescending(l => l.Account!.Name) : query.OrderBy(l => l.Account!.Name),
|
||||||
|
"address" => descending ? query.OrderByDescending(l => l.Address1) : query.OrderBy(l => l.Address1),
|
||||||
|
"city" => descending ? query.OrderByDescending(l => l.City) : query.OrderBy(l => l.City),
|
||||||
|
"state" => descending ? query.OrderByDescending(l => l.State) : query.OrderBy(l => l.State),
|
||||||
|
"poc" => descending
|
||||||
|
? query.OrderByDescending(FirstActiveContactName)
|
||||||
|
: query.OrderBy(FirstActiveContactName),
|
||||||
|
_ => query.OrderBy(l => l.Name)
|
||||||
|
};
|
||||||
|
|
||||||
|
return ordered.ThenBy(l => l.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly System.Linq.Expressions.Expression<Func<Locations, string?>> FirstActiveContactName =
|
||||||
|
l => l.Contacts
|
||||||
|
.Where(c => c.IsDeleted != true)
|
||||||
|
.OrderBy(c => c.SiteContactOrder)
|
||||||
|
.ThenBy(c => c.Id)
|
||||||
|
.Select(c => c.FirstName)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
public async Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
public async Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await _context.Locations
|
return await _context.Locations
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Dto;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation;
|
||||||
|
|
||||||
|
public sealed class TeamPermissionOverrideDataService : ITeamPermissionOverrideDataService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public TeamPermissionOverrideDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TeamPermissionUserData?> GetUserAsync(
|
||||||
|
string userId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userExists = await _context.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.AnyAsync(user => user.Id == userId, cancellationToken);
|
||||||
|
|
||||||
|
if (!userExists)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var roleName = await (
|
||||||
|
from userRole in _context.UserRoles.AsNoTracking()
|
||||||
|
join role in _context.Roles.AsNoTracking()
|
||||||
|
on userRole.RoleId equals role.Id
|
||||||
|
where userRole.UserId == userId
|
||||||
|
select role.Name)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
var overrides = await _context.UserPermissionOverrides
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(permission => permission.UserId == userId)
|
||||||
|
.ToDictionaryAsync(
|
||||||
|
permission => permission.PermissionKey,
|
||||||
|
permission => permission.State,
|
||||||
|
StringComparer.OrdinalIgnoreCase,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return new TeamPermissionUserData
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
RoleName = roleName,
|
||||||
|
Overrides = overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetOverrideAsync(
|
||||||
|
string userId,
|
||||||
|
string permissionKey,
|
||||||
|
UserPermissionState state,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var existing = await _context.UserPermissionOverrides
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
permission => permission.UserId == userId
|
||||||
|
&& permission.PermissionKey == permissionKey,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
existing.State = state;
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var addition = new UserPermissionOverride
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
PermissionKey = permissionKey,
|
||||||
|
State = state
|
||||||
|
};
|
||||||
|
await _context.UserPermissionOverrides.AddAsync(addition, cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
// A concurrent PUT inserted the same (UserId, PermissionKey) between our
|
||||||
|
// existence check and this save, violating PK_UserPermissionOverrides.
|
||||||
|
// Converge on the caller's intent by updating the persisted row.
|
||||||
|
_context.Entry(addition).State = EntityState.Detached;
|
||||||
|
|
||||||
|
var winner = await _context.UserPermissionOverrides
|
||||||
|
.SingleAsync(
|
||||||
|
permission => permission.UserId == userId
|
||||||
|
&& permission.PermissionKey == permissionKey,
|
||||||
|
cancellationToken);
|
||||||
|
winner.State = state;
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -135,6 +135,10 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.Where(role => role.UserId == id)
|
.Where(role => role.UserId == id)
|
||||||
.ExecuteDeleteAsync(cancellationToken);
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
|
await _context.UserPermissionOverrides
|
||||||
|
.Where(permissionOverride => permissionOverride.UserId == id)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
await _context.Users
|
await _context.Users
|
||||||
.Where(existingUser => existingUser.Id == id)
|
.Where(existingUser => existingUser.Id == id)
|
||||||
.ExecuteDeleteAsync(cancellationToken);
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
_context = context;
|
_context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<bool> IsActiveAreaAsync(int areaId, CancellationToken cancellationToken) =>
|
||||||
|
_context.Areas
|
||||||
|
.AsNoTracking()
|
||||||
|
.AnyAsync(area => area.Id == areaId && area.IsActive, cancellationToken);
|
||||||
|
|
||||||
public async Task<VendorCompanyRosterReadModel?> GetRosterAsync(
|
public async Task<VendorCompanyRosterReadModel?> GetRosterAsync(
|
||||||
int? vendorId,
|
int? vendorId,
|
||||||
int? companyId,
|
int? companyId,
|
||||||
|
|
@ -57,6 +62,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
Zip = c.Zip,
|
Zip = c.Zip,
|
||||||
GoogleMapsUrl = c.GoogleMapsUrl,
|
GoogleMapsUrl = c.GoogleMapsUrl,
|
||||||
Notes = c.Notes,
|
Notes = c.Notes,
|
||||||
|
AreaId = c.AreaId,
|
||||||
|
AreaName = c.Area != null ? c.Area.Name : null,
|
||||||
RowVersion = c.RowVersion
|
RowVersion = c.RowVersion
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
@ -114,6 +121,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
company.Zip = roster.Zip;
|
company.Zip = roster.Zip;
|
||||||
company.GoogleMapsUrl = roster.GoogleMapsUrl;
|
company.GoogleMapsUrl = roster.GoogleMapsUrl;
|
||||||
company.Notes = roster.Notes;
|
company.Notes = roster.Notes;
|
||||||
|
if (roster.AreaIdProvided)
|
||||||
|
company.AreaId = roster.AreaId;
|
||||||
company.LastModificationTime = now;
|
company.LastModificationTime = now;
|
||||||
int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null;
|
int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null;
|
||||||
if (actorId.HasValue)
|
if (actorId.HasValue)
|
||||||
|
|
@ -245,6 +254,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
Zip = roster.Zip,
|
Zip = roster.Zip,
|
||||||
GoogleMapsUrl = roster.GoogleMapsUrl,
|
GoogleMapsUrl = roster.GoogleMapsUrl,
|
||||||
Notes = roster.Notes,
|
Notes = roster.Notes,
|
||||||
|
AreaId = roster.AreaId,
|
||||||
CreatedDate = now,
|
CreatedDate = now,
|
||||||
createdby = roster.ActorUserId
|
createdby = roster.ActorUserId
|
||||||
};
|
};
|
||||||
|
|
@ -367,6 +377,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
company.GoogleMapsUrl = fields.GoogleMapsUrl;
|
company.GoogleMapsUrl = fields.GoogleMapsUrl;
|
||||||
if (fields.Notes != null)
|
if (fields.Notes != null)
|
||||||
company.Notes = fields.Notes;
|
company.Notes = fields.Notes;
|
||||||
|
if (fields.AreaIdProvided)
|
||||||
|
company.AreaId = fields.AreaId;
|
||||||
|
|
||||||
// Keep the denormalized company columns on existing technicians aligned
|
// Keep the denormalized company columns on existing technicians aligned
|
||||||
// with the company row (same invariant SaveRosterAsync maintains). This
|
// with the company row (same invariant SaveRosterAsync maintains). This
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,15 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return query.OrderBy(c => c.Id);
|
return query.OrderBy(c => c.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Area>> GetActiveAreasAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Areas
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.IsActive)
|
||||||
|
.OrderBy(a => a.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<VendorCompany>> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<VendorCompany>> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken)
|
||||||
=> await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken);
|
=> await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
|
@ -177,6 +186,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
IReadOnlyCollection<string>? trades = null,
|
IReadOnlyCollection<string>? trades = null,
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null,
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
VendorAreaFilter? areaFilter = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Phase 1: select and paginate bounded company group keys in SQL.
|
// Phase 1: select and paginate bounded company group keys in SQL.
|
||||||
|
|
@ -186,7 +196,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
companies,
|
companies,
|
||||||
trades,
|
trades,
|
||||||
locations,
|
locations,
|
||||||
jobBuckets);
|
jobBuckets,
|
||||||
|
areaFilter);
|
||||||
|
|
||||||
var totalCount = await keyQuery.CountAsync(cancellationToken);
|
var totalCount = await keyQuery.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
|
@ -222,6 +233,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
? new Dictionary<int, VendorCompany>()
|
? new Dictionary<int, VendorCompany>()
|
||||||
: await _context.VendorCompanies
|
: await _context.VendorCompanies
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
.Include(c => c.Area)
|
||||||
.Where(c => missingCompanyIds.Contains(c.Id))
|
.Where(c => missingCompanyIds.Contains(c.Id))
|
||||||
.ToDictionaryAsync(c => c.Id, cancellationToken);
|
.ToDictionaryAsync(c => c.Id, cancellationToken);
|
||||||
|
|
||||||
|
|
@ -291,6 +303,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
TradeSpecialties = AggregateTradeTokens(techniciansNewestFirst),
|
TradeSpecialties = AggregateTradeTokens(techniciansNewestFirst),
|
||||||
GoogleMapsUrl = primary.GoogleMapsUrl,
|
GoogleMapsUrl = primary.GoogleMapsUrl,
|
||||||
Notes = linked ? primary.CompanyNotes : null,
|
Notes = linked ? primary.CompanyNotes : null,
|
||||||
|
AreaId = linked ? primary.CompanyAreaId : null,
|
||||||
|
AreaName = linked ? primary.CompanyAreaName : null,
|
||||||
IsActive = primary.IsActive,
|
IsActive = primary.IsActive,
|
||||||
TotalJobs = techniciansNewestFirst.Sum(row => row.TotalJobs),
|
TotalJobs = techniciansNewestFirst.Sum(row => row.TotalJobs),
|
||||||
Technicians = techniciansNewestFirst.Select(row => new VendorCompanyGroupTechnician
|
Technicians = techniciansNewestFirst.Select(row => new VendorCompanyGroupTechnician
|
||||||
|
|
@ -344,6 +358,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
TradeSpecialties = null,
|
TradeSpecialties = null,
|
||||||
GoogleMapsUrl = company.GoogleMapsUrl,
|
GoogleMapsUrl = company.GoogleMapsUrl,
|
||||||
Notes = company.Notes,
|
Notes = company.Notes,
|
||||||
|
AreaId = company.AreaId,
|
||||||
|
AreaName = company.Area?.Name,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
TotalJobs = 0,
|
TotalJobs = 0,
|
||||||
Technicians = Array.Empty<VendorCompanyGroupTechnician>()
|
Technicians = Array.Empty<VendorCompanyGroupTechnician>()
|
||||||
|
|
@ -355,10 +371,11 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
IReadOnlyCollection<string>? companies = null,
|
IReadOnlyCollection<string>? companies = null,
|
||||||
IReadOnlyCollection<string>? trades = null,
|
IReadOnlyCollection<string>? trades = null,
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null)
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
VendorAreaFilter? areaFilter = null)
|
||||||
{
|
{
|
||||||
var sourceRows = BuildDirectoryUnionBaseQuery(isActive)
|
var sourceRows = BuildDirectoryUnionBaseQuery(isActive)
|
||||||
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations));
|
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations, areaFilter));
|
||||||
|
|
||||||
var grouped = sourceRows
|
var grouped = sourceRows
|
||||||
.GroupBy(row => new { row.CompanyId, row.NameKey, row.VendorKey })
|
.GroupBy(row => new { row.CompanyId, row.NameKey, row.VendorKey })
|
||||||
|
|
@ -372,7 +389,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
SearchMatch = group.Max(row => row.SearchFlag),
|
SearchMatch = group.Max(row => row.SearchFlag),
|
||||||
CompanyMatch = group.Max(row => row.CompanyFlag),
|
CompanyMatch = group.Max(row => row.CompanyFlag),
|
||||||
TradeMatch = group.Max(row => row.TradeFlag),
|
TradeMatch = group.Max(row => row.TradeFlag),
|
||||||
LocationMatch = group.Max(row => row.LocationFlag)
|
LocationMatch = group.Max(row => row.LocationFlag),
|
||||||
|
AreaMatch = group.Max(row => row.AreaFlag)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Each facet selects the group when any status-eligible row matches it
|
// Each facet selects the group when any status-eligible row matches it
|
||||||
|
|
@ -405,6 +423,9 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
if (selectedLocations is { Length: > 0 })
|
if (selectedLocations is { Length: > 0 })
|
||||||
grouped = grouped.Where(key => key.LocationMatch == 1);
|
grouped = grouped.Where(key => key.LocationMatch == 1);
|
||||||
|
|
||||||
|
if (areaFilter != null)
|
||||||
|
grouped = grouped.Where(key => key.AreaMatch == 1);
|
||||||
|
|
||||||
var selectedJobBuckets = jobBuckets?
|
var selectedJobBuckets = jobBuckets?
|
||||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
.Select(value => value.Trim().ToLower())
|
.Select(value => value.Trim().ToLower())
|
||||||
|
|
@ -429,7 +450,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
string? search,
|
string? search,
|
||||||
IReadOnlyCollection<string>? companies,
|
IReadOnlyCollection<string>? companies,
|
||||||
IReadOnlyCollection<string>? trades,
|
IReadOnlyCollection<string>? trades,
|
||||||
IReadOnlyCollection<string>? locations)
|
IReadOnlyCollection<string>? locations,
|
||||||
|
VendorAreaFilter? areaFilter)
|
||||||
{
|
{
|
||||||
var row = Expression.Parameter(typeof(VendorDirectoryRow), "row");
|
var row = Expression.Parameter(typeof(VendorDirectoryRow), "row");
|
||||||
|
|
||||||
|
|
@ -466,6 +488,9 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
var locationBody = selectedLocations is { Length: > 0 }
|
var locationBody = selectedLocations is { Length: > 0 }
|
||||||
? BuildLocationMatchBody<VendorDirectoryRow>(row, selectedLocations)
|
? BuildLocationMatchBody<VendorDirectoryRow>(row, selectedLocations)
|
||||||
: null;
|
: null;
|
||||||
|
var areaBody = areaFilter == null
|
||||||
|
? null
|
||||||
|
: BuildAreaMatchBody(row, areaFilter);
|
||||||
|
|
||||||
var nameKey = Expression.Condition(
|
var nameKey = Expression.Condition(
|
||||||
Expression.NotEqual(
|
Expression.NotEqual(
|
||||||
|
|
@ -508,7 +533,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
Bind(nameof(VendorDirectoryGroupSourceRow.SearchFlag), Flag(searchBody)),
|
Bind(nameof(VendorDirectoryGroupSourceRow.SearchFlag), Flag(searchBody)),
|
||||||
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyFlag), Flag(companyBody)),
|
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyFlag), Flag(companyBody)),
|
||||||
Bind(nameof(VendorDirectoryGroupSourceRow.TradeFlag), Flag(tradeBody)),
|
Bind(nameof(VendorDirectoryGroupSourceRow.TradeFlag), Flag(tradeBody)),
|
||||||
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody))),
|
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody)),
|
||||||
|
Bind(nameof(VendorDirectoryGroupSourceRow.AreaFlag), Flag(areaBody))),
|
||||||
row);
|
row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -535,6 +561,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
CompanyCity = v.Company != null ? v.Company.City : null,
|
CompanyCity = v.Company != null ? v.Company.City : null,
|
||||||
CompanyState = v.Company != null ? v.Company.State : null,
|
CompanyState = v.Company != null ? v.Company.State : null,
|
||||||
CompanyNotes = v.Company != null ? v.Company.Notes : null,
|
CompanyNotes = v.Company != null ? v.Company.Notes : null,
|
||||||
|
CompanyAreaId = v.Company != null ? v.Company.AreaId : null,
|
||||||
|
CompanyAreaName = v.Company != null && v.Company.Area != null ? v.Company.Area.Name : null,
|
||||||
ContactName = v.ContactName,
|
ContactName = v.ContactName,
|
||||||
Email = v.Email,
|
Email = v.Email,
|
||||||
Phone = v.Phone,
|
Phone = v.Phone,
|
||||||
|
|
@ -702,6 +730,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
TradeSpecialties = v.TradeSpecialties,
|
TradeSpecialties = v.TradeSpecialties,
|
||||||
GoogleMapsUrl = v.GoogleMapsUrl,
|
GoogleMapsUrl = v.GoogleMapsUrl,
|
||||||
Notes = v.Company != null ? v.Company.Notes : null,
|
Notes = v.Company != null ? v.Company.Notes : null,
|
||||||
|
AreaId = v.Company != null ? v.Company.AreaId : null,
|
||||||
IsActive = v.IsActive,
|
IsActive = v.IsActive,
|
||||||
TotalJobs = (int?)jobCount.TotalJobs ?? 0
|
TotalJobs = (int?)jobCount.TotalJobs ?? 0
|
||||||
};
|
};
|
||||||
|
|
@ -736,6 +765,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
TradeSpecialties = null,
|
TradeSpecialties = null,
|
||||||
GoogleMapsUrl = c.GoogleMapsUrl,
|
GoogleMapsUrl = c.GoogleMapsUrl,
|
||||||
Notes = c.Notes,
|
Notes = c.Notes,
|
||||||
|
AreaId = c.AreaId,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
TotalJobs = 0
|
TotalJobs = 0
|
||||||
});
|
});
|
||||||
|
|
@ -777,6 +807,32 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return body!;
|
return body!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SH-278: OR within the Area facet. Selected ids match the company's stable
|
||||||
|
// AreaId; the Unassigned sentinel matches rows without an Area, which includes
|
||||||
|
// unlinked legacy vendors that have no VendorCompany at all. A filter with no
|
||||||
|
// resolvable ids and no Unassigned matches nobody.
|
||||||
|
private static Expression BuildAreaMatchBody(ParameterExpression row, VendorAreaFilter areaFilter)
|
||||||
|
{
|
||||||
|
var areaId = Expression.Property(row, nameof(VendorDirectoryRow.AreaId));
|
||||||
|
Expression? body = null;
|
||||||
|
|
||||||
|
if (areaFilter.AreaIds.Count > 0)
|
||||||
|
{
|
||||||
|
var ids = areaFilter.AreaIds.Distinct().Select(id => (int?)id).ToArray();
|
||||||
|
body = OrElse(body, Expression.Call(
|
||||||
|
typeof(Enumerable),
|
||||||
|
nameof(Enumerable.Contains),
|
||||||
|
new[] { typeof(int?) },
|
||||||
|
Expression.Constant(ids),
|
||||||
|
areaId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (areaFilter.IncludeUnassigned)
|
||||||
|
body = OrElse(body, Expression.Equal(areaId, Expression.Constant(null, typeof(int?))));
|
||||||
|
|
||||||
|
return body ?? Expression.Constant(false);
|
||||||
|
}
|
||||||
|
|
||||||
private static Expression BuildCompanyNameMatchBody(
|
private static Expression BuildCompanyNameMatchBody(
|
||||||
ParameterExpression row,
|
ParameterExpression row,
|
||||||
IReadOnlyCollection<string> selectedCompanies)
|
IReadOnlyCollection<string> selectedCompanies)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
int? accountId,
|
int? accountId,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<DashboardKpiCounts> GetKpiCountsAsync(
|
||||||
|
int? accountId,
|
||||||
|
string? dispatcherId,
|
||||||
|
DateOnly today,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
||||||
int? accountId,
|
int? accountId,
|
||||||
string? dispatcherId,
|
string? dispatcherId,
|
||||||
|
|
@ -32,7 +38,12 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
DateTime? ScheduledDate,
|
DateTime? ScheduledDate,
|
||||||
DateOnly? OriginalDate,
|
DateOnly? OriginalDate,
|
||||||
DateTime? CompletedDate,
|
DateTime? CompletedDate,
|
||||||
string? LocationState = null);
|
string? LocationState = null,
|
||||||
|
int RescheduleCount = 0,
|
||||||
|
int? VendorCompanyId = null,
|
||||||
|
string? VendorCompanyName = null,
|
||||||
|
bool VendorIsActive = false,
|
||||||
|
bool VendorCompanyIsDeleted = false);
|
||||||
|
|
||||||
public sealed record DashboardDispatcherWorkload(
|
public sealed record DashboardDispatcherWorkload(
|
||||||
string DispatcherId,
|
string DispatcherId,
|
||||||
|
|
@ -48,4 +59,9 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
public int NotDispatched { get; set; }
|
public int NotDispatched { get; set; }
|
||||||
public int Completed { get; set; }
|
public int Completed { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record DashboardKpiCounts(
|
||||||
|
int ScheduledTomorrow,
|
||||||
|
int PendingUplifts,
|
||||||
|
int AvetaPending);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,11 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task<bool> ExistsAsync(int id);
|
Task<bool> ExistsAsync(int id);
|
||||||
Task<int> CountAsync();
|
Task<int> CountAsync();
|
||||||
|
|
||||||
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken);
|
/// <summary>
|
||||||
|
/// Sort keys are validated upstream; unknown or null keys fall back to the legacy code order.
|
||||||
|
/// Sort options trail the token so existing call sites stay source-compatible.
|
||||||
|
/// </summary>
|
||||||
|
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken, string? sortBy = null, string? sortDirection = null);
|
||||||
Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken);
|
Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken);
|
||||||
Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken);
|
Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.DataServices.Dto;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
public interface ITeamPermissionOverrideDataService
|
||||||
|
{
|
||||||
|
Task<TeamPermissionUserData?> GetUserAsync(string userId, CancellationToken cancellationToken);
|
||||||
|
Task SetOverrideAsync(
|
||||||
|
string userId,
|
||||||
|
string permissionKey,
|
||||||
|
UserPermissionState state,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,9 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
{
|
{
|
||||||
public interface IVendorCompanyRosterDataService
|
public interface IVendorCompanyRosterDataService
|
||||||
{
|
{
|
||||||
|
// SH-278: true only for an existing, non-archived Area in the catalogue.
|
||||||
|
Task<bool> IsActiveAreaAsync(int areaId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
// Loads the company roster from either a vendor row id and/or a company id,
|
// Loads the company roster from either a vendor row id and/or a company id,
|
||||||
// returning the company plus all non-deleted technician rows.
|
// returning the company plus all non-deleted technician rows.
|
||||||
Task<VendorCompanyRosterReadModel?> GetRosterAsync(
|
Task<VendorCompanyRosterReadModel?> GetRosterAsync(
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
// of technician Vendor.TradeSpecialties free text.
|
// of technician Vendor.TradeSpecialties free text.
|
||||||
Task<IReadOnlyList<Trade>> GetActiveTradesAsync(CancellationToken cancellationToken);
|
Task<IReadOnlyList<Trade>> GetActiveTradesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
// SH-278: active (non-archived) Area catalogue rows for vendor facets and for
|
||||||
|
// resolving areas[n] filter ids. Archived Areas are excluded.
|
||||||
|
Task<IReadOnlyList<Area>> GetActiveAreasAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
// SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies,
|
// SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies,
|
||||||
// with status semantics aligned to BuildDirectoryUnionQuery. Null returns all
|
// with status semantics aligned to BuildDirectoryUnionQuery. Null returns all
|
||||||
// companies; active includes companies with zero non-deleted technicians or at
|
// companies; active includes companies with zero non-deleted technicians or at
|
||||||
|
|
@ -66,6 +70,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
IReadOnlyCollection<string>? trades = null,
|
IReadOnlyCollection<string>? trades = null,
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null,
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
VendorAreaFilter? areaFilter = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
Task<Vendor> AddAsync(Vendor vendor);
|
Task<Vendor> AddAsync(Vendor vendor);
|
||||||
Task UpdateAsync(Vendor vendor);
|
Task UpdateAsync(Vendor vendor);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ namespace SeaHaven.DataServices.Models
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public int? AreaId { get; set; }
|
||||||
|
public string? AreaName { get; set; }
|
||||||
public byte[]? RowVersion { get; set; }
|
public byte[]? RowVersion { get; set; }
|
||||||
public List<VendorRosterTechnicianReadModel> Technicians { get; set; } = new();
|
public List<VendorRosterTechnicianReadModel> Technicians { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
@ -43,6 +45,10 @@ namespace SeaHaven.DataServices.Models
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
// SH-278: applied only when AreaIdProvided; null AreaId means Unassigned.
|
||||||
|
public bool AreaIdProvided { get; set; }
|
||||||
|
public int? AreaId { get; set; }
|
||||||
public string? ActorUserId { get; set; }
|
public string? ActorUserId { get; set; }
|
||||||
public List<RosterTechnicianWriteModel> Technicians { get; set; } = new();
|
public List<RosterTechnicianWriteModel> Technicians { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
@ -81,6 +87,10 @@ namespace SeaHaven.DataServices.Models
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
// SH-278: applied only when AreaIdProvided; null AreaId means Unassigned.
|
||||||
|
public bool AreaIdProvided { get; set; }
|
||||||
|
public int? AreaId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raised by the roster data service when a technician removed from the snapshot
|
// Raised by the roster data service when a technician removed from the snapshot
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,20 @@ namespace SeaHaven.DataServices.Models
|
||||||
public string? TradeSpecialties { get; set; }
|
public string? TradeSpecialties { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public int? AreaId { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public int TotalJobs { get; set; }
|
public int TotalJobs { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SH-278: Area facet filter for the company directory. AreaIds are already
|
||||||
|
// resolved against the active catalogue; IncludeUnassigned selects groups with no
|
||||||
|
// Area (including unlinked legacy vendors). An empty filter matches nobody.
|
||||||
|
public sealed class VendorAreaFilter
|
||||||
|
{
|
||||||
|
public IReadOnlyCollection<int> AreaIds { get; init; } = Array.Empty<int>();
|
||||||
|
public bool IncludeUnassigned { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
// SH-281 company directory: per-row source for the grouped key query. Carries the
|
// SH-281 company directory: per-row source for the grouped key query. Carries the
|
||||||
// company group key parts (CompanyId 0 = unlinked; NameKey groups unlinked legacy
|
// company group key parts (CompanyId 0 = unlinked; NameKey groups unlinked legacy
|
||||||
// vendors by trimmed case-insensitive CompanyName; VendorKey isolates vendors with
|
// vendors by trimmed case-insensitive CompanyName; VendorKey isolates vendors with
|
||||||
|
|
@ -37,6 +47,7 @@ namespace SeaHaven.DataServices.Models
|
||||||
public int CompanyFlag { get; set; }
|
public int CompanyFlag { get; set; }
|
||||||
public int TradeFlag { get; set; }
|
public int TradeFlag { get; set; }
|
||||||
public int LocationFlag { get; set; }
|
public int LocationFlag { get; set; }
|
||||||
|
public int AreaFlag { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// SH-281: one row per company group after SQL grouping — the bounded key page.
|
// SH-281: one row per company group after SQL grouping — the bounded key page.
|
||||||
|
|
@ -51,6 +62,7 @@ namespace SeaHaven.DataServices.Models
|
||||||
public int CompanyMatch { get; set; }
|
public int CompanyMatch { get; set; }
|
||||||
public int TradeMatch { get; set; }
|
public int TradeMatch { get; set; }
|
||||||
public int LocationMatch { get; set; }
|
public int LocationMatch { get; set; }
|
||||||
|
public int AreaMatch { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// SH-281: full status-eligible roster row (phase 2) carrying both the vendor's own
|
// SH-281: full status-eligible roster row (phase 2) carrying both the vendor's own
|
||||||
|
|
@ -69,6 +81,8 @@ namespace SeaHaven.DataServices.Models
|
||||||
public string? CompanyCity { get; set; }
|
public string? CompanyCity { get; set; }
|
||||||
public string? CompanyState { get; set; }
|
public string? CompanyState { get; set; }
|
||||||
public string? CompanyNotes { get; set; }
|
public string? CompanyNotes { get; set; }
|
||||||
|
public int? CompanyAreaId { get; set; }
|
||||||
|
public string? CompanyAreaName { get; set; }
|
||||||
public string? ContactName { get; set; }
|
public string? ContactName { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
|
@ -124,6 +138,8 @@ namespace SeaHaven.DataServices.Models
|
||||||
public string? TradeSpecialties { get; set; }
|
public string? TradeSpecialties { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public int? AreaId { get; set; }
|
||||||
|
public string? AreaName { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public int TotalJobs { get; set; }
|
public int TotalJobs { get; set; }
|
||||||
public IReadOnlyList<VendorCompanyGroupTechnician> Technicians { get; set; } = Array.Empty<VendorCompanyGroupTechnician>();
|
public IReadOnlyList<VendorCompanyGroupTechnician> Technicians { get; set; } = Array.Empty<VendorCompanyGroupTechnician>();
|
||||||
|
|
|
||||||
248
SeaHaven.Services.Tests/TeamPermissionPolicyTests.cs
Normal file
248
SeaHaven.Services.Tests/TeamPermissionPolicyTests.cs
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using FluentAssertions;
|
||||||
|
using SeaHaven.Services.Constants;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Tests;
|
||||||
|
|
||||||
|
public class TeamPermissionPolicyTests
|
||||||
|
{
|
||||||
|
private static readonly string[] DispatcherKeys =
|
||||||
|
{
|
||||||
|
TeamPermissionKeys.CreateVendors,
|
||||||
|
TeamPermissionKeys.EditVendors,
|
||||||
|
TeamPermissionKeys.DeactivateVendors,
|
||||||
|
TeamPermissionKeys.CreateSites,
|
||||||
|
TeamPermissionKeys.EditSites,
|
||||||
|
TeamPermissionKeys.CreateWorkOrders,
|
||||||
|
TeamPermissionKeys.EditOthersWorkOrders,
|
||||||
|
TeamPermissionKeys.CancelWorkOrders,
|
||||||
|
TeamPermissionKeys.RequestUplifts,
|
||||||
|
TeamPermissionKeys.AutoApproveUplifts
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] SchedulerOnlyKeys =
|
||||||
|
{
|
||||||
|
TeamPermissionKeys.DeleteSites,
|
||||||
|
TeamPermissionKeys.CreateServices,
|
||||||
|
TeamPermissionKeys.EditServices,
|
||||||
|
TeamPermissionKeys.CreateCompletionDocTemplates,
|
||||||
|
TeamPermissionKeys.EditCompletionDocTemplates,
|
||||||
|
TeamPermissionKeys.ViewAllDispatchersOnDashboard
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] AdminOnlyKeys =
|
||||||
|
{
|
||||||
|
TeamPermissionKeys.ManageTeamMembers,
|
||||||
|
TeamPermissionKeys.ChangeTeamMemberRole,
|
||||||
|
TeamPermissionKeys.DeactivateServices,
|
||||||
|
TeamPermissionKeys.DeleteCompletionDocTemplates,
|
||||||
|
TeamPermissionKeys.DeleteWorkOrders,
|
||||||
|
TeamPermissionKeys.ReviewUplifts
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, UserPermissionState> Overrides(
|
||||||
|
string key, UserPermissionState state) =>
|
||||||
|
new Dictionary<string, UserPermissionState>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
[key] = state
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registry_Exposes_Exactly_The_22_Prototype_Keys()
|
||||||
|
{
|
||||||
|
TeamPermissionKeys.All.Should().HaveCount(22);
|
||||||
|
TeamPermissionKeys.KnownKeys.Should().HaveCount(22);
|
||||||
|
TeamPermissionKeys.All.Should().OnlyHaveUniqueItems();
|
||||||
|
TeamPermissionKeys.All.Should().OnlyContain(key => !string.IsNullOrWhiteSpace(key));
|
||||||
|
TeamPermissionKeys.All.Should().BeEquivalentTo(new[]
|
||||||
|
{
|
||||||
|
"manageTeamMembers",
|
||||||
|
"changeTeamMemberRole",
|
||||||
|
"createVendors",
|
||||||
|
"editVendors",
|
||||||
|
"deactivateVendors",
|
||||||
|
"createSites",
|
||||||
|
"editSites",
|
||||||
|
"deleteSites",
|
||||||
|
"createServices",
|
||||||
|
"editServices",
|
||||||
|
"deactivateServices",
|
||||||
|
"createCompletionDocTemplates",
|
||||||
|
"editCompletionDocTemplates",
|
||||||
|
"deleteCompletionDocTemplates",
|
||||||
|
"createWorkOrders",
|
||||||
|
"editOthersWorkOrders",
|
||||||
|
"cancelWorkOrders",
|
||||||
|
"deleteWorkOrders",
|
||||||
|
"requestUplifts",
|
||||||
|
"autoApproveUplifts",
|
||||||
|
"reviewUplifts",
|
||||||
|
"viewAllDispatchersOnDashboard"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registry_IsKnown_Is_Case_Insensitive_And_Rejects_Unknown_Keys()
|
||||||
|
{
|
||||||
|
TeamPermissionKeys.IsKnown("CREATEVENDORS").Should().BeTrue();
|
||||||
|
TeamPermissionKeys.IsKnown("autoApproveUplifts").Should().BeTrue();
|
||||||
|
TeamPermissionKeys.IsKnown("nope.unknown").Should().BeFalse();
|
||||||
|
TeamPermissionKeys.IsKnown("").Should().BeFalse();
|
||||||
|
TeamPermissionKeys.IsKnown(null).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispatcher_Gets_Exactly_Prototype_Defaults()
|
||||||
|
{
|
||||||
|
var policy = new TeamPermissionPolicy();
|
||||||
|
|
||||||
|
foreach (var key in TeamPermissionKeys.All)
|
||||||
|
{
|
||||||
|
var expected = DispatcherKeys.Contains(key, StringComparer.Ordinal);
|
||||||
|
policy.IsAllowed("Dispatcher", key).Should().Be(expected,
|
||||||
|
$"Dispatcher default for '{key}' should be {expected}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Scheduler_Gets_Exactly_Prototype_Defaults()
|
||||||
|
{
|
||||||
|
var policy = new TeamPermissionPolicy();
|
||||||
|
|
||||||
|
foreach (var key in TeamPermissionKeys.All)
|
||||||
|
{
|
||||||
|
var expected = SchedulerOnlyKeys.Contains(key, StringComparer.Ordinal)
|
||||||
|
|| DispatcherKeys.Contains(key, StringComparer.Ordinal)
|
||||||
|
&& key is not TeamPermissionKeys.RequestUplifts
|
||||||
|
&& key is not TeamPermissionKeys.AutoApproveUplifts;
|
||||||
|
|
||||||
|
policy.IsAllowed("Scheduler", key).Should().Be(expected,
|
||||||
|
$"Scheduler default for '{key}' should be {expected}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Admin_Has_Effective_Access_To_Every_Key()
|
||||||
|
{
|
||||||
|
var policy = new TeamPermissionPolicy();
|
||||||
|
|
||||||
|
foreach (var key in TeamPermissionKeys.All)
|
||||||
|
policy.IsAllowed("Admin", key).Should().BeTrue($"Admin should hold '{key}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Role_Recognition_Is_Case_Insensitive()
|
||||||
|
{
|
||||||
|
var policy = new TeamPermissionPolicy();
|
||||||
|
|
||||||
|
policy.IsAllowed("dIsPaTcHeR", TeamPermissionKeys.CreateVendors).Should().BeTrue();
|
||||||
|
policy.IsAllowed("SCHEDULER", TeamPermissionKeys.DeleteSites).Should().BeTrue();
|
||||||
|
policy.IsAllowed("admin", TeamPermissionKeys.ReviewUplifts).Should().BeTrue();
|
||||||
|
|
||||||
|
policy.IsAllowed("dIsPaTcHeR", TeamPermissionKeys.DeleteSites).Should().BeFalse();
|
||||||
|
policy.IsAllowed("SCHEDULER", TeamPermissionKeys.AutoApproveUplifts).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Manager")]
|
||||||
|
[InlineData("OfficeAdmin")]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(null)]
|
||||||
|
public void Unknown_And_Legacy_Roles_Receive_No_Permissions(string? role)
|
||||||
|
{
|
||||||
|
var policy = new TeamPermissionPolicy();
|
||||||
|
|
||||||
|
foreach (var key in TeamPermissionKeys.All)
|
||||||
|
policy.IsAllowed(role, key).Should().BeFalse(
|
||||||
|
$"unknown/legacy role '{role}' must not hold '{key}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_Role_Gains_Nothing_Even_With_Allow_Overrides()
|
||||||
|
{
|
||||||
|
var policy = new TeamPermissionPolicy();
|
||||||
|
|
||||||
|
foreach (var key in TeamPermissionKeys.All)
|
||||||
|
policy.IsAllowed("Manager", key, Overrides(key, UserPermissionState.Allow))
|
||||||
|
.Should().BeFalse($"an unknown role must not be elevated via '{key}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Allow_Override_Grants_Key_Outside_Role_Default()
|
||||||
|
{
|
||||||
|
var policy = new TeamPermissionPolicy();
|
||||||
|
|
||||||
|
policy.IsAllowed("Scheduler", TeamPermissionKeys.RequestUplifts)
|
||||||
|
.Should().BeFalse();
|
||||||
|
policy.IsAllowed(
|
||||||
|
"Scheduler",
|
||||||
|
TeamPermissionKeys.RequestUplifts,
|
||||||
|
Overrides(TeamPermissionKeys.RequestUplifts, UserPermissionState.Allow))
|
||||||
|
.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Deny_Override_Removes_Key_Inside_Role_Default()
|
||||||
|
{
|
||||||
|
var policy = new TeamPermissionPolicy();
|
||||||
|
|
||||||
|
policy.IsAllowed("Dispatcher", TeamPermissionKeys.CreateVendors)
|
||||||
|
.Should().BeTrue();
|
||||||
|
policy.IsAllowed(
|
||||||
|
"Dispatcher",
|
||||||
|
TeamPermissionKeys.CreateVendors,
|
||||||
|
Overrides(TeamPermissionKeys.CreateVendors, UserPermissionState.Deny))
|
||||||
|
.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unset_Override_And_Missing_Row_Fall_Back_To_Role_Default()
|
||||||
|
{
|
||||||
|
var policy = new TeamPermissionPolicy();
|
||||||
|
|
||||||
|
policy.IsAllowed(
|
||||||
|
"Dispatcher",
|
||||||
|
TeamPermissionKeys.CreateWorkOrders,
|
||||||
|
Overrides(TeamPermissionKeys.CreateWorkOrders, UserPermissionState.Unset))
|
||||||
|
.Should().BeTrue();
|
||||||
|
|
||||||
|
policy.IsAllowed(
|
||||||
|
"Scheduler",
|
||||||
|
TeamPermissionKeys.RequestUplifts,
|
||||||
|
Overrides(TeamPermissionKeys.RequestUplifts, UserPermissionState.Unset))
|
||||||
|
.Should().BeFalse();
|
||||||
|
|
||||||
|
policy.IsAllowed(
|
||||||
|
"Scheduler",
|
||||||
|
TeamPermissionKeys.RequestUplifts,
|
||||||
|
new Dictionary<string, UserPermissionState>())
|
||||||
|
.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Admin_Is_Immune_To_Overrides()
|
||||||
|
{
|
||||||
|
var policy = new TeamPermissionPolicy();
|
||||||
|
|
||||||
|
foreach (var key in TeamPermissionKeys.All)
|
||||||
|
{
|
||||||
|
policy.IsAllowed("Admin", key, Overrides(key, UserPermissionState.Deny))
|
||||||
|
.Should().BeTrue($"Admin must keep '{key}' despite a Deny override");
|
||||||
|
policy.IsAllowed("ADMIN", key, Overrides(key, UserPermissionState.Unset))
|
||||||
|
.Should().BeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_Permission_Key_Is_Denied_For_Every_Role()
|
||||||
|
{
|
||||||
|
var policy = new TeamPermissionPolicy();
|
||||||
|
|
||||||
|
policy.IsAllowed("Admin", "nope.unknown").Should().BeFalse();
|
||||||
|
policy.IsAllowed("Dispatcher", "nope.unknown").Should().BeFalse();
|
||||||
|
policy.IsAllowed("Admin", "nope.unknown", Overrides("nope.unknown", UserPermissionState.Allow))
|
||||||
|
.Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
130
SeaHaven.Services.Tests/TeamPermissionServiceTests.cs
Normal file
130
SeaHaven.Services.Tests/TeamPermissionServiceTests.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using FluentAssertions;
|
||||||
|
using SeaHaven.DataServices.Dto;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.Constants;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Tests;
|
||||||
|
|
||||||
|
public sealed class TeamPermissionServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetProfile_ReturnsEveryKeyAndRoleDefaults()
|
||||||
|
{
|
||||||
|
var data = new FakeDataService("u1", "Dispatcher");
|
||||||
|
var result = await Service(data).GetProfileAsync("u1", Admin(), CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsSuccess.Should().BeTrue();
|
||||||
|
result.Value!.Permissions.Should().HaveCount(22);
|
||||||
|
result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.CreateSites).IsGranted.Should().BeTrue();
|
||||||
|
result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).IsGranted.Should().BeFalse();
|
||||||
|
result.Value.Permissions.Should().OnlyContain(p => p.OverrideState == UserPermissionState.Unset);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetOverride_ChangesOnlyOnePermissionAndPersistsCanonicalKey()
|
||||||
|
{
|
||||||
|
var data = new FakeDataService("u1", "Dispatcher");
|
||||||
|
var result = await Service(data).SetOverrideAsync(
|
||||||
|
"u1", "DELETEsites", UserPermissionState.Allow, Admin(), CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsSuccess.Should().BeTrue();
|
||||||
|
result.Value!.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).IsGranted.Should().BeTrue();
|
||||||
|
result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).OverrideState.Should().Be(UserPermissionState.Allow);
|
||||||
|
result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.CreateSites).IsGranted.Should().BeTrue();
|
||||||
|
data.LastSet.Should().Be(("u1", TeamPermissionKeys.DeleteSites, UserPermissionState.Allow));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetOverride_RejectsUnknownKeyBeforeDataAccess()
|
||||||
|
{
|
||||||
|
var data = new FakeDataService("u1", "Dispatcher");
|
||||||
|
var result = await Service(data).SetOverrideAsync(
|
||||||
|
"u1", "not-a-permission", UserPermissionState.Allow, Admin(), CancellationToken.None);
|
||||||
|
|
||||||
|
result.Status.Should().Be(TeamPermissionResultStatus.InvalidPermissionKey);
|
||||||
|
data.GetCalls.Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NonAdminCannotReadOrWritePermissions()
|
||||||
|
{
|
||||||
|
var data = new FakeDataService("u1", "Dispatcher");
|
||||||
|
var service = Service(data);
|
||||||
|
|
||||||
|
var read = await service.GetProfileAsync("u1", User("Dispatcher"), CancellationToken.None);
|
||||||
|
var write = await service.SetOverrideAsync("u1", TeamPermissionKeys.CreateSites, UserPermissionState.Deny, User("Dispatcher"), CancellationToken.None);
|
||||||
|
|
||||||
|
read.Status.Should().Be(TeamPermissionResultStatus.Forbidden);
|
||||||
|
write.Status.Should().Be(TeamPermissionResultStatus.Forbidden);
|
||||||
|
data.GetCalls.Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UnknownRoleReceivesNoGrantEvenWhenAnOverrideIsSet()
|
||||||
|
{
|
||||||
|
var data = new FakeDataService("u1", "Legacy");
|
||||||
|
var result = await Service(data).SetOverrideAsync(
|
||||||
|
"u1", TeamPermissionKeys.CreateSites, UserPermissionState.Allow, Admin(), CancellationToken.None);
|
||||||
|
|
||||||
|
result.Value!.Permissions.Should().OnlyContain(p => !p.IsGranted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MissingUserReturnsNotFound()
|
||||||
|
{
|
||||||
|
var data = new FakeDataService(null, null);
|
||||||
|
var result = await Service(data).GetProfileAsync("missing", Admin(), CancellationToken.None);
|
||||||
|
|
||||||
|
result.Status.Should().Be(TeamPermissionResultStatus.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeamPermissionService Service(FakeDataService data) =>
|
||||||
|
new(data, new TeamPermissionPolicy());
|
||||||
|
|
||||||
|
private static ClaimsPrincipal Admin() => User("Admin");
|
||||||
|
|
||||||
|
private static ClaimsPrincipal User(string role) =>
|
||||||
|
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, role) }, "test"));
|
||||||
|
|
||||||
|
private sealed class FakeDataService : ITeamPermissionOverrideDataService
|
||||||
|
{
|
||||||
|
private readonly string? _userId;
|
||||||
|
private readonly string? _roleName;
|
||||||
|
private readonly Dictionary<string, UserPermissionState> _overrides = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public FakeDataService(string? userId, string? roleName)
|
||||||
|
{
|
||||||
|
_userId = userId;
|
||||||
|
_roleName = roleName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetCalls { get; private set; }
|
||||||
|
public (string UserId, string PermissionKey, UserPermissionState State)? LastSet { get; private set; }
|
||||||
|
|
||||||
|
public Task<TeamPermissionUserData?> GetUserAsync(string userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
GetCalls++;
|
||||||
|
return Task.FromResult<TeamPermissionUserData?>(_userId != userId
|
||||||
|
? null
|
||||||
|
: new TeamPermissionUserData
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
RoleName = _roleName,
|
||||||
|
Overrides = new Dictionary<string, UserPermissionState>(_overrides, StringComparer.OrdinalIgnoreCase)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SetOverrideAsync(string userId, string permissionKey, UserPermissionState state, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_overrides[permissionKey] = state;
|
||||||
|
LastSet = (userId, permissionKey, state);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
SeaHaven.Services/Constants/TeamPermissionKeys.cs
Normal file
67
SeaHaven.Services/Constants/TeamPermissionKeys.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
namespace SeaHaven.Services.Constants
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical, immutable registry of team-members permission keys.
|
||||||
|
/// Exactly the 22 approved prototype keys; stored overrides must reference
|
||||||
|
/// these keys. Keys are stable identifiers and must never be renamed.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamPermissionKeys
|
||||||
|
{
|
||||||
|
public const string ManageTeamMembers = "manageTeamMembers";
|
||||||
|
public const string ChangeTeamMemberRole = "changeTeamMemberRole";
|
||||||
|
public const string CreateVendors = "createVendors";
|
||||||
|
public const string EditVendors = "editVendors";
|
||||||
|
public const string DeactivateVendors = "deactivateVendors";
|
||||||
|
public const string CreateSites = "createSites";
|
||||||
|
public const string EditSites = "editSites";
|
||||||
|
public const string DeleteSites = "deleteSites";
|
||||||
|
public const string CreateServices = "createServices";
|
||||||
|
public const string EditServices = "editServices";
|
||||||
|
public const string DeactivateServices = "deactivateServices";
|
||||||
|
public const string CreateCompletionDocTemplates = "createCompletionDocTemplates";
|
||||||
|
public const string EditCompletionDocTemplates = "editCompletionDocTemplates";
|
||||||
|
public const string DeleteCompletionDocTemplates = "deleteCompletionDocTemplates";
|
||||||
|
public const string CreateWorkOrders = "createWorkOrders";
|
||||||
|
public const string EditOthersWorkOrders = "editOthersWorkOrders";
|
||||||
|
public const string CancelWorkOrders = "cancelWorkOrders";
|
||||||
|
public const string DeleteWorkOrders = "deleteWorkOrders";
|
||||||
|
public const string RequestUplifts = "requestUplifts";
|
||||||
|
public const string AutoApproveUplifts = "autoApproveUplifts";
|
||||||
|
public const string ReviewUplifts = "reviewUplifts";
|
||||||
|
public const string ViewAllDispatchersOnDashboard = "viewAllDispatchersOnDashboard";
|
||||||
|
|
||||||
|
private static readonly IReadOnlyList<string> AllKeys = Array.AsReadOnly(new[]
|
||||||
|
{
|
||||||
|
ManageTeamMembers,
|
||||||
|
ChangeTeamMemberRole,
|
||||||
|
CreateVendors,
|
||||||
|
EditVendors,
|
||||||
|
DeactivateVendors,
|
||||||
|
CreateSites,
|
||||||
|
EditSites,
|
||||||
|
DeleteSites,
|
||||||
|
CreateServices,
|
||||||
|
EditServices,
|
||||||
|
DeactivateServices,
|
||||||
|
CreateCompletionDocTemplates,
|
||||||
|
EditCompletionDocTemplates,
|
||||||
|
DeleteCompletionDocTemplates,
|
||||||
|
CreateWorkOrders,
|
||||||
|
EditOthersWorkOrders,
|
||||||
|
CancelWorkOrders,
|
||||||
|
DeleteWorkOrders,
|
||||||
|
RequestUplifts,
|
||||||
|
AutoApproveUplifts,
|
||||||
|
ReviewUplifts,
|
||||||
|
ViewAllDispatchersOnDashboard
|
||||||
|
});
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> All => AllKeys;
|
||||||
|
|
||||||
|
public static IReadOnlySet<string> KnownKeys { get; } =
|
||||||
|
new HashSet<string>(AllKeys, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public static bool IsKnown(string? permissionKey) =>
|
||||||
|
!string.IsNullOrWhiteSpace(permissionKey) && KnownKeys.Contains(permissionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,36 @@
|
||||||
namespace SeaHaven.Services.DTOs
|
namespace SeaHaven.Services.DTOs
|
||||||
{
|
{
|
||||||
public sealed class DashboardStatsQueryDTO
|
public class DashboardStatsQueryDTO
|
||||||
{
|
{
|
||||||
public DateOnly? DateFrom { get; set; }
|
public DateOnly? DateFrom { get; set; }
|
||||||
public DateOnly? DateTo { get; set; }
|
public DateOnly? DateTo { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO
|
||||||
|
{
|
||||||
|
public string? Range { get; set; }
|
||||||
|
public int? Year { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardTrendResponseDTO
|
||||||
|
{
|
||||||
|
public string Granularity { get; init; } = "day";
|
||||||
|
public DateOnly Today { get; init; }
|
||||||
|
public IReadOnlyList<DashboardTrendBucketDTO> Buckets { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardTrendBucketDTO
|
||||||
|
{
|
||||||
|
public DateOnly Date { get; init; }
|
||||||
|
public string Label { get; init; } = string.Empty;
|
||||||
|
public int Total { get; init; }
|
||||||
|
public int Open { get; init; }
|
||||||
|
public int Completed { get; init; }
|
||||||
|
public int Canceled { get; init; }
|
||||||
|
public int Overdue { get; init; }
|
||||||
|
public bool IsCurrent { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class DashboardWorkloadResponseDTO
|
public sealed class DashboardWorkloadResponseDTO
|
||||||
{
|
{
|
||||||
public IReadOnlyList<DashboardWorkloadRowDTO> Items { get; init; } = [];
|
public IReadOnlyList<DashboardWorkloadRowDTO> Items { get; init; } = [];
|
||||||
|
|
@ -51,12 +76,33 @@ namespace SeaHaven.Services.DTOs
|
||||||
public int WorkOrderCount { get; init; }
|
public int WorkOrderCount { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardVendorInsightsResponseDTO
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DashboardVendorInsightsRowDTO> Items { get; init; } = [];
|
||||||
|
public int Page { get; init; }
|
||||||
|
public int PageSize { get; init; }
|
||||||
|
public int TotalVendors { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardVendorInsightsRowDTO
|
||||||
|
{
|
||||||
|
public int VendorCompanyId { get; init; }
|
||||||
|
public string VendorCompanyName { get; init; } = string.Empty;
|
||||||
|
public decimal CompletionRate { get; init; }
|
||||||
|
public decimal RescheduleRate { get; init; }
|
||||||
|
public decimal? AverageResolutionDays { get; init; }
|
||||||
|
public int TotalJobs { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public class DashboardStatsDTO
|
public class DashboardStatsDTO
|
||||||
{
|
{
|
||||||
public int Total { get; set; }
|
public int Total { get; set; }
|
||||||
public int Open { get; set; }
|
public int Open { get; set; }
|
||||||
public int NotDispatched { get; set; }
|
public int NotDispatched { get; set; }
|
||||||
public int Completed { get; set; }
|
public int Completed { get; set; }
|
||||||
|
public int ScheduledTomorrow { get; set; }
|
||||||
|
public int PendingUplifts { get; set; }
|
||||||
|
public int AvetaPending { get; set; }
|
||||||
public DashboardBreakdownDTO Breakdown { get; set; } = new();
|
public DashboardBreakdownDTO Breakdown { get; set; } = new();
|
||||||
public int DueCount { get; set; }
|
public int DueCount { get; set; }
|
||||||
public int CompletedDueCount { get; set; }
|
public int CompletedDueCount { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
public int? AccountId { get; set; }
|
public int? AccountId { get; set; }
|
||||||
|
public string? AccountName { get; set; }
|
||||||
public DateTime? CreatedDate { get; set; }
|
public DateTime? CreatedDate { get; set; }
|
||||||
public string? CreatedBy { get; set; }
|
public string? CreatedBy { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
49
SeaHaven.Services/DTOs/TeamPermissionDTOs.cs
Normal file
49
SeaHaven.Services/DTOs/TeamPermissionDTOs.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
public sealed class TeamPermissionProfileDTO
|
||||||
|
{
|
||||||
|
public required string UserId { get; init; }
|
||||||
|
public string? RoleName { get; init; }
|
||||||
|
public required IReadOnlyList<TeamPermissionValueDTO> Permissions { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TeamPermissionValueDTO
|
||||||
|
{
|
||||||
|
public required string PermissionKey { get; init; }
|
||||||
|
public UserPermissionState OverrideState { get; init; }
|
||||||
|
public bool IsGranted { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SetTeamPermissionOverrideDTO
|
||||||
|
{
|
||||||
|
public UserPermissionState State { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TeamPermissionResultStatus
|
||||||
|
{
|
||||||
|
Success,
|
||||||
|
Forbidden,
|
||||||
|
NotFound,
|
||||||
|
InvalidPermissionKey
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TeamPermissionResult<T>
|
||||||
|
{
|
||||||
|
private TeamPermissionResult(TeamPermissionResultStatus status, T? value)
|
||||||
|
{
|
||||||
|
Status = status;
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TeamPermissionResultStatus Status { get; }
|
||||||
|
public T? Value { get; }
|
||||||
|
public bool IsSuccess => Status == TeamPermissionResultStatus.Success;
|
||||||
|
|
||||||
|
public static TeamPermissionResult<T> Success(T value) =>
|
||||||
|
new(TeamPermissionResultStatus.Success, value);
|
||||||
|
|
||||||
|
public static TeamPermissionResult<T> Failure(TeamPermissionResultStatus status) =>
|
||||||
|
new(status, default);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace SeaHaven.Services.DTOs
|
namespace SeaHaven.Services.DTOs
|
||||||
{
|
{
|
||||||
public class VendorRosterDTO
|
public class VendorRosterDTO
|
||||||
|
|
@ -12,6 +14,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public int? AreaId { get; set; }
|
||||||
|
public string? AreaName { get; set; }
|
||||||
public string? RowVersion { get; set; }
|
public string? RowVersion { get; set; }
|
||||||
public List<VendorRosterTechnicianDTO> Technicians { get; set; } = new();
|
public List<VendorRosterTechnicianDTO> Technicians { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +54,10 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
// SH-278: optional Area id; null leaves the new company Unassigned. Assigning
|
||||||
|
// an Area requires the Admin role.
|
||||||
|
public int? AreaId { get; set; }
|
||||||
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
|
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,6 +73,22 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
// SH-278: an omitted areaId leaves the stored Area unchanged, so clients that
|
||||||
|
// predate Area never clear it. An explicit null clears it to Unassigned.
|
||||||
|
private int? _areaId;
|
||||||
|
public int? AreaId
|
||||||
|
{
|
||||||
|
get => _areaId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_areaId = value;
|
||||||
|
AreaIdProvided = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public bool AreaIdProvided { get; private set; }
|
||||||
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
|
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,5 +113,30 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
// SH-278: same presence semantics as ReconcileVendorRosterDTO.AreaId.
|
||||||
|
private int? _areaId;
|
||||||
|
public int? AreaId
|
||||||
|
{
|
||||||
|
get => _areaId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_areaId = value;
|
||||||
|
AreaIdProvided = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public bool AreaIdProvided { get; private set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// SH-278: raised when a caller without the Admin role tries to change a vendor
|
||||||
|
// company's Area. Mapped to a stable 403 by the API.
|
||||||
|
public sealed class VendorAreaAssignmentForbiddenException : Exception
|
||||||
|
{
|
||||||
|
public VendorAreaAssignmentForbiddenException()
|
||||||
|
: base("Only administrators can assign a vendor company Area.")
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? TradeSpecialties { get; set; }
|
public string? TradeSpecialties { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public int? AreaId { get; set; }
|
||||||
|
public string? AreaName { get; set; }
|
||||||
public int TotalJobs { get; set; }
|
public int TotalJobs { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public List<VendorDirectoryTechnicianDTO> Technicians { get; set; } = new();
|
public List<VendorDirectoryTechnicianDTO> Technicians { get; set; } = new();
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,21 @@ namespace SeaHaven.Services.DTOs
|
||||||
{
|
{
|
||||||
public IEnumerable<VendorFacetCompanyDTO> Companies { get; set; } = Enumerable.Empty<VendorFacetCompanyDTO>();
|
public IEnumerable<VendorFacetCompanyDTO> Companies { get; set; } = Enumerable.Empty<VendorFacetCompanyDTO>();
|
||||||
public IEnumerable<string> Trades { get; set; } = Enumerable.Empty<string>();
|
public IEnumerable<string> Trades { get; set; } = Enumerable.Empty<string>();
|
||||||
|
public IEnumerable<VendorFacetAreaDTO> Areas { get; set; } = Enumerable.Empty<VendorFacetAreaDTO>();
|
||||||
public IEnumerable<VendorFacetLocationDTO> Locations { get; set; } = Enumerable.Empty<VendorFacetLocationDTO>();
|
public IEnumerable<VendorFacetLocationDTO> Locations { get; set; } = Enumerable.Empty<VendorFacetLocationDTO>();
|
||||||
public IEnumerable<VendorJobBucketDTO> JobBuckets { get; set; } = Enumerable.Empty<VendorJobBucketDTO>();
|
public IEnumerable<VendorJobBucketDTO> JobBuckets { get; set; } = Enumerable.Empty<VendorJobBucketDTO>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SH-278: one active Area option. Id is the stable areas[n] filter value.
|
||||||
|
public class VendorFacetAreaDTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SH-278: wire values accepted by the vendor directory areas[n] filter besides ids.
|
||||||
|
public static class VendorAreaFilterValues
|
||||||
|
{
|
||||||
|
public const string Unassigned = "__unassigned__";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
using System.Globalization;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Exceptions;
|
using SeaHaven.Services.Exceptions;
|
||||||
|
|
@ -33,6 +35,11 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||||
|
|
||||||
var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken);
|
var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken);
|
||||||
|
var kpis = await _dataService.GetKpiCountsAsync(
|
||||||
|
accountId,
|
||||||
|
dispatcherId,
|
||||||
|
DashboardBusinessTime.Today(),
|
||||||
|
cancellationToken);
|
||||||
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
|
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
|
||||||
accountId,
|
accountId,
|
||||||
dispatcherId,
|
dispatcherId,
|
||||||
|
|
@ -49,6 +56,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
Open = useMetrics ? metrics.Open : counts.Open,
|
Open = useMetrics ? metrics.Open : counts.Open,
|
||||||
NotDispatched = useMetrics ? metrics.NotDispatched : counts.NotDispatched,
|
NotDispatched = useMetrics ? metrics.NotDispatched : counts.NotDispatched,
|
||||||
Completed = useMetrics ? metrics.Completed : counts.Completed,
|
Completed = useMetrics ? metrics.Completed : counts.Completed,
|
||||||
|
ScheduledTomorrow = kpis.ScheduledTomorrow,
|
||||||
|
PendingUplifts = kpis.PendingUplifts,
|
||||||
|
AvetaPending = kpis.AvetaPending,
|
||||||
Breakdown = metrics.Breakdown,
|
Breakdown = metrics.Breakdown,
|
||||||
DueCount = metrics.DueCount,
|
DueCount = metrics.DueCount,
|
||||||
CompletedDueCount = metrics.CompletedDueCount,
|
CompletedDueCount = metrics.CompletedDueCount,
|
||||||
|
|
@ -179,5 +189,220 @@ namespace SeaHaven.Services.Implementation
|
||||||
.ToList()
|
.ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<DashboardTrendResponseDTO> GetTrendAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
DashboardTrendQueryDTO query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||||
|
var dispatcherId = user.IsInRole("Dispatcher")
|
||||||
|
? user.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
|
: null;
|
||||||
|
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
|
||||||
|
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||||
|
|
||||||
|
var today = DashboardBusinessTime.Today();
|
||||||
|
var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today);
|
||||||
|
var firstBucketStart = BucketStart(windowFrom, granularity);
|
||||||
|
var lastBucketStart = BucketStart(windowTo, granularity);
|
||||||
|
var lastBucketEnd = BucketEnd(lastBucketStart, granularity);
|
||||||
|
|
||||||
|
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
|
||||||
|
accountId,
|
||||||
|
dispatcherId,
|
||||||
|
firstBucketStart.AddDays(-1),
|
||||||
|
lastBucketEnd.AddDays(1),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var buckets = BuildBucketStarts(firstBucketStart, windowTo, granularity)
|
||||||
|
.Select(start => new TrendBucket(start))
|
||||||
|
.ToList();
|
||||||
|
var bucketStarts = buckets.Select(bucket => bucket.Start).ToList();
|
||||||
|
|
||||||
|
foreach (var workOrder in workOrders)
|
||||||
|
{
|
||||||
|
if (workOrder.ScheduledDate is not DateTime scheduled)
|
||||||
|
continue;
|
||||||
|
var businessDate = DateOnly.FromDateTime(scheduled.Date);
|
||||||
|
if (businessDate < firstBucketStart || businessDate > lastBucketEnd)
|
||||||
|
continue;
|
||||||
|
var bucket = buckets[LocateBucket(bucketStarts, businessDate)];
|
||||||
|
var status = workOrder.LifecycleStatus
|
||||||
|
?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus);
|
||||||
|
bucket.Total++;
|
||||||
|
if (status == LifecycleStatus.Completed)
|
||||||
|
bucket.Completed++;
|
||||||
|
else if (status == LifecycleStatus.Canceled)
|
||||||
|
bucket.Canceled++;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bucket.Open++;
|
||||||
|
if (businessDate < today)
|
||||||
|
bucket.Overdue++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DateOnly? currentStart = today < firstBucketStart || today > lastBucketEnd
|
||||||
|
? null
|
||||||
|
: buckets[LocateBucket(bucketStarts, today)].Start;
|
||||||
|
|
||||||
|
return new DashboardTrendResponseDTO
|
||||||
|
{
|
||||||
|
Granularity = granularity,
|
||||||
|
Today = today,
|
||||||
|
Buckets = buckets.Select(bucket => new DashboardTrendBucketDTO
|
||||||
|
{
|
||||||
|
Date = bucket.Start,
|
||||||
|
Label = bucket.Start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||||
|
Total = bucket.Total,
|
||||||
|
Open = bucket.Open,
|
||||||
|
Completed = bucket.Completed,
|
||||||
|
Canceled = bucket.Canceled,
|
||||||
|
Overdue = bucket.Overdue,
|
||||||
|
IsCurrent = bucket.Start == currentStart
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (DateOnly From, DateOnly To, string Granularity) ResolveTrendWindow(
|
||||||
|
DashboardTrendQueryDTO query,
|
||||||
|
DateOnly today)
|
||||||
|
{
|
||||||
|
if (query.Year is int year)
|
||||||
|
{
|
||||||
|
if (year < DateOnly.MinValue.Year || year > DateOnly.MaxValue.Year)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(query));
|
||||||
|
return (new DateOnly(year, 1, 1), new DateOnly(year, 12, 31), "month");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(query.Range, ThreeMonthRange, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var weeklyTo = query.DateTo ?? today;
|
||||||
|
var weeklyFrom = query.DateFrom ?? weeklyTo.AddMonths(-3).AddDays(1);
|
||||||
|
if (weeklyFrom > weeklyTo)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(query));
|
||||||
|
return (weeklyFrom, weeklyTo, "week");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.DateFrom is DateOnly dailyFrom && query.DateTo is DateOnly dailyTo)
|
||||||
|
{
|
||||||
|
if (dailyFrom > dailyTo)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(query));
|
||||||
|
return (dailyFrom, dailyTo, "day");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.DateFrom.HasValue != query.DateTo.HasValue)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(query));
|
||||||
|
|
||||||
|
return (today, today, "day");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<DateOnly> BuildBucketStarts(DateOnly from, DateOnly to, string granularity)
|
||||||
|
{
|
||||||
|
var starts = new List<DateOnly>();
|
||||||
|
var current = BucketStart(from, granularity);
|
||||||
|
while (current <= to)
|
||||||
|
{
|
||||||
|
starts.Add(current);
|
||||||
|
current = granularity switch
|
||||||
|
{
|
||||||
|
"week" => current.AddDays(7),
|
||||||
|
"month" => current.AddMonths(1),
|
||||||
|
_ => current.AddDays(1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return starts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly BucketStart(DateOnly date, string granularity) => granularity switch
|
||||||
|
{
|
||||||
|
"week" => date.AddDays(-(((int)date.DayOfWeek + 6) % 7)),
|
||||||
|
"month" => new DateOnly(date.Year, date.Month, 1),
|
||||||
|
_ => date
|
||||||
|
};
|
||||||
|
|
||||||
|
private static DateOnly BucketEnd(DateOnly start, string granularity) => granularity switch
|
||||||
|
{
|
||||||
|
"week" => start.AddDays(6),
|
||||||
|
"month" => start.AddMonths(1).AddDays(-1),
|
||||||
|
_ => start
|
||||||
|
};
|
||||||
|
|
||||||
|
private static int LocateBucket(List<DateOnly> starts, DateOnly date)
|
||||||
|
{
|
||||||
|
var low = 0;
|
||||||
|
var high = starts.Count - 1;
|
||||||
|
while (low < high)
|
||||||
|
{
|
||||||
|
var middle = (low + high + 1) / 2;
|
||||||
|
if (starts[middle] <= date)
|
||||||
|
low = middle;
|
||||||
|
else
|
||||||
|
high = middle - 1;
|
||||||
|
}
|
||||||
|
return low;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string ThreeMonthRange = "3m";
|
||||||
|
|
||||||
|
private sealed class TrendBucket(DateOnly start)
|
||||||
|
{
|
||||||
|
public DateOnly Start { get; } = start;
|
||||||
|
public int Total { get; set; }
|
||||||
|
public int Open { get; set; }
|
||||||
|
public int Completed { get; set; }
|
||||||
|
public int Canceled { get; set; }
|
||||||
|
public int Overdue { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DashboardVendorInsightsResponseDTO> GetVendorInsightsAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||||
|
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
|
||||||
|
accountId, null, null, null, cancellationToken);
|
||||||
|
var activeVendorOrders = workOrders
|
||||||
|
.Where(workOrder => workOrder.VendorCompanyId.HasValue
|
||||||
|
&& workOrder.VendorIsActive
|
||||||
|
&& !workOrder.VendorCompanyIsDeleted)
|
||||||
|
.ToList();
|
||||||
|
var today = DashboardBusinessTime.Today();
|
||||||
|
var rows = activeVendorOrders
|
||||||
|
.GroupBy(workOrder => new
|
||||||
|
{
|
||||||
|
Id = workOrder.VendorCompanyId!.Value,
|
||||||
|
Name = workOrder.VendorCompanyName ?? string.Empty
|
||||||
|
})
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var orders = group.ToList();
|
||||||
|
var metrics = DashboardMetrics.Calculate(orders, today);
|
||||||
|
var rescheduled = orders.Count(order => order.RescheduleCount >= 1);
|
||||||
|
return new DashboardVendorInsightsRowDTO
|
||||||
|
{
|
||||||
|
VendorCompanyId = group.Key.Id,
|
||||||
|
VendorCompanyName = group.Key.Name,
|
||||||
|
CompletionRate = metrics.CompletionRate,
|
||||||
|
RescheduleRate = Math.Round((decimal)rescheduled / orders.Count * 100, 2),
|
||||||
|
AverageResolutionDays = metrics.AverageResolutionDays,
|
||||||
|
TotalJobs = orders.Count
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.OrderByDescending(row => row.CompletionRate)
|
||||||
|
.ThenBy(row => row.VendorCompanyName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(row => row.VendorCompanyId)
|
||||||
|
.ToList();
|
||||||
|
const int pageSize = 10;
|
||||||
|
|
||||||
|
return new DashboardVendorInsightsResponseDTO
|
||||||
|
{
|
||||||
|
Items = rows.Take(pageSize).ToList(),
|
||||||
|
Page = 1,
|
||||||
|
PageSize = pageSize,
|
||||||
|
TotalVendors = rows.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,11 +166,15 @@ namespace SeaHaven.Services.Implementation
|
||||||
int pageSize,
|
int pageSize,
|
||||||
string? search,
|
string? search,
|
||||||
string? states = null,
|
string? states = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default,
|
||||||
|
string? sortBy = null,
|
||||||
|
string? sortDirection = null)
|
||||||
{
|
{
|
||||||
var stateFilter = NormalizeStateFilter(states);
|
var stateFilter = NormalizeStateFilter(states);
|
||||||
var storageStateFilter = stateFilter == null ? null : UsStateCodes.ExpandStorageValues(stateFilter);
|
var storageStateFilter = stateFilter == null ? null : UsStateCodes.ExpandStorageValues(stateFilter);
|
||||||
var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken);
|
var sort = NormalizeSortColumn(sortBy);
|
||||||
|
var direction = NormalizeSortDirection(sortDirection);
|
||||||
|
var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken, sort, direction);
|
||||||
|
|
||||||
// SH-138: one bounded contact read for the whole page, never per row.
|
// SH-138: one bounded contact read for the whole page, never per row.
|
||||||
var contactsByLocation = await GetSiteContactsByLocationIdsAsync(items, cancellationToken);
|
var contactsByLocation = await GetSiteContactsByLocationIdsAsync(items, cancellationToken);
|
||||||
|
|
@ -184,6 +188,42 @@ namespace SeaHaven.Services.Implementation
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static readonly IReadOnlyCollection<string> SortableColumns = new[] { "code", "client", "address", "city", "state", "poc" };
|
||||||
|
|
||||||
|
internal static string? NormalizeSortColumn(string? sortBy)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sortBy))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var normalized = sortBy.Trim().ToLowerInvariant();
|
||||||
|
if (!SortableColumns.Contains(normalized))
|
||||||
|
{
|
||||||
|
throw new ValidationException(new[]
|
||||||
|
{
|
||||||
|
new ValidationFailure("sortBy", $"sortBy must be one of: {string.Join(", ", SortableColumns)}.")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string NormalizeSortDirection(string? sortDirection)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sortDirection))
|
||||||
|
return "asc";
|
||||||
|
|
||||||
|
var normalized = sortDirection.Trim().ToLowerInvariant();
|
||||||
|
if (normalized != "asc" && normalized != "desc")
|
||||||
|
{
|
||||||
|
throw new ValidationException(new[]
|
||||||
|
{
|
||||||
|
new ValidationFailure("sortDirection", "sortDirection must be asc or desc.")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
internal static IReadOnlyCollection<string>? NormalizeStateFilter(string? states)
|
internal static IReadOnlyCollection<string>? NormalizeStateFilter(string? states)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(states))
|
if (string.IsNullOrWhiteSpace(states))
|
||||||
|
|
@ -503,6 +543,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
Email = location.Email,
|
Email = location.Email,
|
||||||
Status = location.Status,
|
Status = location.Status,
|
||||||
AccountId = location.AccountId,
|
AccountId = location.AccountId,
|
||||||
|
AccountName = location.Account?.Name,
|
||||||
CreatedDate = location.CreatedDate,
|
CreatedDate = location.CreatedDate,
|
||||||
CreatedBy = location.createdby,
|
CreatedBy = location.createdby,
|
||||||
Contacts = contactsByLocation != null && contactsByLocation.TryGetValue(location.Id, out var contacts)
|
Contacts = contactsByLocation != null && contactsByLocation.TryGetValue(location.Id, out var contacts)
|
||||||
|
|
|
||||||
88
SeaHaven.Services/Implementation/TeamPermissionPolicy.cs
Normal file
88
SeaHaven.Services/Implementation/TeamPermissionPolicy.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.Services.Constants;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public sealed class TeamPermissionPolicy : ITeamPermissionPolicy
|
||||||
|
{
|
||||||
|
private static readonly StringComparer KeyComparer = StringComparer.OrdinalIgnoreCase;
|
||||||
|
|
||||||
|
private static readonly HashSet<string> DispatcherDefaults = new(KeyComparer)
|
||||||
|
{
|
||||||
|
TeamPermissionKeys.CreateVendors,
|
||||||
|
TeamPermissionKeys.EditVendors,
|
||||||
|
TeamPermissionKeys.DeactivateVendors,
|
||||||
|
TeamPermissionKeys.CreateSites,
|
||||||
|
TeamPermissionKeys.EditSites,
|
||||||
|
TeamPermissionKeys.CreateWorkOrders,
|
||||||
|
TeamPermissionKeys.EditOthersWorkOrders,
|
||||||
|
TeamPermissionKeys.CancelWorkOrders,
|
||||||
|
TeamPermissionKeys.RequestUplifts,
|
||||||
|
TeamPermissionKeys.AutoApproveUplifts
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly HashSet<string> SchedulerDefaults = new(
|
||||||
|
DispatcherDefaults
|
||||||
|
.Except(new[]
|
||||||
|
{
|
||||||
|
TeamPermissionKeys.RequestUplifts,
|
||||||
|
TeamPermissionKeys.AutoApproveUplifts
|
||||||
|
}, KeyComparer)
|
||||||
|
.Concat(new[]
|
||||||
|
{
|
||||||
|
TeamPermissionKeys.DeleteSites,
|
||||||
|
TeamPermissionKeys.CreateServices,
|
||||||
|
TeamPermissionKeys.EditServices,
|
||||||
|
TeamPermissionKeys.CreateCompletionDocTemplates,
|
||||||
|
TeamPermissionKeys.EditCompletionDocTemplates,
|
||||||
|
TeamPermissionKeys.ViewAllDispatchersOnDashboard
|
||||||
|
}), KeyComparer);
|
||||||
|
|
||||||
|
public bool IsAllowed(
|
||||||
|
string? roleName,
|
||||||
|
string permissionKey,
|
||||||
|
IReadOnlyDictionary<string, UserPermissionState>? overrides = null)
|
||||||
|
{
|
||||||
|
if (!TeamPermissionKeys.IsKnown(permissionKey))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (IsAdmin(roleName))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var defaults = ResolveRoleDefaults(roleName);
|
||||||
|
if (defaults is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (overrides is not null
|
||||||
|
&& overrides.TryGetValue(permissionKey, out var state))
|
||||||
|
{
|
||||||
|
return state switch
|
||||||
|
{
|
||||||
|
UserPermissionState.Allow => true,
|
||||||
|
UserPermissionState.Deny => false,
|
||||||
|
_ => defaults.Contains(permissionKey)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaults.Contains(permissionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAdmin(string? roleName) =>
|
||||||
|
string.Equals(roleName, "Admin", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static IReadOnlySet<string>? ResolveRoleDefaults(string? roleName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(roleName))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (roleName.Equals("Dispatcher", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return DispatcherDefaults;
|
||||||
|
|
||||||
|
if (roleName.Equals("Scheduler", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return SchedulerDefaults;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
SeaHaven.Services/Implementation/TeamPermissionService.cs
Normal file
96
SeaHaven.Services/Implementation/TeamPermissionService.cs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.DataServices.Dto;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.Constants;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation;
|
||||||
|
|
||||||
|
public sealed class TeamPermissionService : ITeamPermissionService
|
||||||
|
{
|
||||||
|
private readonly ITeamPermissionOverrideDataService _dataService;
|
||||||
|
private readonly ITeamPermissionPolicy _policy;
|
||||||
|
|
||||||
|
public TeamPermissionService(
|
||||||
|
ITeamPermissionOverrideDataService dataService,
|
||||||
|
ITeamPermissionPolicy policy)
|
||||||
|
{
|
||||||
|
_dataService = dataService;
|
||||||
|
_policy = policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TeamPermissionResult<TeamPermissionProfileDTO>> GetProfileAsync(
|
||||||
|
string userId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!IsAdmin(caller))
|
||||||
|
return TeamPermissionResult<TeamPermissionProfileDTO>.Failure(TeamPermissionResultStatus.Forbidden);
|
||||||
|
|
||||||
|
var user = await _dataService.GetUserAsync(userId, cancellationToken);
|
||||||
|
return user is null
|
||||||
|
? TeamPermissionResult<TeamPermissionProfileDTO>.Failure(TeamPermissionResultStatus.NotFound)
|
||||||
|
: TeamPermissionResult<TeamPermissionProfileDTO>.Success(BuildProfile(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TeamPermissionResult<TeamPermissionProfileDTO>> SetOverrideAsync(
|
||||||
|
string userId,
|
||||||
|
string permissionKey,
|
||||||
|
UserPermissionState state,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!IsAdmin(caller))
|
||||||
|
return TeamPermissionResult<TeamPermissionProfileDTO>.Failure(TeamPermissionResultStatus.Forbidden);
|
||||||
|
|
||||||
|
var canonicalKey = TeamPermissionKeys.All.FirstOrDefault(
|
||||||
|
key => string.Equals(key, permissionKey, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (canonicalKey is null)
|
||||||
|
return TeamPermissionResult<TeamPermissionProfileDTO>.Failure(
|
||||||
|
TeamPermissionResultStatus.InvalidPermissionKey);
|
||||||
|
|
||||||
|
var user = await _dataService.GetUserAsync(userId, cancellationToken);
|
||||||
|
if (user is null)
|
||||||
|
return TeamPermissionResult<TeamPermissionProfileDTO>.Failure(TeamPermissionResultStatus.NotFound);
|
||||||
|
|
||||||
|
await _dataService.SetOverrideAsync(userId, canonicalKey, state, cancellationToken);
|
||||||
|
|
||||||
|
var updatedOverrides = user.Overrides.ToDictionary(
|
||||||
|
pair => pair.Key,
|
||||||
|
pair => pair.Value,
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
updatedOverrides[canonicalKey] = state;
|
||||||
|
|
||||||
|
return TeamPermissionResult<TeamPermissionProfileDTO>.Success(
|
||||||
|
BuildProfile(new TeamPermissionUserData
|
||||||
|
{
|
||||||
|
UserId = user.UserId,
|
||||||
|
RoleName = user.RoleName,
|
||||||
|
Overrides = updatedOverrides
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private TeamPermissionProfileDTO BuildProfile(TeamPermissionUserData user)
|
||||||
|
{
|
||||||
|
return new TeamPermissionProfileDTO
|
||||||
|
{
|
||||||
|
UserId = user.UserId,
|
||||||
|
RoleName = user.RoleName,
|
||||||
|
Permissions = TeamPermissionKeys.All
|
||||||
|
.Select(key => new TeamPermissionValueDTO
|
||||||
|
{
|
||||||
|
PermissionKey = key,
|
||||||
|
OverrideState = user.Overrides.TryGetValue(key, out var state)
|
||||||
|
? state
|
||||||
|
: UserPermissionState.Unset,
|
||||||
|
IsGranted = _policy.IsAllowed(user.RoleName, key, user.Overrides)
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAdmin(ClaimsPrincipal? caller) =>
|
||||||
|
caller?.IsInRole("Admin") == true;
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using SeaHaven.Services.Validation;
|
using SeaHaven.Services.Validation;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Implementation
|
namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
|
|
@ -13,6 +14,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
private const int MaxNotesLength = 500;
|
private const int MaxNotesLength = 500;
|
||||||
private const string NotesMaxLengthMessage = "Notes cannot exceed 500 characters.";
|
private const string NotesMaxLengthMessage = "Notes cannot exceed 500 characters.";
|
||||||
|
private const string AdminRole = "Admin";
|
||||||
|
private const string AreaNotFoundMessage = "Area was not found.";
|
||||||
|
|
||||||
private readonly IVendorCompanyRosterDataService _rosterDataService;
|
private readonly IVendorCompanyRosterDataService _rosterDataService;
|
||||||
|
|
||||||
|
|
@ -39,6 +42,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
public async Task<VendorRosterDTO> CreateRosterAsync(
|
public async Task<VendorRosterDTO> CreateRosterAsync(
|
||||||
CreateVendorRosterDTO dto,
|
CreateVendorRosterDTO dto,
|
||||||
string userId,
|
string userId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated(userId);
|
EnsureAuthenticated(userId);
|
||||||
|
|
@ -60,6 +64,13 @@ namespace SeaHaven.Services.Implementation
|
||||||
"Technician ids are not allowed when creating a vendor company.")
|
"Technician ids are not allowed when creating a vendor company.")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await EnsureAreaAssignmentAllowedAsync(
|
||||||
|
caller,
|
||||||
|
dto.AreaId,
|
||||||
|
currentAreaId: null,
|
||||||
|
nameof(CreateVendorRosterDTO.AreaId),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
var writeModel = new VendorCompanyRosterWriteModel
|
var writeModel = new VendorCompanyRosterWriteModel
|
||||||
{
|
{
|
||||||
Name = dto.Name,
|
Name = dto.Name,
|
||||||
|
|
@ -71,6 +82,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
Zip = dto.Zip,
|
Zip = dto.Zip,
|
||||||
GoogleMapsUrl = dto.GoogleMapsUrl,
|
GoogleMapsUrl = dto.GoogleMapsUrl,
|
||||||
Notes = dto.Notes,
|
Notes = dto.Notes,
|
||||||
|
AreaIdProvided = true,
|
||||||
|
AreaId = dto.AreaId,
|
||||||
ActorUserId = userId,
|
ActorUserId = userId,
|
||||||
Technicians = MapTechnicians(dto.Technicians)
|
Technicians = MapTechnicians(dto.Technicians)
|
||||||
};
|
};
|
||||||
|
|
@ -83,6 +96,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
int companyId,
|
int companyId,
|
||||||
ReconcileVendorRosterDTO dto,
|
ReconcileVendorRosterDTO dto,
|
||||||
string userId,
|
string userId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated(userId);
|
EnsureAuthenticated(userId);
|
||||||
|
|
@ -101,6 +115,14 @@ namespace SeaHaven.Services.Implementation
|
||||||
// Mismatched ids: every non-null technician id must belong to this company.
|
// Mismatched ids: every non-null technician id must belong to this company.
|
||||||
await EnsureTechnicianIdsBelongToCompanyAsync(companyId, dto.Technicians, cancellationToken);
|
await EnsureTechnicianIdsBelongToCompanyAsync(companyId, dto.Technicians, cancellationToken);
|
||||||
|
|
||||||
|
if (dto.AreaIdProvided)
|
||||||
|
await EnsureAreaChangeAllowedAsync(
|
||||||
|
companyId,
|
||||||
|
caller,
|
||||||
|
dto.AreaId,
|
||||||
|
nameof(ReconcileVendorRosterDTO.AreaId),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
var writeModel = new VendorCompanyRosterWriteModel
|
var writeModel = new VendorCompanyRosterWriteModel
|
||||||
{
|
{
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
|
|
@ -114,6 +136,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
Zip = dto.Zip,
|
Zip = dto.Zip,
|
||||||
GoogleMapsUrl = dto.GoogleMapsUrl,
|
GoogleMapsUrl = dto.GoogleMapsUrl,
|
||||||
Notes = dto.Notes,
|
Notes = dto.Notes,
|
||||||
|
AreaIdProvided = dto.AreaIdProvided,
|
||||||
|
AreaId = dto.AreaId,
|
||||||
ActorUserId = userId,
|
ActorUserId = userId,
|
||||||
Technicians = MapTechnicians(dto.Technicians)
|
Technicians = MapTechnicians(dto.Technicians)
|
||||||
};
|
};
|
||||||
|
|
@ -126,6 +150,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
int companyId,
|
int companyId,
|
||||||
AddTechniciansVendorRosterDTO dto,
|
AddTechniciansVendorRosterDTO dto,
|
||||||
string userId,
|
string userId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated(userId);
|
EnsureAuthenticated(userId);
|
||||||
|
|
@ -167,6 +192,14 @@ namespace SeaHaven.Services.Implementation
|
||||||
if (failures.Count > 0)
|
if (failures.Count > 0)
|
||||||
throw new ValidationException(failures);
|
throw new ValidationException(failures);
|
||||||
|
|
||||||
|
if (companyFields is { AreaIdProvided: true })
|
||||||
|
await EnsureAreaChangeAllowedAsync(
|
||||||
|
companyId,
|
||||||
|
caller,
|
||||||
|
companyFields.AreaId,
|
||||||
|
$"{nameof(AddTechniciansVendorRosterDTO.CompanyFields)}.{nameof(VendorRosterCompanyFieldsDTO.AreaId)}",
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
// The contact-group invariant ("at least one company phone or email") is
|
// The contact-group invariant ("at least one company phone or email") is
|
||||||
// preserved by construction: blank company fields map to null ("unchanged"),
|
// preserved by construction: blank company fields map to null ("unchanged"),
|
||||||
// so this endpoint can only set valid phone/email values, never clear them.
|
// so this endpoint can only set valid phone/email values, never clear them.
|
||||||
|
|
@ -184,6 +217,50 @@ namespace SeaHaven.Services.Implementation
|
||||||
return MapToDTO(saved);
|
return MapToDTO(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SH-278: Area assignment is Admin-only. A payload that leaves the stored Area
|
||||||
|
// unchanged is allowed for any authenticated caller, so non-admin users can
|
||||||
|
// still save other company fields while round-tripping the current Area.
|
||||||
|
private async Task EnsureAreaChangeAllowedAsync(
|
||||||
|
int companyId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
|
int? requestedAreaId,
|
||||||
|
string propertyName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var roster = await _rosterDataService.GetRosterAsync(null, companyId, cancellationToken)
|
||||||
|
?? throw new KeyNotFoundException($"Vendor company with ID {companyId} not found.");
|
||||||
|
|
||||||
|
await EnsureAreaAssignmentAllowedAsync(
|
||||||
|
caller,
|
||||||
|
requestedAreaId,
|
||||||
|
roster.AreaId,
|
||||||
|
propertyName,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureAreaAssignmentAllowedAsync(
|
||||||
|
ClaimsPrincipal caller,
|
||||||
|
int? requestedAreaId,
|
||||||
|
int? currentAreaId,
|
||||||
|
string propertyName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (requestedAreaId == currentAreaId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (caller?.IsInRole(AdminRole) != true)
|
||||||
|
throw new VendorAreaAssignmentForbiddenException();
|
||||||
|
|
||||||
|
if (requestedAreaId is int areaId
|
||||||
|
&& (areaId <= 0 || !await _rosterDataService.IsActiveAreaAsync(areaId, cancellationToken)))
|
||||||
|
{
|
||||||
|
throw new ValidationException(new[]
|
||||||
|
{
|
||||||
|
new ValidationFailure(propertyName, AreaNotFoundMessage)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void EnsureAuthenticated(string userId)
|
private static void EnsureAuthenticated(string userId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(userId))
|
if (string.IsNullOrWhiteSpace(userId))
|
||||||
|
|
@ -290,7 +367,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
|| !string.IsNullOrWhiteSpace(fields.State)
|
|| !string.IsNullOrWhiteSpace(fields.State)
|
||||||
|| !string.IsNullOrWhiteSpace(fields.Zip)
|
|| !string.IsNullOrWhiteSpace(fields.Zip)
|
||||||
|| !string.IsNullOrWhiteSpace(fields.GoogleMapsUrl)
|
|| !string.IsNullOrWhiteSpace(fields.GoogleMapsUrl)
|
||||||
|| !string.IsNullOrWhiteSpace(fields.Notes);
|
|| !string.IsNullOrWhiteSpace(fields.Notes)
|
||||||
|
|| fields.AreaIdProvided;
|
||||||
|
|
||||||
private static bool HasNoCompanyChange(VendorRosterCompanyFieldsWriteModel model) =>
|
private static bool HasNoCompanyChange(VendorRosterCompanyFieldsWriteModel model) =>
|
||||||
model.Name == null
|
model.Name == null
|
||||||
|
|
@ -301,7 +379,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
&& model.State == null
|
&& model.State == null
|
||||||
&& model.Zip == null
|
&& model.Zip == null
|
||||||
&& model.GoogleMapsUrl == null
|
&& model.GoogleMapsUrl == null
|
||||||
&& model.Notes == null;
|
&& model.Notes == null
|
||||||
|
&& !model.AreaIdProvided;
|
||||||
|
|
||||||
private static VendorRosterCompanyFieldsWriteModel ValidateAndMapCompanyFields(
|
private static VendorRosterCompanyFieldsWriteModel ValidateAndMapCompanyFields(
|
||||||
VendorRosterCompanyFieldsDTO fields,
|
VendorRosterCompanyFieldsDTO fields,
|
||||||
|
|
@ -344,7 +423,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
State = string.IsNullOrWhiteSpace(fields.State) ? null : fields.State,
|
State = string.IsNullOrWhiteSpace(fields.State) ? null : fields.State,
|
||||||
Zip = string.IsNullOrWhiteSpace(fields.Zip) ? null : fields.Zip,
|
Zip = string.IsNullOrWhiteSpace(fields.Zip) ? null : fields.Zip,
|
||||||
GoogleMapsUrl = string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) ? null : fields.GoogleMapsUrl,
|
GoogleMapsUrl = string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) ? null : fields.GoogleMapsUrl,
|
||||||
Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes
|
Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes,
|
||||||
|
AreaIdProvided = fields.AreaIdProvided,
|
||||||
|
AreaId = fields.AreaId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -443,6 +524,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
Zip = roster.Zip,
|
Zip = roster.Zip,
|
||||||
GoogleMapsUrl = roster.GoogleMapsUrl,
|
GoogleMapsUrl = roster.GoogleMapsUrl,
|
||||||
Notes = roster.Notes,
|
Notes = roster.Notes,
|
||||||
|
AreaId = roster.AreaId,
|
||||||
|
AreaName = roster.AreaName,
|
||||||
RowVersion = roster.RowVersion == null ? null : Convert.ToBase64String(roster.RowVersion),
|
RowVersion = roster.RowVersion == null ? null : Convert.ToBase64String(roster.RowVersion),
|
||||||
Technicians = roster.Technicians.Select(t => new VendorRosterTechnicianDTO
|
Technicians = roster.Technicians.Select(t => new VendorRosterTechnicianDTO
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using FluentValidation;
|
||||||
using FluentValidation.Results;
|
using FluentValidation.Results;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Globalization;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.DataServices.Models;
|
using SeaHaven.DataServices.Models;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
|
|
@ -160,10 +161,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
IReadOnlyCollection<string>? trades = null,
|
IReadOnlyCollection<string>? trades = null,
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null,
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
IReadOnlyCollection<string>? areas = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var normalizedPage = Math.Max(1, page);
|
var normalizedPage = Math.Max(1, page);
|
||||||
var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize);
|
var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize);
|
||||||
|
var areaFilter = await ResolveAreaFilterAsync(areas, cancellationToken);
|
||||||
var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync(
|
var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync(
|
||||||
normalizedPage,
|
normalizedPage,
|
||||||
normalizedPageSize,
|
normalizedPageSize,
|
||||||
|
|
@ -173,6 +176,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
trades,
|
trades,
|
||||||
locations,
|
locations,
|
||||||
jobBuckets,
|
jobBuckets,
|
||||||
|
areaFilter,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return new PagedResult<VendorDirectoryItemDTO>
|
return new PagedResult<VendorDirectoryItemDTO>
|
||||||
|
|
@ -184,6 +188,47 @@ namespace SeaHaven.Services.Implementation
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SH-278 areas[n] contract: blank entries are ignored; "__unassigned__" selects
|
||||||
|
// companies without an Area; other values must be stable positive integer ids
|
||||||
|
// of active catalogue rows. Display names, unknown ids and archived ids never
|
||||||
|
// error — they simply match nobody. Null means no Area filter was requested.
|
||||||
|
private async Task<VendorAreaFilter?> ResolveAreaFilterAsync(
|
||||||
|
IReadOnlyCollection<string>? areas,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var values = areas?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (values is not { Count: > 0 })
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var includeUnassigned = values.Contains(VendorAreaFilterValues.Unassigned, StringComparer.Ordinal);
|
||||||
|
var requestedIds = new HashSet<int>();
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
if (int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0)
|
||||||
|
requestedIds.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyCollection<int> activeIds = Array.Empty<int>();
|
||||||
|
if (requestedIds.Count > 0)
|
||||||
|
{
|
||||||
|
var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken);
|
||||||
|
activeIds = activeAreas
|
||||||
|
.Select(area => area.Id)
|
||||||
|
.Where(requestedIds.Contains)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VendorAreaFilter
|
||||||
|
{
|
||||||
|
AreaIds = activeIds,
|
||||||
|
IncludeUnassigned = includeUnassigned
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static VendorDirectoryItemDTO MapToCompanyGroup(VendorCompanyGroup group) => new()
|
private static VendorDirectoryItemDTO MapToCompanyGroup(VendorCompanyGroup group) => new()
|
||||||
{
|
{
|
||||||
Id = group.Id,
|
Id = group.Id,
|
||||||
|
|
@ -201,6 +246,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
TradeSpecialties = group.TradeSpecialties,
|
TradeSpecialties = group.TradeSpecialties,
|
||||||
GoogleMapsUrl = group.GoogleMapsUrl,
|
GoogleMapsUrl = group.GoogleMapsUrl,
|
||||||
Notes = group.Notes,
|
Notes = group.Notes,
|
||||||
|
AreaId = group.AreaId,
|
||||||
|
AreaName = group.AreaName,
|
||||||
IsActive = group.IsActive,
|
IsActive = group.IsActive,
|
||||||
TotalJobs = group.TotalJobs,
|
TotalJobs = group.TotalJobs,
|
||||||
Technicians = group.Technicians.Select(MapToTechnician).ToList()
|
Technicians = group.Technicians.Select(MapToTechnician).ToList()
|
||||||
|
|
@ -731,6 +778,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
// populated even on an empty database and independent of the status filter.
|
// populated even on an empty database and independent of the status filter.
|
||||||
var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken);
|
var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken);
|
||||||
var canonicalTrades = await _vendorDataService.GetActiveTradesAsync(cancellationToken);
|
var canonicalTrades = await _vendorDataService.GetActiveTradesAsync(cancellationToken);
|
||||||
|
var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken);
|
||||||
|
|
||||||
var companyFacets = companies
|
var companyFacets = companies
|
||||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
||||||
|
|
@ -772,10 +820,19 @@ namespace SeaHaven.Services.Implementation
|
||||||
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
|
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
|
||||||
.OrderBy(location => location.Label);
|
.OrderBy(location => location.Label);
|
||||||
|
|
||||||
|
// SH-278: the active Area catalogue, A–Z case-insensitive, independent of the
|
||||||
|
// status filter and of whether any company currently uses an Area. Clients
|
||||||
|
// prepend the "__unassigned__" option themselves.
|
||||||
|
var areas = activeAreas
|
||||||
|
.OrderBy(area => area.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(area => new VendorFacetAreaDTO { Id = area.Id, Name = area.Name })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
return new VendorFacetsDTO
|
return new VendorFacetsDTO
|
||||||
{
|
{
|
||||||
Companies = companyFacets,
|
Companies = companyFacets,
|
||||||
Trades = trades,
|
Trades = trades,
|
||||||
|
Areas = areas,
|
||||||
Locations = locations,
|
Locations = locations,
|
||||||
JobBuckets = new[]
|
JobBuckets = new[]
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -26,5 +26,14 @@ namespace SeaHaven.Services.Interfaces
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
DashboardStatsQueryDTO query,
|
DashboardStatsQueryDTO query,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<DashboardTrendResponseDTO> GetTrendAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
DashboardTrendQueryDTO query,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<DashboardVendorInsightsResponseDTO> GetVendorInsightsAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ namespace SeaHaven.Services.Interfaces
|
||||||
Task<bool> LocationExistsAsync(int id);
|
Task<bool> LocationExistsAsync(int id);
|
||||||
Task<int> GetTotalLocationCountAsync();
|
Task<int> GetTotalLocationCountAsync();
|
||||||
|
|
||||||
Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, string? states = null, CancellationToken cancellationToken = default);
|
Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, string? states = null, CancellationToken cancellationToken = default, string? sortBy = null, string? sortDirection = null);
|
||||||
Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken);
|
Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken);
|
||||||
Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
|
Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
|
||||||
Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
|
Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
|
||||||
|
|
|
||||||
23
SeaHaven.Services/Interfaces/ITeamPermissionPolicy.cs
Normal file
23
SeaHaven.Services/Interfaces/ITeamPermissionPolicy.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pure evaluator for team-members permissions. Applies explicit
|
||||||
|
/// per-person overrides on top of role defaults; Admin has effective access
|
||||||
|
/// to every canonical key regardless of stored values. Unknown or legacy
|
||||||
|
/// roles receive no permissions.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITeamPermissionPolicy
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves whether a user in <paramref name="roleName"/> holds
|
||||||
|
/// <paramref name="permissionKey"/>. <paramref name="overrides"/> is the
|
||||||
|
/// person's stored override set (missing key behaves as Unset).
|
||||||
|
/// </summary>
|
||||||
|
bool IsAllowed(
|
||||||
|
string? roleName,
|
||||||
|
string permissionKey,
|
||||||
|
IReadOnlyDictionary<string, UserPermissionState>? overrides = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
SeaHaven.Services/Interfaces/ITeamPermissionService.cs
Normal file
20
SeaHaven.Services/Interfaces/ITeamPermissionService.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
public interface ITeamPermissionService
|
||||||
|
{
|
||||||
|
Task<TeamPermissionResult<TeamPermissionProfileDTO>> GetProfileAsync(
|
||||||
|
string userId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<TeamPermissionResult<TeamPermissionProfileDTO>> SetOverrideAsync(
|
||||||
|
string userId,
|
||||||
|
string permissionKey,
|
||||||
|
UserPermissionState state,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Security.Claims;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Interfaces
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
|
@ -13,18 +14,21 @@ namespace SeaHaven.Services.Interfaces
|
||||||
Task<VendorRosterDTO> CreateRosterAsync(
|
Task<VendorRosterDTO> CreateRosterAsync(
|
||||||
CreateVendorRosterDTO dto,
|
CreateVendorRosterDTO dto,
|
||||||
string userId,
|
string userId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<VendorRosterDTO> ReconcileRosterAsync(
|
Task<VendorRosterDTO> ReconcileRosterAsync(
|
||||||
int companyId,
|
int companyId,
|
||||||
ReconcileVendorRosterDTO dto,
|
ReconcileVendorRosterDTO dto,
|
||||||
string userId,
|
string userId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<VendorRosterDTO> AddTechniciansAsync(
|
Task<VendorRosterDTO> AddTechniciansAsync(
|
||||||
int companyId,
|
int companyId,
|
||||||
AddTechniciansVendorRosterDTO dto,
|
AddTechniciansVendorRosterDTO dto,
|
||||||
string userId,
|
string userId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ namespace SeaHaven.Services.Interfaces
|
||||||
IReadOnlyCollection<string>? trades = null,
|
IReadOnlyCollection<string>? trades = null,
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null,
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
IReadOnlyCollection<string>? areas = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
|
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
|
||||||
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);
|
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);
|
||||||
|
|
|
||||||
|
|
@ -835,8 +835,8 @@ public class WorkOrderAccountScopeTests
|
||||||
public Task<bool> ExistsAsync(int id) => _inner.ExistsAsync(id);
|
public Task<bool> ExistsAsync(int id) => _inner.ExistsAsync(id);
|
||||||
public Task<int> CountAsync() => _inner.CountAsync();
|
public Task<int> CountAsync() => _inner.CountAsync();
|
||||||
public Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
|
public Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
|
||||||
int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken)
|
int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken, string? sortBy = null, string? sortDirection = null)
|
||||||
=> _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken);
|
=> _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection);
|
||||||
public Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
public Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
||||||
=> _inner.GetDetailByIdAsync(id, cancellationToken);
|
=> _inner.GetDetailByIdAsync(id, cancellationToken);
|
||||||
public Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
|
public Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue