This commit is contained in:
Alexandre Brandizzi 2026-09-16 20:02:44 -03:00 • committed by GitHub
commit 9ef4084a9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 14497 additions and 5 deletions

View file

@ -0,0 +1,60 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class AccountOwnerDataServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
[Fact]
public async Task EnsureConfiguredOwnerAsync_MarksOnlyConfiguredUser()
{
await using var context = NewContext();
context.Users.AddRange(
new ApplicationUser { Id = "old-owner", UserName = "old@example.com", IsAccountOwner = true },
new ApplicationUser { Id = "configured-owner", UserName = "configured@example.com" });
await context.SaveChangesAsync();
var service = new AccountOwnerDataService(context);
(await service.EnsureConfiguredOwnerAsync(" configured-owner ", CancellationToken.None)).Should().BeTrue();
(await context.Users.SingleAsync(user => user.Id == "old-owner")).IsAccountOwner.Should().BeFalse();
(await context.Users.SingleAsync(user => user.Id == "configured-owner")).IsAccountOwner.Should().BeTrue();
}
[Fact]
public async Task EnsureConfiguredOwnerAsync_WithNoConfiguration_ClearsExistingOwner()
{
await using var context = NewContext();
context.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner@example.com", IsAccountOwner = true });
await context.SaveChangesAsync();
var service = new AccountOwnerDataService(context);
(await service.EnsureConfiguredOwnerAsync(null, CancellationToken.None)).Should().BeTrue();
(await service.EnsureConfiguredOwnerAsync(" ", CancellationToken.None)).Should().BeFalse();
(await context.Users.SingleAsync()).IsAccountOwner.Should().BeFalse();
}
[Fact]
public async Task EnsureConfiguredOwnerAsync_RejectsUnknownUser()
{
await using var context = NewContext();
var service = new AccountOwnerDataService(context);
var action = () => service.EnsureConfiguredOwnerAsync("missing", CancellationToken.None);
await action.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("The configured account owner user was not found.");
}
}

View file

