mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-05 07:12:10 +00:00
feat(team-members): support pending member creation (SH-325)
This commit is contained in:
parent
ca4d4833ff
commit
fc5c7d5ca4
20 changed files with 4813 additions and 21 deletions
153
Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs
Normal file
153
Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
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 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>());
|
||||||
|
|
||||||
|
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 _);
|
||||||
|
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeamMemberService NewService(
|
||||||
|
out Mock<UserManager<ApplicationUser>> userManager,
|
||||||
|
out Mock<RoleManager<IdentityRole>> roleManager,
|
||||||
|
out Mock<IUserServiceAreaDataService> areas,
|
||||||
|
out Mock<ITeamPermissionOverrideDataService> overrides)
|
||||||
|
{
|
||||||
|
userManager = UserManager();
|
||||||
|
roleManager = RoleManager();
|
||||||
|
areas = new Mock<IUserServiceAreaDataService>();
|
||||||
|
overrides = new Mock<ITeamPermissionOverrideDataService>();
|
||||||
|
return new TeamMemberService(userManager.Object, roleManager.Object, areas.Object, overrides.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"));
|
||||||
|
}
|
||||||
36
Api.SeaHavenIndustries/Controllers/TeamMemberController.cs
Normal file
36
Api.SeaHavenIndustries/Controllers/TeamMemberController.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -257,6 +257,26 @@ namespace Data.SeaHavenIndustries
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey");
|
.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<Category> Categories { get; set; }
|
||||||
public DbSet<Locations> Locations { get; set; }
|
public DbSet<Locations> Locations { get; set; }
|
||||||
|
|
@ -312,6 +332,7 @@ namespace Data.SeaHavenIndustries
|
||||||
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 DbSet<UserPermissionOverride> UserPermissionOverrides { get; set; }
|
||||||
|
public DbSet<UserServiceArea> UserServiceAreas { get; set; }
|
||||||
|
|
||||||
public override int SaveChanges()
|
public override int SaveChanges()
|
||||||
{
|
{
|
||||||
|
|
@ -392,12 +413,15 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? Color { get; set; }
|
public string? Color { get; set; }
|
||||||
public int? Type { get; set; } // 1 for users 0 for admin
|
public int? Type { get; set; } // 1 for users 0 for admin
|
||||||
public bool IsAccountOwner { get; set; }
|
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>
|
/// <summary>Optional CRM account membership for server-derived media scope (SH-221).</summary>
|
||||||
public int? AccountId { get; set; }
|
public int? AccountId { get; set; }
|
||||||
[ForeignKey(nameof(AccountId))]
|
[ForeignKey(nameof(AccountId))]
|
||||||
public virtual Accounts? Account { get; set; }
|
public virtual Accounts? Account { get; set; }
|
||||||
public ICollection<Template>? Templates { get; set; }
|
public ICollection<Template>? Templates { get; set; }
|
||||||
public ICollection<WorkOrder>? WorkOrders { get; set; }
|
public ICollection<WorkOrder>? WorkOrders { get; set; }
|
||||||
|
public ICollection<UserServiceArea> ServiceAreas { get; set; } = new List<UserServiceArea>();
|
||||||
}
|
}
|
||||||
public class FullAuditEntity
|
public class FullAuditEntity
|
||||||
{
|
{
|
||||||
|
|
|
||||||
9
Data.SeaHavenIndustries/Auth/UserServiceArea.cs
Normal file
9
Data.SeaHavenIndustries/Auth/UserServiceArea.cs
Normal 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!;
|
||||||
|
}
|
||||||
4056
Data.SeaHavenIndustries/Migrations/20260916220437_SH325_PendingTeamMember.Designer.cs
generated
Normal file
4056
Data.SeaHavenIndustries/Migrations/20260916220437_SH325_PendingTeamMember.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -249,6 +249,12 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Property<string>("PasswordHash")
|
b.Property<string>("PasswordHash")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool?>("PendingRegistration")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PendingRegistrationCreatedDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<string>("PhoneNumber")
|
b.Property<string>("PhoneNumber")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
|
@ -2102,6 +2108,25 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.ToTable("UserPermissionOverrides");
|
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 =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -3657,6 +3682,17 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Navigation("User");
|
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 =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company")
|
b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company")
|
||||||
|
|
@ -3901,6 +3937,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("ServiceAreas");
|
||||||
|
|
||||||
b.Navigation("Templates");
|
b.Navigation("Templates");
|
||||||
|
|
||||||
b.Navigation("WorkOrders");
|
b.Navigation("WorkOrders");
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ namespace SeaHaven.DataServices.Dto
|
||||||
public string? Name { get; init; }
|
public string? Name { get; init; }
|
||||||
public DateTime? CreatedDate { get; init; }
|
public DateTime? CreatedDate { get; init; }
|
||||||
public string? Status { 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
|
public sealed class UserProfileData
|
||||||
|
|
|
||||||
|
|
@ -57,26 +57,43 @@ public sealed class TeamPermissionOverrideDataService : ITeamPermissionOverrideD
|
||||||
UserPermissionState state,
|
UserPermissionState state,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var existing = await _context.UserPermissionOverrides
|
await SetOverridesAsync(
|
||||||
.SingleOrDefaultAsync(
|
userId,
|
||||||
permission => permission.UserId == userId
|
new Dictionary<string, UserPermissionState>(StringComparer.OrdinalIgnoreCase)
|
||||||
&& permission.PermissionKey == permissionKey,
|
{
|
||||||
cancellationToken);
|
[permissionKey] = state
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
if (existing is null)
|
public async Task SetOverridesAsync(
|
||||||
|
string userId,
|
||||||
|
IReadOnlyDictionary<string, UserPermissionState> overrides,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var pair in overrides)
|
||||||
{
|
{
|
||||||
await _context.UserPermissionOverrides.AddAsync(
|
var existing = await _context.UserPermissionOverrides
|
||||||
new UserPermissionOverride
|
.SingleOrDefaultAsync(
|
||||||
{
|
permission => permission.UserId == userId
|
||||||
UserId = userId,
|
&& permission.PermissionKey == pair.Key,
|
||||||
PermissionKey = permissionKey,
|
cancellationToken);
|
||||||
State = state
|
|
||||||
},
|
if (existing is null)
|
||||||
cancellationToken);
|
{
|
||||||
}
|
await _context.UserPermissionOverrides.AddAsync(
|
||||||
else
|
new UserPermissionOverride
|
||||||
{
|
{
|
||||||
existing.State = state;
|
UserId = userId,
|
||||||
|
PermissionKey = pair.Key,
|
||||||
|
State = pair.Value
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.State = pair.Value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,22 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.Select(user => new UserListRowData
|
.Select(user => new UserListRowData
|
||||||
{
|
{
|
||||||
Id = user.Id,
|
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,
|
Email = user.Email,
|
||||||
Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim(),
|
Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim(),
|
||||||
CreatedDate = user.CreatedDate,
|
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);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,4 +11,9 @@ public interface ITeamPermissionOverrideDataService
|
||||||
string permissionKey,
|
string permissionKey,
|
||||||
UserPermissionState state,
|
UserPermissionState state,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task SetOverridesAsync(
|
||||||
|
string userId,
|
||||||
|
IReadOnlyDictionary<string, UserPermissionState> overrides,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -126,5 +126,19 @@ public sealed class TeamPermissionServiceTests
|
||||||
LastSet = (userId, permissionKey, state);
|
LastSet = (userId, permissionKey, state);
|
||||||
return Task.CompletedTask;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
SeaHaven.Services/Constants/TeamMemberConstants.cs
Normal file
31
SeaHaven.Services/Constants/TeamMemberConstants.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -57,6 +57,10 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string Date { get; set; } = string.Empty;
|
public string Date { get; set; } = string.Empty;
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
public string? Id { 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
|
public class UserProfileRowDTO
|
||||||
|
|
|
||||||
33
SeaHaven.Services/DTOs/TeamMemberDTOs.cs
Normal file
33
SeaHaven.Services/DTOs/TeamMemberDTOs.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
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 sealed 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 CreateTeamMemberOutcomeDTO
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
public string? Error { get; init; }
|
||||||
|
public TeamMemberCreatedDTO? Member { get; init; }
|
||||||
|
}
|
||||||
214
SeaHaven.Services/Implementation/TeamMemberService.cs
Normal file
214
SeaHaven.Services/Implementation/TeamMemberService.cs
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
public TeamMemberService(
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
RoleManager<IdentityRole> roleManager,
|
||||||
|
IUserServiceAreaDataService areaDataService,
|
||||||
|
ITeamPermissionOverrideDataService permissionDataService)
|
||||||
|
{
|
||||||
|
_userManager = userManager;
|
||||||
|
_roleManager = roleManager;
|
||||||
|
_areaDataService = areaDataService;
|
||||||
|
_permissionDataService = permissionDataService;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.");
|
||||||
|
|
||||||
|
if (!await _roleManager.RoleExistsAsync(role!))
|
||||||
|
{
|
||||||
|
var roleResult = await _roleManager.CreateAsync(new IdentityRole(role!));
|
||||||
|
if (!roleResult.Succeeded)
|
||||||
|
return Failure(roleResult.Errors.FirstOrDefault()?.Description ?? "Unable to create role.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -38,7 +38,11 @@ namespace SeaHaven.Services.Implementation
|
||||||
Name = s.Name,
|
Name = s.Name,
|
||||||
Date = s.CreatedDate != null ? s.CreatedDate.Value.Date.ToString("MMM dd yyyy") : "",
|
Date = s.CreatedDate != null ? s.CreatedDate.Value.Date.ToString("MMM dd yyyy") : "",
|
||||||
Status = s.Status,
|
Status = s.Status,
|
||||||
Id = s.Id
|
Id = s.Id,
|
||||||
|
Phone = s.Phone,
|
||||||
|
Color = s.Color,
|
||||||
|
PendingRegistration = s.PendingRegistration,
|
||||||
|
ServiceAreas = s.ServiceAreas
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
12
SeaHaven.Services/Interfaces/ITeamMemberService.cs
Normal file
12
SeaHaven.Services/Interfaces/ITeamMemberService.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
public interface ITeamMemberService
|
||||||
|
{
|
||||||
|
Task<CreateTeamMemberOutcomeDTO> CreateAsync(
|
||||||
|
CreateTeamMemberRequestDTO request,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue