diff --git a/web.Tests/FunctionTests/Controllers/TermsApiController.Tests.cs b/web.Tests/FunctionTests/Controllers/TermsApiController.Tests.cs new file mode 100644 index 00000000..6cae21c1 --- /dev/null +++ b/web.Tests/FunctionTests/Controllers/TermsApiController.Tests.cs @@ -0,0 +1,428 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Atlas_Web.Contracts.Api.Terms; +using Atlas_Web.Controllers.Api; +using Atlas_Web.Models; +using Atlas_Web.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace web.Tests.FunctionTests.Controllers; + +public class TermsApiControllerTests +{ + [Fact] + public async Task GetTerms_ReturnsListSurfaceWithPermissionsAndFlags() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "terms-api-list") + .Options; + + await using var context = new Atlas_WebContext(options); + context.Users.Add(new User { UserId = 1, Username = "viewer", FullnameCalc = "Viewer Name" }); + context.Terms.AddRange( + new Term + { + TermId = 3, + Name = "Admissions", + Summary = "Admission summary", + ApprovedYn = "Y", + UpdatedByUserId = 1, + }, + new Term + { + TermId = 4, + Name = "Discharge", + TechnicalDefinition = "Technical discharge definition", + ApprovedYn = "N", + UpdatedByUserId = 1, + } + ); + context.StarredTerms.Add(new StarredTerm { StarId = 20, Ownerid = 1, Termid = 3 }); + await context.SaveChangesAsync(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["features:enable_user_profile"] = "true", + ["features:enable_sharing"] = "true", + ["features:enable_feedback"] = "false", + } + ) + .Build(); + + var service = new TermsApiService(context, config, new MemoryCache(new MemoryCacheOptions())); + var controller = BuildController( + service, + BuildPrincipal(userId: 1, username: "viewer", permissions: new[] { "Create New Terms" }) + ); + + var result = await controller.GetTerms(); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(ok.Value); + + Assert.True(payload.Permissions.CanCreateTerm); + Assert.True(payload.Features.UserProfilesEnabled); + Assert.True(payload.Features.SharingEnabled); + Assert.False(payload.Features.FeedbackEnabled); + Assert.Equal(2, payload.Items.Count); + + var approved = Assert.Single(payload.Items.Where(x => x.Id == 3)); + Assert.True(approved.IsApproved); + Assert.True(approved.IsStarred); + Assert.Equal(1, approved.StarCount); + Assert.Equal("Admission summary... ", approved.BodyText); + + var unapproved = Assert.Single(payload.Items.Where(x => x.Id == 4)); + Assert.False(unapproved.IsApproved); + Assert.False(unapproved.IsStarred); + Assert.Equal("Technical discharge definition... ", unapproved.BodyText); + } + + [Fact] + public async Task GetTerm_ReturnsDetailWithApprovalAwarePermissions() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "terms-api-detail") + .Options; + + await using var context = new Atlas_WebContext(options); + context.Users.AddRange( + new User { UserId = 1, Username = "viewer", FullnameCalc = "Viewer Name" }, + new User { UserId = 2, Username = "approver", FullnameCalc = "Approver Name" } + ); + context.Terms.Add( + new Term + { + TermId = 10, + Name = "Census", + Summary = "Approved summary", + TechnicalDefinition = "Approved technical definition", + ApprovedYn = "Y", + ApprovedByUserId = 2, + UpdatedByUserId = 1, + } + ); + context.StarredTerms.Add(new StarredTerm { StarId = 30, Ownerid = 1, Termid = 10 }); + await context.SaveChangesAsync(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["features:enable_user_profile"] = "true", + ["features:enable_sharing"] = "true", + ["features:enable_feedback"] = "true", + } + ) + .Build(); + + var service = new TermsApiService(context, config, new MemoryCache(new MemoryCacheOptions())); + var controller = BuildController( + service, + BuildPrincipal( + userId: 1, + username: "viewer", + permissions: new[] { "Create New Terms", "Edit Approved Terms", "Delete Approved Terms", "View Other User" } + ) + ); + + var result = await controller.GetTerm(10); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(ok.Value); + + Assert.Equal(10, payload.Id); + Assert.Equal("Census", payload.Name); + Assert.True(payload.IsApproved); + Assert.True(payload.IsStarred); + Assert.Equal(1, payload.StarCount); + Assert.True(payload.Permissions.CanCreateTerm); + Assert.True(payload.Permissions.CanEditTerm); + Assert.True(payload.Permissions.CanDeleteTerm); + Assert.False(payload.Permissions.CanApproveTerm); + Assert.True(payload.Permissions.CanViewUserProfiles); + Assert.True(payload.Features.UserProfilesEnabled); + Assert.True(payload.Features.SharingEnabled); + Assert.True(payload.Features.FeedbackEnabled); + Assert.Equal("Approver Name", payload.ApprovedBy.FullName); + Assert.Equal("Viewer Name", payload.LastUpdatedBy.FullName); + } + + [Fact] + public async Task GetTermReports_ReturnsVisibleDirectAndInheritedReports() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "terms-api-related-reports") + .Options; + + await using var context = new Atlas_WebContext(options); + context.Users.Add(new User { UserId = 1, Username = "viewer", FullnameCalc = "Viewer Name" }); + context.Terms.Add(new Term { TermId = 15, Name = "Quality", ApprovedYn = "N", UpdatedByUserId = 1 }); + context.ReportObjectTypes.Add( + new ReportObjectType + { + ReportObjectTypeId = 5, + Name = "SSRS Report", + ShortName = "SSRS", + Visible = "Y", + } + ); + context.ReportObjects.AddRange( + new ReportObject + { + ReportObjectId = 101, + Name = "Direct Report", + DisplayTitle = "Direct Report", + Description = "Direct body", + ReportObjectTypeId = 5, + DefaultVisibilityYn = "Y", + SourceDb = "warehouse", + SourceServer = "reports", + SourceTable = "dbo.direct", + }, + new ReportObject + { + ReportObjectId = 102, + Name = "Parent Report", + DisplayTitle = "Parent Report", + Description = "Parent body", + ReportObjectTypeId = 5, + DefaultVisibilityYn = "Y", + SourceDb = "warehouse", + SourceServer = "reports", + SourceTable = "dbo.parent", + }, + new ReportObject + { + ReportObjectId = 103, + Name = "Hidden Report", + DisplayTitle = "Hidden Report", + Description = "Hidden body", + ReportObjectTypeId = 5, + DefaultVisibilityYn = "Y", + SourceDb = "warehouse", + SourceServer = "reports", + SourceTable = "dbo.hidden", + } + ); + context.ReportObjectDocs.AddRange( + new ReportObjectDoc + { + ReportObjectId = 101, + Hidden = "N", + DeveloperDescription = "Direct description", + }, + new ReportObjectDoc + { + ReportObjectId = 102, + Hidden = "N", + DeveloperDescription = "Parent description", + }, + new ReportObjectDoc + { + ReportObjectId = 103, + Hidden = "Y", + DeveloperDescription = "Hidden description", + } + ); + context.ReportObjectDocTerms.Add(new ReportObjectDocTerm { ReportObjectId = 101, TermId = 15 }); + context.ReportObjectDocTerms.Add(new ReportObjectDocTerm { ReportObjectId = 103, TermId = 15 }); + context.ReportObjectHierarchies.Add( + new ReportObjectHierarchy + { + ParentReportObjectId = 102, + ChildReportObjectId = 101, + } + ); + await context.SaveChangesAsync(); + + var service = new TermsApiService( + context, + new ConfigurationBuilder().AddInMemoryCollection().Build(), + new MemoryCache(new MemoryCacheOptions()) + ); + var controller = BuildController(service, BuildPrincipal(userId: 1, username: "viewer")); + + var result = await controller.GetTermReports(15); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsAssignableFrom>(ok.Value); + + Assert.Equal(2, payload.Count); + Assert.Contains(payload, x => x.Id == 101 && x.Name == "Direct Report"); + Assert.Contains(payload, x => x.Id == 102 && x.Name == "Parent Report"); + Assert.DoesNotContain(payload, x => x.Id == 103); + } + + [Fact] + public async Task CreateTerm_SetsAuditFields_AndApprovesWhenPermitted() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "terms-api-create") + .Options; + + await using var context = new Atlas_WebContext(options); + context.Users.Add(new User { UserId = 1, Username = "creator", FullnameCalc = "Creator Name" }); + await context.SaveChangesAsync(); + + var service = new TermsApiService( + context, + new ConfigurationBuilder().AddInMemoryCollection().Build(), + new MemoryCache(new MemoryCacheOptions()) + ); + var controller = BuildController( + service, + BuildPrincipal( + userId: 1, + username: "creator", + permissions: new[] { "Create New Terms", "Approve Terms" } + ) + ); + + var result = await controller.CreateTerm( + new CreateTermRequestDto + { + Name = "Mortality", + Summary = "Mortality summary", + TechnicalDefinition = "Mortality technical definition", + ApprovedYn = "Y", + } + ); + + var created = Assert.IsType(result.Result); + var payload = Assert.IsType(created.Value); + Assert.True(payload.IsApproved); + + var term = Assert.Single(context.Terms); + Assert.Equal(1, term.UpdatedByUserId); + Assert.Equal(1, term.ApprovedByUserId); + Assert.Equal("Y", term.ApprovedYn); + } + + [Fact] + public async Task UpdateTerm_ForbidsEditingApprovedTermWithoutPermission() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "terms-api-update-forbid") + .Options; + + await using var context = new Atlas_WebContext(options); + context.Users.Add(new User { UserId = 1, Username = "editor", FullnameCalc = "Editor Name" }); + context.Terms.Add( + new Term + { + TermId = 31, + Name = "Original", + Summary = "Summary", + ApprovedYn = "Y", + UpdatedByUserId = 1, + } + ); + await context.SaveChangesAsync(); + + var service = new TermsApiService( + context, + new ConfigurationBuilder().AddInMemoryCollection().Build(), + new MemoryCache(new MemoryCacheOptions()) + ); + var controller = BuildController( + service, + BuildPrincipal(userId: 1, username: "editor", permissions: new[] { "Edit Unapproved Terms" }) + ); + + var result = await controller.UpdateTerm( + 31, + new UpdateTermRequestDto + { + Name = "Changed", + Summary = "Changed summary", + TechnicalDefinition = "Changed definition", + ApprovedYn = "Y", + } + ); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task DeleteTerm_RemovesLinks_WhenPermissionMatchesApprovalState() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "terms-api-delete") + .Options; + + await using var context = new Atlas_WebContext(options); + context.Users.Add(new User { UserId = 1, Username = "deleter", FullnameCalc = "Deleter Name" }); + context.Terms.Add( + new Term + { + TermId = 40, + Name = "Delete Me", + ApprovedYn = "N", + UpdatedByUserId = 1, + } + ); + context.ReportObjectDocTerms.Add(new ReportObjectDocTerm { ReportObjectId = 200, TermId = 40 }); + context.CollectionTerms.Add(new CollectionTerm { CollectionId = 201, TermId = 40 }); + await context.SaveChangesAsync(); + + var service = new TermsApiService( + context, + new ConfigurationBuilder().AddInMemoryCollection().Build(), + new MemoryCache(new MemoryCacheOptions()) + ); + var controller = BuildController( + service, + BuildPrincipal(userId: 1, username: "deleter", permissions: new[] { "Delete Unapproved Terms" }) + ); + + var result = await controller.DeleteTerm(40); + + Assert.IsType(result); + Assert.Empty(context.Terms); + Assert.Empty(context.ReportObjectDocTerms); + Assert.Empty(context.CollectionTerms); + } + + private static TermsApiController BuildController(ITermsApiService service, ClaimsPrincipal principal) + { + return new TermsApiController(service) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = principal }, + }, + }; + } + + private static ClaimsPrincipal BuildPrincipal( + int userId, + string username, + IEnumerable permissions = null + ) + { + var claims = new List + { + new(ClaimTypes.Name, username), + new("UserId", userId.ToString()), + new("Fullname", username), + new("AdminEnabled", "Y"), + }; + + if (permissions != null) + { + claims.AddRange(permissions.Select(permission => new Claim("Permission", permission))); + } + + return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + } +} diff --git a/web/Contracts/Api/Terms/TermDtos.cs b/web/Contracts/Api/Terms/TermDtos.cs new file mode 100644 index 00000000..f539c709 --- /dev/null +++ b/web/Contracts/Api/Terms/TermDtos.cs @@ -0,0 +1,98 @@ +using System.ComponentModel.DataAnnotations; + +namespace Atlas_Web.Contracts.Api.Terms; + +public sealed class TermsListDto +{ + public TermFeaturesDto Features { get; init; } + public TermPermissionsDto Permissions { get; init; } + public IReadOnlyList Items { get; init; } = Array.Empty(); +} + +public sealed class TermListItemDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Summary { get; init; } + public string TechnicalDefinition { get; init; } + public string BodyText { get; init; } + public string Url { get; init; } + public bool IsApproved { get; init; } + public bool IsStarred { get; init; } + public int StarCount { get; init; } +} + +public sealed class TermDetailDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Summary { get; init; } + public string TechnicalDefinition { get; init; } + public bool IsApproved { get; init; } + public string ApprovedYn { get; init; } + public string ApprovalDateDisplay { get; init; } + public string LastUpdatedDisplay { get; init; } + public bool IsStarred { get; init; } + public int StarCount { get; init; } + public TermFeaturesDto Features { get; init; } + public TermPermissionsDto Permissions { get; init; } + public TermUserSummaryDto ApprovedBy { get; init; } + public TermUserSummaryDto LastUpdatedBy { get; init; } +} + +public sealed class TermFeaturesDto +{ + public bool UserProfilesEnabled { get; init; } + public bool SharingEnabled { get; init; } + public bool FeedbackEnabled { get; init; } +} + +public sealed class TermPermissionsDto +{ + public bool CanCreateTerm { get; init; } + public bool CanEditTerm { get; init; } + public bool CanDeleteTerm { get; init; } + public bool CanApproveTerm { get; init; } + public bool CanViewUserProfiles { get; init; } +} + +public sealed class TermUserSummaryDto +{ + public int Id { get; init; } + public string Username { get; init; } + public string FullName { get; init; } + public string Email { get; init; } +} + +public sealed class TermRelatedReportDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Description { get; init; } + public string BodyText { get; init; } + public string Type { get; init; } + public string Url { get; init; } + public int AttachmentCount { get; init; } + public bool CanRun { get; init; } + public bool IsStarred { get; init; } + public int StarCount { get; init; } + public bool IsCertified { get; init; } +} + +public sealed class CreateTermRequestDto +{ + [Required] + public string Name { get; init; } + public string Summary { get; init; } + public string TechnicalDefinition { get; init; } + public string ApprovedYn { get; init; } +} + +public sealed class UpdateTermRequestDto +{ + [Required] + public string Name { get; init; } + public string Summary { get; init; } + public string TechnicalDefinition { get; init; } + public string ApprovedYn { get; init; } +} diff --git a/web/Controllers/Api/TermsApiController.cs b/web/Controllers/Api/TermsApiController.cs new file mode 100644 index 00000000..7522a68c --- /dev/null +++ b/web/Controllers/Api/TermsApiController.cs @@ -0,0 +1,111 @@ +using Atlas_Web.Authorization; +using Atlas_Web.Contracts.Api.Terms; +using Atlas_Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Atlas_Web.Controllers.Api; + +[ApiController] +[Route("api/terms")] +[Authorize(AuthenticationSchemes = "Bearer")] +public class TermsApiController : ControllerBase +{ + private readonly ITermsApiService _termsApiService; + + public TermsApiController(ITermsApiService termsApiService) + { + _termsApiService = termsApiService; + } + + [HttpGet] + public async Task> GetTerms(CancellationToken cancellationToken = default) + { + return Ok(await _termsApiService.GetTermsAsync(User, cancellationToken)); + } + + [HttpGet("{id:int}")] + public async Task> GetTerm( + int id, + CancellationToken cancellationToken = default + ) + { + var term = await _termsApiService.GetTermAsync(User, id, cancellationToken); + if (term == null) + { + return NotFound(); + } + + return Ok(term); + } + + [HttpGet("{id:int}/reports")] + public async Task>> GetTermReports( + int id, + CancellationToken cancellationToken = default + ) + { + var term = await _termsApiService.GetTermAsync(User, id, cancellationToken); + if (term == null) + { + return NotFound(); + } + + return Ok(await _termsApiService.GetTermReportsAsync(User, id, cancellationToken)); + } + + [HttpPost] + public async Task> CreateTerm( + [FromBody] CreateTermRequestDto request, + CancellationToken cancellationToken = default + ) + { + if (!User.HasPermission("Create New Terms")) + { + return Forbid(); + } + + var term = await _termsApiService.CreateTermAsync(User, request, cancellationToken); + return CreatedAtAction(nameof(GetTerm), new { id = term.Id }, term); + } + + [HttpPut("{id:int}")] + public async Task> UpdateTerm( + int id, + [FromBody] UpdateTermRequestDto request, + CancellationToken cancellationToken = default + ) + { + var existing = await _termsApiService.GetTermAsync(User, id, cancellationToken); + if (existing == null) + { + return NotFound(); + } + + if (!existing.Permissions.CanEditTerm) + { + return Forbid(); + } + + var term = await _termsApiService.UpdateTermAsync(User, id, request, cancellationToken); + return Ok(term); + } + + [HttpDelete("{id:int}")] + public async Task DeleteTerm(int id, CancellationToken cancellationToken = default) + { + var existing = await _termsApiService.GetTermAsync(User, id, cancellationToken); + if (existing == null) + { + return NotFound(); + } + + if (!existing.Permissions.CanDeleteTerm) + { + return Forbid(); + } + + await _termsApiService.DeleteTermAsync(id, cancellationToken); + return NoContent(); + } +} diff --git a/web/Program.cs b/web/Program.cs index b88d5967..28f0e9ad 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -208,6 +208,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); diff --git a/web/Services/Terms/TermsApiService.Reads.cs b/web/Services/Terms/TermsApiService.Reads.cs new file mode 100644 index 00000000..ee86d0b0 --- /dev/null +++ b/web/Services/Terms/TermsApiService.Reads.cs @@ -0,0 +1,199 @@ +using System.Security.Claims; +using Atlas_Web.Authorization; +using Atlas_Web.Contracts.Api.Terms; +using Atlas_Web.Models; +using Microsoft.EntityFrameworkCore; + +namespace Atlas_Web.Services; + +public sealed partial class TermsApiService +{ + public async Task GetTermsAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken + ) + { + var currentUserId = user.GetUserId(); + var features = BuildFeatures(); + var permissions = new TermPermissionsDto + { + CanCreateTerm = user.HasPermission("Create New Terms"), + CanApproveTerm = user.HasPermission("Approve Terms"), + CanEditTerm = false, + CanDeleteTerm = false, + CanViewUserProfiles = user.HasPermission("View Other User"), + }; + + var items = await _context.Terms.AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new TermListItemDto + { + Id = x.TermId, + Name = x.Name, + Summary = x.Summary, + TechnicalDefinition = x.TechnicalDefinition, + Url = "/terms?id=" + x.TermId, + IsApproved = (x.ApprovedYn ?? "N") == "Y", + IsStarred = x.StarredTerms.Any(y => y.Ownerid == currentUserId), + StarCount = x.StarredTerms.Count, + BodyText = + !string.IsNullOrEmpty(x.Summary) + ? TruncateWithReadMore(x.Summary) + : TruncateWithReadMore(x.TechnicalDefinition), + }) + .ToListAsync(cancellationToken); + + return new TermsListDto + { + Features = features, + Permissions = permissions, + Items = items, + }; + } + + public async Task GetTermAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ) + { + var currentUserId = user.GetUserId(); + var term = await _context.Terms.AsNoTracking() + .Include(x => x.ApprovedByUser) + .Include(x => x.UpdatedByUser) + .Include(x => x.StarredTerms) + .SingleOrDefaultAsync(x => x.TermId == id, cancellationToken); + + if (term == null) + { + return null; + } + + return new TermDetailDto + { + Id = term.TermId, + Name = term.Name, + Summary = term.Summary, + TechnicalDefinition = term.TechnicalDefinition, + IsApproved = IsApproved(term.ApprovedYn), + ApprovedYn = IsApproved(term.ApprovedYn) ? "Y" : "N", + ApprovalDateDisplay = term.ApprovalDateTimeDisplayString, + LastUpdatedDisplay = term.LastUpdatedDateTimeDisplayString, + IsStarred = term.StarredTerms.Any(x => x.Ownerid == currentUserId), + StarCount = term.StarredTerms.Count, + Features = BuildFeatures(), + Permissions = BuildPermissions(user, term), + ApprovedBy = ToUserSummary(term.ApprovedByUser), + LastUpdatedBy = ToUserSummary(term.UpdatedByUser), + }; + } + + public async Task> GetTermReportsAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ) + { + var directReports = await LoadRelatedReportsAtDepthAsync(id, 1, cancellationToken); + var parentReports = await LoadRelatedReportsAtDepthAsync(id, 2, cancellationToken); + var grandParentReports = await LoadRelatedReportsAtDepthAsync(id, 3, cancellationToken); + var fourthLevelReports = await LoadRelatedReportsAtDepthAsync(id, 4, cancellationToken); + + var relatedReports = directReports + .Concat(parentReports) + .Concat(grandParentReports) + .Concat(fourthLevelReports) + .GroupBy(x => x.ReportObjectId) + .Select(x => x.First()) + .OrderBy(x => x.DisplayTitle ?? x.Name) + .ToList(); + + return await BuildRelatedReportDtosAsync(user, relatedReports); + } + + private async Task> LoadRelatedReportsAtDepthAsync( + int termId, + int depth, + CancellationToken cancellationToken + ) + { + IQueryable query = _context.ReportObjects.AsNoTracking(); + + query = depth switch + { + 1 => query.Where(x => x.ReportObjectDoc.ReportObjectDocTerms.Any(y => y.TermId == termId)), + 2 => query.Where(x => x.ReportObjectHierarchyParentReportObjects.Any(y => + y.ChildReportObject.ReportObjectDoc.ReportObjectDocTerms.Any(z => z.TermId == termId))), + 3 => query.Where(x => x.ReportObjectHierarchyParentReportObjects.Any(g => + g.ChildReportObject.ReportObjectHierarchyParentReportObjects.Any(y => + y.ChildReportObject.ReportObjectDoc.ReportObjectDocTerms.Any(z => z.TermId == termId)))), + 4 => query.Where(x => x.ReportObjectHierarchyParentReportObjects.Any(gg => + gg.ChildReportObject.ReportObjectHierarchyParentReportObjects.Any(g => + g.ChildReportObject.ReportObjectHierarchyParentReportObjects.Any(y => + y.ChildReportObject.ReportObjectDoc.ReportObjectDocTerms.Any(z => z.TermId == termId))))), + _ => query.Where(_ => false), + }; + + return await query + .Where(x => (x.ReportObjectDoc.Hidden ?? "N") == "N") + .Where(x => x.DefaultVisibilityYn == "Y") + .Include(x => x.ReportObjectType) + .Include(x => x.ReportObjectDoc) + .Include(x => x.ReportObjectAttachments) + .Include(x => x.ReportTagLinks) + .ThenInclude(x => x.Tag) + .Include(x => x.StarredReports) + .Include(x => x.ReportGroupsMemberships) + .Include(x => x.ReportObjectHierarchyChildReportObjects) + .ThenInclude(x => x.ParentReportObject) + .ThenInclude(x => x.ReportGroupsMemberships) + .ToListAsync(cancellationToken); + } + + private async Task> BuildRelatedReportDtosAsync( + ClaimsPrincipal user, + IReadOnlyList reports + ) + { + var currentUserId = user.GetUserId(); + var result = new List(reports.Count); + + foreach (var report in reports) + { + var canRun = false; + if (_authorizationService != null) + { + canRun = (await _authorizationService.AuthorizeAsync( + user, + report, + "ReportRunPolicy" + )).Succeeded; + } + + result.Add( + new TermRelatedReportDto + { + Id = report.ReportObjectId, + Name = report.DisplayTitle ?? report.DisplayName ?? report.Name, + Description = report.Description, + BodyText = + !string.IsNullOrEmpty(report.ReportObjectDoc?.DeveloperDescription) + ? TruncateWithReadMore(report.ReportObjectDoc.DeveloperDescription) + : TruncateWithReadMore(report.Description), + Type = string.IsNullOrEmpty(report.ReportObjectType?.ShortName) + ? report.ReportObjectType?.Name + : report.ReportObjectType.ShortName, + Url = "/reports?id=" + report.ReportObjectId, + AttachmentCount = report.ReportObjectAttachments.Count, + CanRun = canRun, + IsStarred = report.StarredReports.Any(x => x.Ownerid == currentUserId), + StarCount = report.StarredReports.Count, + IsCertified = report.ReportTagLinks.Any(x => + x.Tag.Name == "Analytics Certified" || x.Tag.Name == "Analytics Reviewed"), + } + ); + } + + return result; + } +} diff --git a/web/Services/Terms/TermsApiService.Writes.cs b/web/Services/Terms/TermsApiService.Writes.cs new file mode 100644 index 00000000..3ee50108 --- /dev/null +++ b/web/Services/Terms/TermsApiService.Writes.cs @@ -0,0 +1,101 @@ +using System.Security.Claims; +using Atlas_Web.Authorization; +using Atlas_Web.Contracts.Api.Terms; +using Atlas_Web.Models; +using Microsoft.EntityFrameworkCore; + +namespace Atlas_Web.Services; + +public sealed partial class TermsApiService +{ + public async Task CreateTermAsync( + ClaimsPrincipal user, + CreateTermRequestDto request, + CancellationToken cancellationToken + ) + { + var currentUserId = user.GetUserId(); + var canApprove = user.HasPermission("Approve Terms"); + var approved = canApprove && IsApproved(request.ApprovedYn); + + var term = new Term + { + Name = request.Name.Trim(), + Summary = request.Summary, + TechnicalDefinition = request.TechnicalDefinition, + ApprovedYn = approved ? "Y" : "N", + UpdatedByUserId = currentUserId, + LastUpdatedDateTime = DateTime.Now, + ApprovedByUserId = approved ? currentUserId : null, + ApprovalDateTime = approved ? DateTime.Now : null, + }; + + await _context.Terms.AddAsync(term, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + InvalidateTermCaches(term.TermId); + + return await GetTermAsync(user, term.TermId, cancellationToken); + } + + public async Task UpdateTermAsync( + ClaimsPrincipal user, + int id, + UpdateTermRequestDto request, + CancellationToken cancellationToken + ) + { + var currentUserId = user.GetUserId(); + var canApprove = user.HasPermission("Approve Terms"); + var term = await _context.Terms.SingleOrDefaultAsync(x => x.TermId == id, cancellationToken); + if (term == null) + { + return null; + } + + term.Name = request.Name.Trim(); + term.Summary = request.Summary; + term.TechnicalDefinition = request.TechnicalDefinition; + term.UpdatedByUserId = currentUserId; + term.LastUpdatedDateTime = DateTime.Now; + + if (canApprove && IsApproved(request.ApprovedYn)) + { + term.ApprovedYn = "Y"; + term.ApprovalDateTime ??= DateTime.Now; + term.ApprovedByUserId ??= currentUserId; + } + else + { + term.ApprovedYn = "N"; + term.ApprovalDateTime = null; + term.ApprovedByUserId = null; + } + + await _context.SaveChangesAsync(cancellationToken); + InvalidateTermCaches(id); + + return await GetTermAsync(user, id, cancellationToken); + } + + public async Task DeleteTermAsync(int id, CancellationToken cancellationToken) + { + var reportLinks = await _context.ReportObjectDocTerms.Where(x => x.TermId == id) + .ToListAsync(cancellationToken); + var collectionLinks = await _context.CollectionTerms.Where(x => x.TermId == id) + .ToListAsync(cancellationToken); + var starredLinks = await _context.StarredTerms.Where(x => x.Termid == id) + .ToListAsync(cancellationToken); + var term = await _context.Terms.SingleOrDefaultAsync(x => x.TermId == id, cancellationToken); + + _context.ReportObjectDocTerms.RemoveRange(reportLinks); + _context.CollectionTerms.RemoveRange(collectionLinks); + _context.StarredTerms.RemoveRange(starredLinks); + if (term != null) + { + _context.Terms.Remove(term); + } + + await _context.SaveChangesAsync(cancellationToken); + InvalidateTermCaches(id); + } +} diff --git a/web/Services/Terms/TermsApiService.cs b/web/Services/Terms/TermsApiService.cs new file mode 100644 index 00000000..b1e0f7a4 --- /dev/null +++ b/web/Services/Terms/TermsApiService.cs @@ -0,0 +1,130 @@ +using System.Security.Claims; +using Atlas_Web.Authorization; +using Atlas_Web.Contracts.Api.Terms; +using Atlas_Web.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Caching.Memory; + +namespace Atlas_Web.Services; + +public interface ITermsApiService +{ + Task GetTermsAsync(ClaimsPrincipal user, CancellationToken cancellationToken); + Task GetTermAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ); + Task> GetTermReportsAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ); + Task CreateTermAsync( + ClaimsPrincipal user, + CreateTermRequestDto request, + CancellationToken cancellationToken + ); + Task UpdateTermAsync( + ClaimsPrincipal user, + int id, + UpdateTermRequestDto request, + CancellationToken cancellationToken + ); + Task DeleteTermAsync(int id, CancellationToken cancellationToken); +} + +public sealed partial class TermsApiService : ITermsApiService +{ + private readonly Atlas_WebContext _context; + private readonly IConfiguration _configuration; + private readonly IMemoryCache _cache; + private readonly IAuthorizationService _authorizationService; + + public TermsApiService( + Atlas_WebContext context, + IConfiguration configuration, + IMemoryCache cache, + IAuthorizationService authorizationService = null + ) + { + _context = context; + _configuration = configuration; + _cache = cache; + _authorizationService = authorizationService; + } + + private bool IsFeatureEnabled(string key) + { + var value = _configuration[key]; + return string.IsNullOrWhiteSpace(value) + || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } + + private TermFeaturesDto BuildFeatures() + { + return new TermFeaturesDto + { + UserProfilesEnabled = IsFeatureEnabled("features:enable_user_profile"), + SharingEnabled = IsFeatureEnabled("features:enable_sharing"), + FeedbackEnabled = IsFeatureEnabled("features:enable_feedback"), + }; + } + + private TermPermissionsDto BuildPermissions(ClaimsPrincipal user, Term term) + { + var isApproved = string.Equals(term.ApprovedYn, "Y", StringComparison.OrdinalIgnoreCase); + + return new TermPermissionsDto + { + CanCreateTerm = user.HasPermission("Create New Terms"), + CanEditTerm = isApproved + ? user.HasPermission("Edit Approved Terms") + : user.HasPermission("Edit Unapproved Terms"), + CanDeleteTerm = isApproved + ? user.HasPermission("Delete Approved Terms") + : user.HasPermission("Delete Unapproved Terms"), + CanApproveTerm = user.HasPermission("Approve Terms"), + CanViewUserProfiles = user.HasPermission("View Other User"), + }; + } + + private static TermUserSummaryDto ToUserSummary(User user) + { + if (user == null) + { + return null; + } + + return new TermUserSummaryDto + { + Id = user.UserId, + Username = user.Username, + FullName = user.FullnameCalc ?? user.FullName ?? user.DisplayName, + Email = user.Email, + }; + } + + private static string TruncateWithReadMore(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return "Open to view details."; + } + + var trimmed = text.Trim(); + return trimmed.Substring(0, Math.Min(160, trimmed.Length)) + "... "; + } + + private static bool IsApproved(string approvedYn) + { + return string.Equals(approvedYn, "Y", StringComparison.OrdinalIgnoreCase); + } + + private void InvalidateTermCaches(int termId) + { + _cache.Remove("terms"); + _cache.Remove("term-" + termId); + _cache.Remove("term-reports-" + termId); + } +}