@ -0,0 +1,275 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class TeamMemberServiceTests
{
[Fact]
public async Task Create_DispatcherRequiresAtLeastOneServiceArea()
{
var userManager = UserManager();
var roleManager = RoleManager();
var service = new TeamMemberService(
userManager.Object,
roleManager.Object,
Mock.Of<IUserServiceAreaDataService>(),
Mock.Of<ITeamPermissionOverrideDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<ITeamPermissionService>());
var result = await service.CreateAsync(
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
Admin(),
CancellationToken.None);
result.Success.Should().BeFalse();
result.Error.Should().Be("At least one service area is required for a Dispatcher.");
userManager.Verify(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
[Fact]
public async Task Create_RejectsUnknownColorAndPermission()
{
var service = NewService(out var userManager, out _, out _, out _, out _, out _);
var badColor = await service.CreateAsync(
ValidRequest() with { Color = "#000000" },
Admin(),
CancellationToken.None);
var badPermission = await service.CreateAsync(
ValidRequest() with
{
PermissionOverrides = new Dictionary<string, UserPermissionState>
{
["not-a-permission"] = UserPermissionState.Allow
}
},
Admin(),
CancellationToken.None);
badColor.Error.Should().Be("Color must be selected from the accessible palette.");
badPermission.Error.Should().Be("Unknown permission key: not-a-permission.");
userManager.Verify(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
[Fact]
public async Task Create_PersistsPendingUserWithoutPasswordAndStoresAreasAndOverrides()
{
var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides, out _, out _);
ApplicationUser? created = null;
userManager
.Setup(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()))
.Callback<ApplicationUser>(user =>
{
user.Id = "new-user";
created = user;
})
.ReturnsAsync(IdentityResult.Success);
userManager
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((ApplicationUser?)null);
userManager
.Setup(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), "Dispatcher"))
.ReturnsAsync(IdentityResult.Success);
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
var result = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None);
result.Success.Should().BeTrue();
created.Should().NotBeNull();
created!.PasswordHash.Should().BeNull();
created.EmailConfirmed.Should().BeFalse();
created.PendingRegistration.Should().BeTrue();
created.PhoneNumber.Should().Be("555-0100");
result.Member!.ServiceAreas.Should().Equal("East", "West");
areas.Verify(data => data.ReplaceAsync("new-user", It.Is<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "East", "West" })), It.IsAny<CancellationToken>()), Times.Once);
overrides.Verify(data => data.SetOverridesAsync("new-user", It.Is<IReadOnlyDictionary<string, UserPermissionState>>(value => value["deleteSites"] == UserPermissionState.Allow), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Update_ProtectsAccountOwnerFromRoleChangeAndDeactivation()
{
var service = NewService(
out var userManager,
out _,
out _,
out _,
out var userData,
out _);
var owner = new ApplicationUser
{
Id = "owner",
FirstName = "Primary",
Email = "owner@example.com",
IsAccountOwner = true
};
userManager.Setup(manager => manager.FindByIdAsync(owner.Id)).ReturnsAsync(owner);
userManager.Setup(manager => manager.GetRolesAsync(owner)).ReturnsAsync(new List<string> { "Admin" });
userData.Setup(data => data.IsAccountOwnerAsync(owner.Id, It.IsAny<CancellationToken>())).ReturnsAsync(true);
var result = await service.UpdateAsync(
owner.Id,
new UpdateTeamMemberRequestDTO
{
Name = "Primary",
Role = "Dispatcher",
Color = "#F59E0B",
Email = owner.Email,
ServiceAreas = new[] { "East" },
IsActive = false
},
Admin(),
CancellationToken.None);
result.Success.Should().BeFalse();
result.Error.Should().Be("Forbidden");
userData.Verify(data => data.UpdateUserAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Update_ReplacesAreasAndOverridesAndReturnsFullDetail()
{
var service = NewService(
out var userManager,
out var roleManager,
out var areas,
out var overrides,
out var userData,
out var permissions);
var user = new ApplicationUser
{
Id = "dispatcher",
FirstName = "Taylor",
Email = "old@example.com",
Color = "#F59E0B",
UniqueName = "Active"
};
userManager.Setup(manager => manager.FindByIdAsync(user.Id)).ReturnsAsync(user);
userManager.Setup(manager => manager.GetRolesAsync(user)).ReturnsAsync(new List<string> { "Dispatcher" });
userManager.Setup(manager => manager.FindByEmailAsync("new@example.com")).ReturnsAsync((ApplicationUser?)null);
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { "Central" });
permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TeamPermissionResult<TeamPermissionProfileDTO>.Success(new TeamPermissionProfileDTO
{
UserId = user.Id,
RoleName = "Dispatcher",
Permissions = new[]
{
new TeamPermissionValueDTO
{
PermissionKey = "createSites",
OverrideState = UserPermissionState.Deny,
IsGranted = false
}
}
}));
var result = await service.UpdateAsync(
user.Id,
new UpdateTeamMemberRequestDTO
{
Name = "Taylor Updated",
Role = "Dispatcher",
Color = "#0D9488",
Email = "new@example.com",
Phone = "555-0101",
ServiceAreas = new[] { "West" },
PermissionOverrides = new Dictionary<string, UserPermissionState>
{
["createSites"] = UserPermissionState.Deny
}
},
Admin(),
CancellationToken.None);
result.Success.Should().BeTrue();
result.Member!.Name.Should().Be("Taylor Updated");
result.Member.Email.Should().Be("new@example.com");
result.Member.ServiceAreas.Should().Equal("Central");
result.Member.Permissions.Should().ContainSingle(permission => permission.PermissionKey == "createSites");
userData.Verify(data => data.UpdateUserAsync(user, It.IsAny<CancellationToken>()), Times.Once);
areas.Verify(data => data.ReplaceAsync(user.Id, It.Is<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "West" })), It.IsAny<CancellationToken>()), Times.Once);
overrides.Verify(data => data.ClearOverridesAsync(user.Id, It.IsAny<CancellationToken>()), Times.Once);
overrides.Verify(data => data.SetOverridesAsync(user.Id, It.Is<IReadOnlyDictionary<string, UserPermissionState>>(value => value["createSites"] == UserPermissionState.Deny), It.IsAny<CancellationToken>()), Times.Once);
}
private static TeamMemberService NewService(
out Mock<UserManager<ApplicationUser>> userManager,
out Mock<RoleManager<IdentityRole>> roleManager,
out Mock<IUserServiceAreaDataService> areas,
out Mock<ITeamPermissionOverrideDataService> overrides,
out Mock<IUserDataService> userData,
out Mock<ITeamPermissionService> permissions)
{
userManager = UserManager();
roleManager = RoleManager();
areas = new Mock<IUserServiceAreaDataService>();
overrides = new Mock<ITeamPermissionOverrideDataService>();
userData = new Mock<IUserDataService>();
permissions = new Mock<ITeamPermissionService>();
return new TeamMemberService(
userManager.Object,
roleManager.Object,
areas.Object,
overrides.Object,
userData.Object,
permissions.Object);
}
private static Mock<UserManager<ApplicationUser>> UserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object,
Microsoft.Extensions.Options.Options.Create(new IdentityOptions()),
Mock.Of<IPasswordHasher<ApplicationUser>>(),
Array.Empty<IUserValidator<ApplicationUser>>(),
Array.Empty<IPasswordValidator<ApplicationUser>>(),
Mock.Of<ILookupNormalizer>(),
new IdentityErrorDescriber(),
Mock.Of<IServiceProvider>(),
Mock.Of<ILogger<UserManager<ApplicationUser>>>());
}
private static Mock<RoleManager<IdentityRole>> RoleManager()
{
var store = new Mock<IRoleStore<IdentityRole>>();
return new Mock<RoleManager<IdentityRole>>(
store.Object,
Array.Empty<IRoleValidator<IdentityRole>>(),
Mock.Of<ILookupNormalizer>(),
new IdentityErrorDescriber(),
Mock.Of<ILogger<RoleManager<IdentityRole>>>());
}
private static CreateTeamMemberRequestDTO ValidRequest() => new()
{
Name = "Taylor Dispatcher",
Role = "dispatcher",
Color = "#F59E0B",
Email = "taylor@example.com",
Phone = "555-0100",
ServiceAreas = new[] { "east", "West" },
PermissionOverrides = new Dictionary<string, UserPermissionState>
{
["deleteSites"] = UserPermissionState.Allow
}
};
private static ClaimsPrincipal Admin() =>
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "test"));
}

View file

@ -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"
});
}
}

View file

