Refactored organisation entities and endpoints
This commit is contained in:
@@ -19,6 +19,7 @@ public sealed class CreateOrganisationEndpoint : IEndpoint
|
||||
await service.CreateOrganisationAsync(request, context.GetUserId(), cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true } => Results.Ok(),
|
||||
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class GetTenantEndpoint : IEndpoint
|
||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
.WithDescription("Gets system tenants")
|
||||
.WithDescription("Gets a single tenant")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetTenantsEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using PostFundManagement.Application.Abstractions.Services;
|
||||
using PostFundManagement.Application.Extensions;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Entities;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Domain.Security;
|
||||
using StackExchange.Redis;
|
||||
using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Governance;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public sealed class GetTenantOrganisationsEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/governance/tenants/{tenantIdHash}/organisations", async (IGovernanceService service, string tenantIdHash,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await service.GetTenantOrganisationsAsync(tenantIdHash, cancellationToken) switch
|
||||
{
|
||||
{ IsSuccess: true, Value: var tenantOrganisations } => Results.Ok(tenantOrganisations),
|
||||
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
|
||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||
})
|
||||
.WithDescription("Gets organisations created and managed by one tenant")
|
||||
.RequireAuthorization(Constants.MainScope)
|
||||
.WithName(typeof(GetTenantOrganisationsEndpoint).ToEndpointName("Retrieval"))
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<TenantOrganisation[]>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Governance);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ public interface IGovernanceService
|
||||
Task<Result> UpdateTenantOrganisationAsync(string tenantOrganisationIdHash, ActivityStatus status, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<TenantOrganisation[]>> GetTenantOrganisationsAsync(CancellationToken cancellationToken = default);
|
||||
Task<Result<TenantOrganisation[]>> GetTenantOrganisationsAsync(string tenantIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<TenantOrganisation>> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -266,19 +266,19 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
|
||||
return Result.Ok(cached.FromJson<TenantOrganisation>());
|
||||
}));
|
||||
|
||||
public async Task<Result<TenantOrganisation[]>> GetTenantOrganisationsAsync(CancellationToken cancellationToken = default) =>
|
||||
public async Task<Result<TenantOrganisation[]>> GetTenantOrganisationsAsync(string tenantIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<TenantOrganisation[]>>>)(async () =>
|
||||
{
|
||||
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
|
||||
return await service.GetTenantOrganisationsAsync(cancellationToken);
|
||||
return await service.GetTenantOrganisationsAsync(tenantIdHash, cancellationToken);
|
||||
|
||||
var cacheKey = HashService.ToMd5Hash($"gettenantorganisations").Value;
|
||||
var cacheKey = HashService.ToMd5Hash($"gettenantorganisations-{tenantIdHash}").Value;
|
||||
|
||||
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(cached))
|
||||
{
|
||||
var result = await service.GetTenantOrganisationsAsync(cancellationToken);
|
||||
var result = await service.GetTenantOrganisationsAsync(tenantIdHash, cancellationToken);
|
||||
|
||||
if (result.IsFailed) return result;
|
||||
|
||||
|
||||
@@ -138,8 +138,14 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
||||
|
||||
var registrationNo = request.RegistrationNo.Clean();
|
||||
|
||||
if(!await context.Users.AnyAsync(u => u.Id == request.OrganisationUser, cancellationToken))
|
||||
return Result.Fail(NotFoundError.Create("Organisation user not found"));
|
||||
|
||||
if(!await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
|
||||
return Result.Fail(ValidationError.Create("Organisation user does not belong to the current tenant"));
|
||||
|
||||
if (await context.Organisations.AnyAsync(o => o.RegistrationNo == registrationNo, cancellationToken))
|
||||
return Result.Fail(CreateError.Create("Organisation with the same registration number already exists"));
|
||||
return Result.Fail(ValidationError.Create("Organisation with the same registration number already exists"));
|
||||
|
||||
var organisation = context.Organisations.Add(new Domain.Entities.Organisation
|
||||
{
|
||||
@@ -149,9 +155,17 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
||||
Type = request.Type,
|
||||
Status = ActivityStatus.Active,
|
||||
CreatedBy = userId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UserId = request.OrganisationUser
|
||||
});
|
||||
|
||||
if(!string.IsNullOrWhiteSpace(request.IdNumber))
|
||||
{
|
||||
var linkedUser = await context.Users.FirstOrDefaultAsync(u => u.Id == request.OrganisationUser, cancellationToken);
|
||||
|
||||
linkedUser!.IdNumber = request.IdNumber.Clean();
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var mapResult = await CreateTenantOrganisationAsync(hashService.HashEncodeLongId(organisation.Entity.Id).Value,
|
||||
@@ -165,6 +179,12 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
if (!await context.Users.AnyAsync(u => u.Id == request.OrganisationUser, cancellationToken))
|
||||
return Result.Fail(ValidationError.Create("Organisation user not found"));
|
||||
|
||||
if (!await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
|
||||
return Result.Fail(ValidationError.Create("Organisation user does not belong to the current tenant"));
|
||||
|
||||
var changedTotal = await context.Organisations
|
||||
.Where(f => f.Id == request.OrganisationId)
|
||||
.ExecuteUpdateAsync(f => f
|
||||
@@ -181,7 +201,16 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
||||
|
||||
var updateResult = await UpdateTenantOrganisationAsync(hashService.HashEncodeLongId(tenantOrganisation.Id).Value, request.Status, userId, cancellationToken);
|
||||
|
||||
return Result.OkIf(changedTotal > 0 && updateResult.IsSuccess, UpdateError.Create("Failed to update organisation"));
|
||||
if (!string.IsNullOrWhiteSpace(request.IdNumber))
|
||||
{
|
||||
var linkedUser = await context.Users.FirstOrDefaultAsync(u => u.Id == request.OrganisationUser, cancellationToken);
|
||||
|
||||
linkedUser!.IdNumber = request.IdNumber.Clean();
|
||||
}
|
||||
|
||||
var userUpdated = await context.SaveChangesAsync(cancellationToken) > 0;
|
||||
|
||||
return Result.OkIf(changedTotal > 0 && updateResult.IsSuccess && userUpdated, UpdateError.Create("Failed to update organisation"));
|
||||
}));
|
||||
|
||||
public async Task<Result> CreatePortfolioAsync(CreatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
@@ -403,15 +432,21 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
|
||||
return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update tenant organisation map"));
|
||||
}));
|
||||
|
||||
public async Task<Result<TenantOrganisation[]>> GetTenantOrganisationsAsync(CancellationToken cancellationToken = default) =>
|
||||
public async Task<Result<TenantOrganisation[]>> GetTenantOrganisationsAsync(string tenantIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<TenantOrganisation[]>>>)(async () =>
|
||||
{
|
||||
var decodeResult = hashService.DecodeLongIdHash(tenantIdHash);
|
||||
|
||||
if (decodeResult.IsFailed)
|
||||
return Result.Fail(IdDecodeError.Create()
|
||||
.CausedBy(decodeResult.Errors));
|
||||
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var tenantOrganisations = await context.TenantOrganisations.AsNoTracking()
|
||||
.Include(i => i.Organisation)
|
||||
.Include(i => i.Tenant)
|
||||
.Where(f => f.TenantId == tenantContext.TenantId)
|
||||
.Where(f => f.TenantId == decodeResult.Value)
|
||||
.ToListAsync();
|
||||
|
||||
return Result.Ok(tenantOrganisations?.Select(i => i.Map()).ToArray() ?? []);
|
||||
|
||||
@@ -38,12 +38,16 @@ public sealed record UpdateOrganisationRequest([Required] long OrganisationId,
|
||||
[Required][MaxLength(LabelLength)] string Name,
|
||||
[Required][MaxLength(LabelLength)] string RegistrationNo,
|
||||
[Required][EmailAddress][MaxLength(LabelLength)] string Email,
|
||||
[Required][EmailAddress][MaxLength(LabelLength)] string IdNumber,
|
||||
[Required] Guid OrganisationUser,
|
||||
[Required] OrganisationType Type,
|
||||
[Required] ActivityStatus Status);
|
||||
|
||||
public sealed record CreateOrganisationRequest([Required][MaxLength(LabelLength)] string Name,
|
||||
[Required][MaxLength(LabelLength)] string RegistrationNo,
|
||||
[Required][EmailAddress][MaxLength(LabelLength)] string Email,
|
||||
[Required][EmailAddress][MaxLength(LabelLength)] string IdNumber,
|
||||
[Required] Guid OrganisationUser,
|
||||
[Required] OrganisationType Type = OrganisationType.PrivateLimitedCompany);
|
||||
|
||||
public sealed record Dashboard([Required] decimal PortfolioValue,
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace PostFundManagement.Domain.Entities;
|
||||
[EntityTypeConfiguration<OrganisationConfiguration, Organisation>]
|
||||
public class Organisation : Models.Organisation
|
||||
{
|
||||
public virtual User? User { get; set; }
|
||||
|
||||
public virtual User? Creator { get; set; }
|
||||
}
|
||||
|
||||
@@ -16,6 +18,7 @@ public sealed class OrganisationConfiguration : IEntityTypeConfiguration<Organis
|
||||
|
||||
builder.HasKey(pk => pk.Id);
|
||||
builder.Property(f => f.CreatedBy).IsRequired();
|
||||
builder.Property(f => f.UserId).IsRequired(false);
|
||||
builder.Property(f => f.CreatedAt).IsRequired().HasDefaultValueSql("now()");
|
||||
builder.Property(f => f.RegistrationNo).IsRequired().HasMaxLength(LabelLength);
|
||||
builder.Property(f => f.Name).IsRequired().HasMaxLength(LabelLength);
|
||||
@@ -25,6 +28,12 @@ public sealed class OrganisationConfiguration : IEntityTypeConfiguration<Organis
|
||||
builder.Property(f => f.Status).IsRequired().HasConversion<int>().HasDefaultValueSql("1")
|
||||
.HasSentinel(ActivityStatus.Active);
|
||||
|
||||
builder.HasOne(f => f.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(fk => fk.UserId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
builder.HasOne(f => f.Creator)
|
||||
.WithMany()
|
||||
.HasForeignKey(fk => fk.CreatedBy)
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed class UserConfiguration : IEntityTypeConfiguration<User>
|
||||
builder.HasKey(pk => pk.Id);
|
||||
builder.Property(f => f.Email).IsRequired().HasMaxLength(LabelLength);
|
||||
builder.Property(f => f.Role).IsRequired(false).HasMaxLength(LabelLength);
|
||||
builder.Property(f => f.IdNumber).IsRequired(false).HasMaxLength(LabelLength);
|
||||
builder.Property(f => f.Status).IsRequired().HasConversion<int>().HasDefaultValueSql("2")
|
||||
.HasSentinel(ActivityStatus.Inactive);
|
||||
builder.Property(f => f.LastLoginAt).IsRequired(false);
|
||||
|
||||
@@ -11,7 +11,7 @@ public static class Mappers
|
||||
CreatedAt = entity.CreatedAt,
|
||||
CreatedBy = entity.CreatedBy,
|
||||
Status = entity.Status,
|
||||
UserId = entity.UserId
|
||||
UserId = entity.UserId
|
||||
};
|
||||
|
||||
public static TenantOrganisation Map(this Entities.TenantOrganisation entity) => new()
|
||||
@@ -21,7 +21,7 @@ public static class Mappers
|
||||
CreatedAt = entity.CreatedAt,
|
||||
CreatedBy = entity.CreatedBy,
|
||||
OrganisationId = entity.OrganisationId,
|
||||
TenantId = entity.TenantId
|
||||
TenantId = entity.TenantId
|
||||
};
|
||||
|
||||
public static Tenant Map(this Entities.Tenant entity) => new()
|
||||
@@ -32,7 +32,7 @@ public static class Mappers
|
||||
CreatedBy = entity.CreatedBy,
|
||||
Status = entity.Status,
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
UpdatedBy = entity.UpdatedBy
|
||||
UpdatedBy = entity.UpdatedBy
|
||||
};
|
||||
|
||||
public static Activity Map(this Entities.Activity entity) => new()
|
||||
@@ -270,7 +270,8 @@ public static class Mappers
|
||||
Name = entity.Name,
|
||||
RegistrationNo = entity.RegistrationNo,
|
||||
Status = entity.Status,
|
||||
Type = entity.Type
|
||||
Type = entity.Type,
|
||||
UserId = entity.UserId
|
||||
};
|
||||
|
||||
public static Outcome Map(this Entities.Outcome entity) => new()
|
||||
@@ -392,6 +393,7 @@ public static class Mappers
|
||||
Email = entity.Email,
|
||||
LastLoginAt = entity.LastLoginAt,
|
||||
Status = entity.Status,
|
||||
Role = entity.Role
|
||||
Role = entity.Role,
|
||||
IdNumber = entity.IdNumber
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,8 @@ public class Organisation
|
||||
|
||||
public string? Email { get; set; }
|
||||
|
||||
public Guid? UserId { get; set; }
|
||||
|
||||
public OrganisationType Type { get; set; }
|
||||
|
||||
public ActivityStatus Status { get; set; }
|
||||
|
||||
@@ -10,5 +10,7 @@ public class User
|
||||
|
||||
public string? Role { get; set; }
|
||||
|
||||
public string? IdNumber { get; set; }
|
||||
|
||||
public ActivityStatus Status { get; set; }
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace PostFundManagement.Infrastructure.Database;
|
||||
public sealed class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options,
|
||||
ITenantContextService tenantService) : DbContext(options)
|
||||
{
|
||||
private bool BypassFilter => tenantService.IsAdmin || tenantService.ByPassTenantQueryFilter;
|
||||
private bool BypassFilter => tenantService.ByPassTenantQueryFilter;
|
||||
private long CurrentTenantId => tenantService.TenantId;
|
||||
|
||||
public DbSet<TenantUser> TenantUsers => Set<TenantUser>();
|
||||
@@ -84,14 +84,14 @@ public sealed class ApplicationDbContext(DbContextOptions<ApplicationDbContext>
|
||||
}
|
||||
}
|
||||
|
||||
modelBuilder.Entity<Organisation>().HasQueryFilter(o => BypassFilter == false &&
|
||||
modelBuilder.Entity<Organisation>().HasQueryFilter(o => BypassFilter ||
|
||||
Set<TenantOrganisation>().Any(to => to.TenantId == CurrentTenantId && to.OrganisationId == o.Id));
|
||||
|
||||
modelBuilder.Entity<TenantOrganisation>()
|
||||
.HasQueryFilter(to => BypassFilter == false && to.TenantId == CurrentTenantId);
|
||||
.HasQueryFilter(to => BypassFilter || to.TenantId == CurrentTenantId);
|
||||
|
||||
modelBuilder.Entity<TenantUser>()
|
||||
.HasQueryFilter(to => BypassFilter == false && to.TenantId == CurrentTenantId);
|
||||
.HasQueryFilter(to => BypassFilter || to.TenantId == CurrentTenantId);
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
|
||||
+2473
File diff suppressed because it is too large
Load Diff
+60
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PostFundManagement.Infrastructure.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddedUserIdAndIdNumberTenantOrgAndUserObjects : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "IdNumber",
|
||||
table: "Users",
|
||||
type: "character varying(256)",
|
||||
maxLength: 256,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "UserId",
|
||||
table: "Organisations",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Organisations_UserId",
|
||||
table: "Organisations",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Organisations_Users_UserId",
|
||||
table: "Organisations",
|
||||
column: "UserId",
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Organisations_Users_UserId",
|
||||
table: "Organisations");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Organisations_UserId",
|
||||
table: "Organisations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IdNumber",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UserId",
|
||||
table: "Organisations");
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -1017,10 +1017,15 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValueSql("5");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Organisations", (string)null);
|
||||
});
|
||||
|
||||
@@ -1530,6 +1535,10 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("IdNumber")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<DateTime?>("LastLoginAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -2124,7 +2133,14 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
|
||||
.OnDelete(DeleteBehavior.NoAction)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PostFundManagement.Domain.Entities.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PostFundManagement.Domain.Entities.Outcome", b =>
|
||||
|
||||
@@ -19,6 +19,9 @@ public sealed class TenantContextService : ITenantContextService
|
||||
var user = httpContextAccessor.HttpContext?.User;
|
||||
|
||||
IsAdmin = user?.IsInRole("Admin") == true || user?.HasClaim("role", "Admin") == true;
|
||||
ByPassTenantQueryFilter = user?.IsInRole("Partner") == true || user?.HasClaim("role", "Partner") == true;
|
||||
|
||||
ByPassTenantQueryFilter = ByPassTenantQueryFilter || IsAdmin;
|
||||
|
||||
HasTenantFilter = !IsAdmin;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user