@ -90,6 +90,23 @@ public class UserServiceTests
userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DeleteUser_AccountOwner_ReturnsForbiddenWithoutCascade()
{
var user = IdentityTestHelpers.User("owner");
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(user);
userData.Setup(u => u.IsAccountOwnerAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(true);
var service = NewService(userData, new Mock<IEmailSender>(), out _);
var outcome = await service.DeleteUserAsync("owner", Principal("Admin"), CancellationToken.None);
outcome.Success.Should().BeFalse();
outcome.Error.Should().Be(UserMutationErrors.Forbidden);
userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task DeleteUser_NonAdmin_ReturnsForbiddenWithoutCascade()
{
@ -138,6 +155,26 @@ public class UserServiceTests
outcome.Error.Should().Be(UserMutationErrors.UserNotFound);
}
[Fact]
public async Task EditUser_AccountOwner_ReturnsForbiddenWithoutMutation()
{
var user = IdentityTestHelpers.User("owner");
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(user);
userData.Setup(u => u.IsAccountOwnerAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(true);
var service = NewService(userData, new Mock<IEmailSender>(), out _);
var outcome = await service.EditUserAsync(
new EditUserRequestDTO { Id = "owner", Email = "owner@example.com", Name = "Owner", Role = "User" },
Principal("Admin"),
CancellationToken.None);
outcome.Success.Should().BeFalse();
outcome.Error.Should().Be(UserMutationErrors.Forbidden);
userData.Verify(u => u.UpdateUserAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task GetUserProfile_MapsCreatedDateToAddedDate()
{

View file

@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers;
[Authorize]
[ApiController]
[Route("api/team-members")]
public sealed class TeamMemberController : ControllerBase
{
private readonly ITeamMemberService _teamMemberService;
public TeamMemberController(ITeamMemberService teamMemberService)
{
_teamMemberService = teamMemberService;
}
[HttpPost]
public async Task<IActionResult> Create(
CreateTeamMemberRequestDTO request,
CancellationToken cancellationToken)
{
var outcome = await _teamMemberService.CreateAsync(request, User, cancellationToken);
if (!outcome.Success)
{
if (string.Equals(outcome.Error, "Forbidden", StringComparison.Ordinal))
return Forbid();
return BadRequest(new { message = outcome.Error });
}
return Ok(outcome.Member);
}
[HttpGet("{userId}")]
public async Task<IActionResult> Get(string userId, CancellationToken cancellationToken)
{
var outcome = await _teamMemberService.GetAsync(userId, User, cancellationToken);
return ToActionResult(outcome);
}
[HttpPut("{userId}")]
public async Task<IActionResult> Update(
string userId,
UpdateTeamMemberRequestDTO request,
CancellationToken cancellationToken)
{
var outcome = await _teamMemberService.UpdateAsync(userId, request, User, cancellationToken);
return ToActionResult(outcome);
}
private IActionResult ToActionResult(TeamMemberOperationOutcomeDTO outcome)
{
if (outcome.Success)
return Ok(outcome.Member);
if (string.Equals(outcome.Error, "Forbidden", StringComparison.Ordinal))
return Forbid();
if (string.Equals(outcome.Error, "Team member not found.", StringComparison.Ordinal))
return NotFound(new { message = outcome.Error });
return BadRequest(new { message = outcome.Error });
}
}

View file

@ -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." })
};
}
}

View file

@ -222,6 +222,13 @@ app.UseAuthorization();
app.MapControllers();
using (var ownerScope = app.Services.CreateScope())
{
await ownerScope.ServiceProvider
.GetRequiredService<IAccountOwnerDesignationService>()
.EnsureConfiguredOwnerAsync(CancellationToken.None);
}
//if (app.Environment.IsDevelopment())
//{
// using var scope = app.Services.CreateScope();

View file

@ -170,6 +170,12 @@ namespace Data.SeaHavenIndustries
builder.Entity<ApplicationUser>()
.HasIndex(u => u.AccountId);
builder.Entity<ApplicationUser>()
.HasIndex(u => u.IsAccountOwner)
.IsUnique()
.HasFilter("IsAccountOwner = 1")
.HasDatabaseName("IX_AspNetUsers_IsAccountOwner");
builder.Entity<VendorCompany>()
.HasIndex(c => c.NormalizedName)
.IsUnique();
@ -225,6 +231,52 @@ namespace Data.SeaHavenIndustries
.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");
});
builder.Entity<UserServiceArea>(entity =>
{
entity.HasKey(area => new { area.UserId, area.Area });
entity.Property(area => area.UserId)
.HasMaxLength(450);
entity.Property(area => area.Area)
.HasMaxLength(32);
entity.HasOne(area => area.User)
.WithMany(user => user.ServiceAreas)
.HasForeignKey(area => area.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(area => new { area.UserId, area.Area })
.IsUnique()
.HasDatabaseName("IX_UserServiceAreas_UserId_Area");
});
}
public DbSet<Category> Categories { get; set; }
public DbSet<Locations> Locations { get; set; }
@ -279,6 +331,8 @@ namespace Data.SeaHavenIndustries
public DbSet<Department> Departments { get; set; }
public DbSet<JobTitle> JobTitles { get; set; }
public DbSet<Region> Regions { get; set; }
public DbSet<UserPermissionOverride> UserPermissionOverrides { get; set; }
public DbSet<UserServiceArea> UserServiceAreas { get; set; }
public override int SaveChanges()
{
@ -358,12 +412,16 @@ namespace Data.SeaHavenIndustries
public string? Initials { get; set; }
public string? Color { get; set; }
public int? Type { get; set; } // 1 for users 0 for admin
public bool IsAccountOwner { get; set; }
public bool? PendingRegistration { get; set; }
public DateTime? PendingRegistrationCreatedDate { get; set; }
/// <summary>Optional CRM account membership for server-derived media scope (SH-221).</summary>
public int? AccountId { get; set; }
[ForeignKey(nameof(AccountId))]
public virtual Accounts? Account { get; set; }
public ICollection<Template>? Templates { get; set; }
public ICollection<WorkOrder>? WorkOrders { get; set; }
public ICollection<UserServiceArea> ServiceAreas { get; set; } = new List<UserServiceArea>();
}
public class FullAuditEntity
{

View 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; }
}
}

View file

@ -0,0 +1,9 @@
namespace Data.SeaHavenIndustries;
/// <summary>Canonical service area assigned to a team member.</summary>
public sealed class UserServiceArea
{
public string UserId { get; set; } = null!;
public string Area { get; set; } = null!;
public ApplicationUser User { get; set; } = null!;
}

View 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
}
}

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class SH329_PrimaryAccountOwner : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsAccountOwner",
table: "AspNetUsers",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.CreateIndex(
name: "IX_AspNetUsers_IsAccountOwner",
table: "AspNetUsers",
column: "IsAccountOwner",
unique: true,
filter: "IsAccountOwner = 1");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_AspNetUsers_IsAccountOwner",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "IsAccountOwner",
table: "AspNetUsers");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,66 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class SH325_PendingTeamMember : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "PendingRegistration",
table: "AspNetUsers",
type: "bit",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "PendingRegistrationCreatedDate",
table: "AspNetUsers",
type: "datetime2",
nullable: true);
migrationBuilder.CreateTable(
name: "UserServiceAreas",
columns: table => new
{
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
Area = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserServiceAreas", x => new { x.UserId, x.Area });
table.ForeignKey(
name: "FK_UserServiceAreas_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_UserServiceAreas_UserId_Area",
table: "UserServiceAreas",
columns: new[] { "UserId", "Area" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserServiceAreas");
migrationBuilder.DropColumn(
name: "PendingRegistration",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "PendingRegistrationCreatedDate",
table: "AspNetUsers");
}
}
}

View file

@ -220,6 +220,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Initials")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsAccountOwner")
.HasColumnType("bit");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -246,6 +249,12 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<bool?>("PendingRegistration")
.HasColumnType("bit");
b.Property<DateTime?>("PendingRegistrationCreatedDate")
.HasColumnType("datetime2");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
@ -275,6 +284,11 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("AccountId");
b.HasIndex("IsAccountOwner")
.IsUnique()
.HasDatabaseName("IX_AspNetUsers_IsAccountOwner")
.HasFilter("IsAccountOwner = 1");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
@ -2072,6 +2086,47 @@ namespace Data.SeaHavenIndustries.Migrations
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.UserServiceArea", b =>
{
b.Property<string>("UserId")
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.Property<string>("Area")
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.HasKey("UserId", "Area");
b.HasIndex("UserId", "Area")
.IsUnique()
.HasDatabaseName("IX_UserServiceAreas_UserId_Area");
b.ToTable("UserServiceAreas");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
{
b.Property<int>("Id")
@ -3616,6 +3671,28 @@ namespace Data.SeaHavenIndustries.Migrations
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.UserServiceArea", b =>
{
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User")
.WithMany("ServiceAreas")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
{
b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company")
@ -3860,6 +3937,8 @@ namespace Data.SeaHavenIndustries.Migrations
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
{
b.Navigation("ServiceAreas");
b.Navigation("Templates");
b.Navigation("WorkOrders");

View 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);
}

View file

@ -8,6 +8,10 @@ namespace SeaHaven.DataServices.Dto
public string? Name { get; init; }
public DateTime? CreatedDate { get; init; }
public string? Status { get; init; }
public string? Phone { get; init; }
public string? Color { get; init; }
public bool? PendingRegistration { get; init; }
public IReadOnlyList<string> ServiceAreas { get; init; } = Array.Empty<string>();
}
public sealed class UserProfileData

View file

@ -0,0 +1,54 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation;
public sealed class AccountOwnerDataService : IAccountOwnerDataService
{
private readonly ApplicationDbContext _context;
public AccountOwnerDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<bool> EnsureConfiguredOwnerAsync(
string? configuredUserId,
CancellationToken cancellationToken)
{
var ownerId = string.IsNullOrWhiteSpace(configuredUserId)
? null
: configuredUserId.Trim();
if (ownerId is not null)
{
var configuredUserExists = await _context.Users
.AsNoTracking()
.AnyAsync(user => user.Id == ownerId, cancellationToken);
if (!configuredUserExists)
throw new InvalidOperationException("The configured account owner user was not found.");
}
var ownerRows = await _context.Users
.Where(user => user.IsAccountOwner || (ownerId != null && user.Id == ownerId))
.ToListAsync(cancellationToken);
var changed = false;
foreach (var user in ownerRows)
{
var shouldBeOwner = ownerId is not null && user.Id == ownerId;
if (user.IsAccountOwner != shouldBeOwner)
{
user.IsAccountOwner = shouldBeOwner;
changed = true;
}
}
if (changed)
await _context.SaveChangesAsync(cancellationToken);
return changed;
}
}

View file

@ -0,0 +1,108 @@
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)
{
await SetOverridesAsync(
userId,
new Dictionary<string, UserPermissionState>(StringComparer.OrdinalIgnoreCase)
{
[permissionKey] = state
},
cancellationToken);
}
public async Task SetOverridesAsync(
string userId,
IReadOnlyDictionary<string, UserPermissionState> overrides,
CancellationToken cancellationToken)
{
foreach (var pair in overrides)
{
var existing = await _context.UserPermissionOverrides
.SingleOrDefaultAsync(
permission => permission.UserId == userId
&& permission.PermissionKey == pair.Key,
cancellationToken);
if (existing is null)
{
await _context.UserPermissionOverrides.AddAsync(
new UserPermissionOverride
{
UserId = userId,
PermissionKey = pair.Key,
State = pair.Value
},
cancellationToken);
}
else
{
existing.State = pair.Value;
}
}
await _context.SaveChangesAsync(cancellationToken);
}
public async Task ClearOverridesAsync(string userId, CancellationToken cancellationToken)
{
await _context.UserPermissionOverrides
.Where(item => item.UserId == userId)
.ExecuteDeleteAsync(cancellationToken);
}
}

View file

@ -42,11 +42,22 @@ namespace SeaHaven.DataServices.Implementation
.Select(user => new UserListRowData
{
Id = user.Id,
RoleName = user.PhoneNumber,
RoleName = (
from userRole in _context.UserRoles
join role in _context.Roles on userRole.RoleId equals role.Id
where userRole.UserId == user.Id
select role.Name).FirstOrDefault() ?? user.PhoneNumber,
Email = user.Email,
Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim(),
CreatedDate = user.CreatedDate,
Status = user.UniqueName
Status = user.UniqueName,
Phone = user.Contact ?? user.PhoneNumber,
Color = user.Color,
PendingRegistration = user.PendingRegistration,
ServiceAreas = user.ServiceAreas
.OrderBy(area => area.Area)
.Select(area => area.Area)
.ToList()
})
.ToListAsync(cancellationToken);
}
@ -93,6 +104,15 @@ namespace SeaHaven.DataServices.Implementation
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<bool> IsAccountOwnerAsync(string userId, CancellationToken cancellationToken)
{
return await _context.Users
.AsNoTracking()
.Where(user => user.Id == userId)
.Select(user => user.IsAccountOwner)
.SingleOrDefaultAsync(cancellationToken);
}
public async Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken)
{
var normalizedEmail = email.Trim().ToUpperInvariant();

View file

@ -0,0 +1,46 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation;
public sealed class UserServiceAreaDataService : IUserServiceAreaDataService
{
private readonly ApplicationDbContext _context;
public UserServiceAreaDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task ReplaceAsync(
string userId,
IReadOnlyCollection<string> areas,
CancellationToken cancellationToken)
{
var existing = await _context.UserServiceAreas
.Where(area => area.UserId == userId)
.ToListAsync(cancellationToken);
if (existing.Count > 0)
_context.UserServiceAreas.RemoveRange(existing);
await _context.UserServiceAreas.AddRangeAsync(
areas.Select(area => new UserServiceArea { UserId = userId, Area = area }),
cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<IReadOnlyList<string>> GetForUserAsync(
string userId,
CancellationToken cancellationToken)
{
return await _context.UserServiceAreas
.AsNoTracking()
.Where(area => area.UserId == userId)
.OrderBy(area => area.Area)
.Select(area => area.Area)
.ToListAsync(cancellationToken);
}
}

View file

@ -0,0 +1,6 @@
namespace SeaHaven.DataServices.Interfaces;
public interface IAccountOwnerDataService
{
Task<bool> EnsureConfiguredOwnerAsync(string? configuredUserId, CancellationToken cancellationToken);
}

View file

@ -0,0 +1,21 @@
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);
Task SetOverridesAsync(
string userId,
IReadOnlyDictionary<string, UserPermissionState> overrides,
CancellationToken cancellationToken);
Task ClearOverridesAsync(string userId, CancellationToken cancellationToken);
}

View file

@ -14,6 +14,7 @@ namespace SeaHaven.DataServices.Interfaces
Task<UserProfileData?> GetProfileAsync(string id, CancellationToken cancellationToken);
Task<bool> UpdateProfileAsync(string id, string? name, string? email, string? contact, CancellationToken cancellationToken);
Task<ApplicationUser?> GetForEditAsync(string id, CancellationToken cancellationToken);
Task<bool> IsAccountOwnerAsync(string userId, CancellationToken cancellationToken);
Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken);
Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken);
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);

View file

@ -0,0 +1,15 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces;
public interface IUserServiceAreaDataService
{
Task ReplaceAsync(
string userId,
IReadOnlyCollection<string> areas,
CancellationToken cancellationToken);
Task<IReadOnlyList<string>> GetForUserAsync(
string userId,
CancellationToken cancellationToken);
}

View 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();
}
}

View file

@ -0,0 +1,150 @@
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;
}
public Task SetOverridesAsync(
string userId,
IReadOnlyDictionary<string, UserPermissionState> overrides,
CancellationToken cancellationToken)
{
foreach (var (permissionKey, state) in overrides)
{
_overrides[permissionKey] = state;
LastSet = (userId, permissionKey, state);
}
return Task.CompletedTask;
}
public Task ClearOverridesAsync(string userId, CancellationToken cancellationToken)
{
_overrides.Clear();
return Task.CompletedTask;
}
}
}

View file

@ -0,0 +1,8 @@
namespace SeaHaven.Services.Configuration;
public sealed class AccountOwnerOptions
{
public const string SectionName = "TeamMembers:AccountOwner";
public string? UserId { get; set; }
}

View file

@ -0,0 +1,31 @@
namespace SeaHaven.Services.Constants;
public static class TeamMemberConstants
{
public static readonly IReadOnlyList<string> Roles =
new[] { "Dispatcher", "Scheduler", "Admin" };
public static readonly IReadOnlyList<string> ServiceAreas =
new[] { "East", "Central", "West", "California" };
public static readonly IReadOnlySet<string> Colors = new HashSet<string>(
new[]
{
"#F59E0B", "#0D9488", "#6366F1", "#F43F5E", "#7C3AED",
"#0EA5E9", "#16A34A", "#EA580C", "#0891B2", "#C026D3"
},
StringComparer.OrdinalIgnoreCase);
public static string? CanonicalRole(string? value) => Canonical(value, Roles);
public static string? CanonicalArea(string? value) => Canonical(value, ServiceAreas);
private static string? Canonical(string? value, IReadOnlyList<string> allowed)
{
if (string.IsNullOrWhiteSpace(value))
return null;
return allowed.FirstOrDefault(item =>
string.Equals(item, value.Trim(), StringComparison.OrdinalIgnoreCase));
}
}

View 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);
}
}

View file

@ -57,6 +57,10 @@ namespace SeaHaven.Services.DTOs
public string Date { get; set; } = string.Empty;
public string? Status { get; set; }
public string? Id { get; set; }
public string? Phone { get; set; }
public string? Color { get; set; }
public bool? PendingRegistration { get; set; }
public IReadOnlyList<string> ServiceAreas { get; set; } = Array.Empty<string>();
}
public class UserProfileRowDTO

View file

@ -0,0 +1,59 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.DTOs;
public sealed record CreateTeamMemberRequestDTO
{
public string? Name { get; init; }
public string? Role { get; init; }
public string? Color { get; init; }
public string? Email { get; init; }
public string? Phone { get; init; }
public IReadOnlyList<string>? ServiceAreas { get; init; }
public IReadOnlyDictionary<string, UserPermissionState>? PermissionOverrides { get; init; }
}
public class TeamMemberCreatedDTO
{
public required string Id { get; init; }
public required string Name { get; init; }
public required string Role { get; init; }
public required string Color { get; init; }
public string? Email { get; init; }
public string? Phone { get; init; }
public required IReadOnlyList<string> ServiceAreas { get; init; }
public bool PendingRegistration { get; init; }
}
public sealed class TeamMemberDetailDTO : TeamMemberCreatedDTO
{
public string Status { get; init; } = "Active";
public bool IsAccountOwner { get; init; }
public IReadOnlyList<TeamPermissionValueDTO> Permissions { get; init; } = Array.Empty<TeamPermissionValueDTO>();
}
public sealed record UpdateTeamMemberRequestDTO
{
public string? Name { get; init; }
public string? Role { get; init; }
public string? Color { get; init; }
public string? Email { get; init; }
public string? Phone { get; init; }
public IReadOnlyList<string>? ServiceAreas { get; init; }
public IReadOnlyDictionary<string, UserPermissionState>? PermissionOverrides { get; init; }
public bool IsActive { get; init; } = true;
}
public sealed class TeamMemberOperationOutcomeDTO
{
public bool Success { get; init; }
public string? Error { get; init; }
public TeamMemberDetailDTO? Member { get; init; }
}
public sealed class CreateTeamMemberOutcomeDTO
{
public bool Success { get; init; }
public string? Error { get; init; }
public TeamMemberCreatedDTO? Member { get; init; }
}

View 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);
}

View file

@ -18,6 +18,7 @@ namespace SeaHaven.Services.DependencyInjection
services.Configure<ApprovalsOptions>(configuration.GetSection(ApprovalsOptions.SectionName));
services.Configure<VendorPortalOptions>(configuration.GetSection(VendorPortalOptions.SectionName));
services.Configure<VendorDocumentsOptions>(configuration.GetSection(VendorDocumentsOptions.SectionName));
services.Configure<AccountOwnerOptions>(configuration.GetSection(AccountOwnerOptions.SectionName));
services.AddOptions<WorkOrderWebhookOptions>()
.Bind(configuration.GetSection(WorkOrderWebhookOptions.SectionName))

View file

@ -0,0 +1,23 @@
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation;
public sealed class AccountOwnerDesignationService : IAccountOwnerDesignationService
{
private readonly IAccountOwnerDataService _dataService;
private readonly IOptions<AccountOwnerOptions> _options;
public AccountOwnerDesignationService(
IAccountOwnerDataService dataService,
IOptions<AccountOwnerOptions> options)
{
_dataService = dataService;
_options = options;
}
public Task EnsureConfiguredOwnerAsync(CancellationToken cancellationToken) =>
_dataService.EnsureConfiguredOwnerAsync(_options.Value.UserId, cancellationToken);
}

View file

@ -0,0 +1,342 @@
using System.Net.Mail;
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Identity;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation;
public sealed class TeamMemberService : ITeamMemberService
{
private const string ActiveStatus = "Active";
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole> _roleManager;
private readonly IUserServiceAreaDataService _areaDataService;
private readonly ITeamPermissionOverrideDataService _permissionDataService;
private readonly IUserDataService _userDataService;
private readonly ITeamPermissionService _permissionService;
public TeamMemberService(
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
IUserServiceAreaDataService areaDataService,
ITeamPermissionOverrideDataService permissionDataService,
IUserDataService userDataService,
ITeamPermissionService permissionService)
{
_userManager = userManager;
_roleManager = roleManager;
_areaDataService = areaDataService;
_permissionDataService = permissionDataService;
_userDataService = userDataService;
_permissionService = permissionService;
}
public async Task<CreateTeamMemberOutcomeDTO> CreateAsync(
CreateTeamMemberRequestDTO request,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
if (caller?.IsInRole("Admin") != true)
return Failure("Forbidden");
var validation = Validate(request, out var role, out var color, out var email, out var areas, out var overrides);
if (validation is not null)
return Failure(validation);
if (await _userManager.FindByEmailAsync(email!) is not null)
return Failure("Email is already in use.");
var roleError = await EnsureRoleAsync(role!);
if (roleError is not null)
return Failure(roleError);
var now = DateTime.UtcNow;
var user = new ApplicationUser
{
UserName = email,
Email = email,
EmailConfirmed = false,
FirstName = request.Name!.Trim(),
Contact = request.Phone?.Trim(),
PhoneNumber = request.Phone?.Trim(),
Color = color,
UniqueName = ActiveStatus,
CreatedDate = now,
PendingRegistration = true,
PendingRegistrationCreatedDate = now
};
var createResult = await _userManager.CreateAsync(user);
if (!createResult.Succeeded)
return Failure(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member.");
var addRoleResult = await _userManager.AddToRoleAsync(user, role!);
if (!addRoleResult.Succeeded)
{
await _userManager.DeleteAsync(user);
return Failure(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role.");
}
try
{
await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken);
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken);
}
catch
{
await _userManager.DeleteAsync(user);
throw;
}
return new CreateTeamMemberOutcomeDTO
{
Success = true,
Member = new TeamMemberCreatedDTO
{
Id = user.Id,
Name = user.FirstName!,
Role = role!,
Color = color!,
Email = user.Email,
Phone = user.PhoneNumber,
ServiceAreas = areas!,
PendingRegistration = true
}
};
}
public async Task<TeamMemberOperationOutcomeDTO> GetAsync(
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
if (caller?.IsInRole("Admin") != true)
return OperationFailure("Forbidden");
var user = await _userManager.FindByIdAsync(userId);
if (user is null)
return OperationFailure("Team member not found.");
return await OperationSuccessAsync(user, caller, cancellationToken);
}
public async Task<TeamMemberOperationOutcomeDTO> UpdateAsync(
string userId,
UpdateTeamMemberRequestDTO request,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
if (caller?.IsInRole("Admin") != true)
return OperationFailure("Forbidden");
var user = await _userManager.FindByIdAsync(userId);
if (user is null)
return OperationFailure("Team member not found.");
var existingRoles = await _userManager.GetRolesAsync(user);
var existingRole = existingRoles.FirstOrDefault();
var candidate = new CreateTeamMemberRequestDTO
{
Name = request.Name,
Role = request.Role,
Color = request.Color,
Email = request.Email,
Phone = request.Phone,
ServiceAreas = request.ServiceAreas,
PermissionOverrides = request.PermissionOverrides
};
var validation = Validate(candidate, out var role, out var color, out var email, out var areas, out var overrides);
if (validation is not null)
return OperationFailure(validation);
var roleChanged = !string.Equals(existingRole, role, StringComparison.OrdinalIgnoreCase);
var isOwner = await _userDataService.IsAccountOwnerAsync(user.Id, cancellationToken);
if (isOwner && (roleChanged || !request.IsActive))
return OperationFailure("Forbidden");
var duplicate = await _userManager.FindByEmailAsync(email!);
if (duplicate is not null && !string.Equals(duplicate.Id, user.Id, StringComparison.Ordinal))
return OperationFailure("Email is already in use.");
var roleError = await EnsureRoleAsync(role!);
if (roleError is not null)
return OperationFailure(roleError);
user.FirstName = candidate.Name!.Trim();
user.Email = email;
user.UserName = email;
user.NormalizedEmail = email!.ToUpperInvariant();
user.NormalizedUserName = email.ToUpperInvariant();
user.Contact = candidate.Phone?.Trim();
user.PhoneNumber = candidate.Phone?.Trim();
user.Color = color;
user.UniqueName = request.IsActive ? ActiveStatus : "Inactive";
user.IsDeleted = !request.IsActive;
if (roleChanged)
{
if (existingRoles.Count > 0)
await _userManager.RemoveFromRolesAsync(user, existingRoles);
await _userManager.AddToRoleAsync(user, role!);
}
await _userDataService.UpdateUserAsync(user, cancellationToken);
await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken);
await _permissionDataService.ClearOverridesAsync(user.Id, cancellationToken);
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken);
return await OperationSuccessAsync(user, caller, cancellationToken);
}
private async Task<string?> EnsureRoleAsync(string role)
{
if (await _roleManager.RoleExistsAsync(role))
return null;
var roleResult = await _roleManager.CreateAsync(new IdentityRole(role));
return roleResult.Succeeded
? null
: roleResult.Errors.FirstOrDefault()?.Description ?? "Unable to create role.";
}
private async Task<TeamMemberOperationOutcomeDTO> OperationSuccessAsync(
ApplicationUser user,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
var roles = await _userManager.GetRolesAsync(user);
var profile = await _permissionService.GetProfileAsync(user.Id, caller, cancellationToken);
var areas = await _areaDataService.GetForUserAsync(user.Id, cancellationToken);
return new TeamMemberOperationOutcomeDTO
{
Success = true,
Member = new TeamMemberDetailDTO
{
Id = user.Id,
Name = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim(),
Role = TeamMemberConstants.CanonicalRole(roles.FirstOrDefault()) ?? roles.FirstOrDefault() ?? "",
Color = user.Color ?? "",
Email = user.Email,
Phone = user.Contact ?? user.PhoneNumber,
ServiceAreas = areas,
PendingRegistration = user.PendingRegistration == true,
Status = user.UniqueName ?? "Active",
IsAccountOwner = user.IsAccountOwner,
Permissions = profile?.Value?.Permissions ?? Array.Empty<TeamPermissionValueDTO>()
}
};
}
private static string? Validate(
CreateTeamMemberRequestDTO request,
out string? role,
out string? color,
out string? email,
out IReadOnlyList<string>? areas,
out IReadOnlyDictionary<string, UserPermissionState>? overrides)
{
role = TeamMemberConstants.CanonicalRole(request.Role);
color = request.Color?.Trim();
email = request.Email?.Trim();
areas = NormalizeAreas(request.ServiceAreas, out var invalidArea);
overrides = NormalizeOverrides(request.PermissionOverrides, out var invalidPermission);
if (string.IsNullOrWhiteSpace(request.Name))
return "Name is required.";
if (role is null)
return "Role must be Dispatcher, Scheduler, or Admin.";
if (color is null || !TeamMemberConstants.Colors.Contains(color))
return "Color must be selected from the accessible palette.";
if (!IsValidEmail(email))
return "A valid email is required.";
if (invalidArea is not null)
return $"Unknown service area: {invalidArea}.";
if (string.Equals(role, "Dispatcher", StringComparison.Ordinal)
&& (areas is null || areas.Count == 0))
return "At least one service area is required for a Dispatcher.";
if (!string.Equals(role, "Dispatcher", StringComparison.Ordinal)
&& areas is { Count: > 0 })
return "Service areas are only available for Dispatchers.";
if (invalidPermission is not null)
return $"Unknown permission key: {invalidPermission}.";
return null;
}
private static IReadOnlyList<string> NormalizeAreas(
IReadOnlyList<string>? values,
out string? invalidArea)
{
invalidArea = null;
var normalized = new List<string>();
foreach (var value in values ?? Array.Empty<string>())
{
var canonical = TeamMemberConstants.CanonicalArea(value);
if (canonical is null)
{
invalidArea = value;
return Array.Empty<string>();
}
if (!normalized.Contains(canonical, StringComparer.Ordinal))
normalized.Add(canonical);
}
return normalized;
}
private static IReadOnlyDictionary<string, UserPermissionState> NormalizeOverrides(
IReadOnlyDictionary<string, UserPermissionState>? values,
out string? invalidPermission)
{
invalidPermission = null;
var normalized = new Dictionary<string, UserPermissionState>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in values ?? new Dictionary<string, UserPermissionState>())
{
var canonical = TeamPermissionKeys.All.FirstOrDefault(key =>
string.Equals(key, pair.Key, StringComparison.OrdinalIgnoreCase));
if (canonical is null)
{
invalidPermission = pair.Key;
return new Dictionary<string, UserPermissionState>();
}
if (!Enum.IsDefined(pair.Value))
{
invalidPermission = pair.Key;
return new Dictionary<string, UserPermissionState>();
}
normalized[canonical] = pair.Value;
}
return normalized;
}
private static bool IsValidEmail(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
try
{
var address = new MailAddress(value);
return string.Equals(address.Address, value, StringComparison.OrdinalIgnoreCase);
}
catch (FormatException)
{
return false;
}
}
private static CreateTeamMemberOutcomeDTO Failure(string error) =>
new() { Success = false, Error = error };
private static TeamMemberOperationOutcomeDTO OperationFailure(string error) =>
new() { Success = false, Error = error };
}

View 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;
}
}
}

View 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;
}

View file

@ -38,7 +38,11 @@ namespace SeaHaven.Services.Implementation
Name = s.Name,
Date = s.CreatedDate != null ? s.CreatedDate.Value.Date.ToString("MMM dd yyyy") : "",
Status = s.Status,
Id = s.Id
Id = s.Id,
Phone = s.Phone,
Color = s.Color,
PendingRegistration = s.PendingRegistration,
ServiceAreas = s.ServiceAreas
});
}
@ -136,6 +140,9 @@ namespace SeaHaven.Services.Implementation
if (exist == null)
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound };
if (await _userDataService.IsAccountOwnerAsync(exist.Id, cancellationToken))
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden };
exist.FirstName = dto.Name;
if (string.IsNullOrWhiteSpace(dto.Email))
throw new ArgumentException("Email is required.", nameof(dto));
@ -174,6 +181,9 @@ namespace SeaHaven.Services.Implementation
if (data == null)
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound };
if (await _userDataService.IsAccountOwnerAsync(data.Id, cancellationToken))
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden };
await _userDataService.DeleteUserWithCascadeAsync(data, cancellationToken);
return new AddUserOutcomeDTO { Success = true };
}
@ -181,7 +191,7 @@ namespace SeaHaven.Services.Implementation
public async Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken)
{
var exist = await _userDataService.GetForEditAsync(userId, cancellationToken);
if (exist != null)
if (exist != null && !await _userDataService.IsAccountOwnerAsync(exist.Id, cancellationToken))
{
exist.IsDeleted = true;
await _userDataService.UpdateUserAsync(exist, cancellationToken);

View file

@ -0,0 +1,6 @@
namespace SeaHaven.Services.Interfaces;
public interface IAccountOwnerDesignationService
{
Task EnsureConfiguredOwnerAsync(CancellationToken cancellationToken);
}

View file

@ -0,0 +1,21 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces;
public interface ITeamMemberService
{
Task<CreateTeamMemberOutcomeDTO> CreateAsync(
CreateTeamMemberRequestDTO request,
ClaimsPrincipal caller,
CancellationToken cancellationToken);
Task<TeamMemberOperationOutcomeDTO> GetAsync(
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken);
Task<TeamMemberOperationOutcomeDTO> UpdateAsync(
string userId,
UpdateTeamMemberRequestDTO request,
ClaimsPrincipal caller,
CancellationToken cancellationToken);
}

View 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);
}
}

View 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);
}

View file

@ -208,7 +208,7 @@ public class WorkOrderMediaConcurrencyRelationalTests
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
{
if (index.GetFilter() != null)
if (index.GetFilter() != null && index.GetDatabaseName() != "IX_AspNetUsers_IsAccountOwner")
index.SetFilter(null);
}