diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 011f636a..4bfd9564 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -19,7 +19,7 @@ jobs: - name: setup node uses: actions/setup-node@v6 with: - node-version: '16.x' + node-version: '22.x' - name: install node deps run: npm install - name: node build @@ -38,6 +38,10 @@ jobs: install: localdb - name: migrate run: .\.dotnet-tools\dotnet-ef database update --project web/web.csproj + env: + Jwt__Key: "lighthouse-ci-test-jwt-key-minimum-32-characters-required" + Jwt__Issuer: "atlas-lighthouse-ci" + Jwt__Audience: "atlas-lighthouse-ci" - name: run Lighthouse CI run: | npm install -g @lhci/cli@0.9.x @@ -45,3 +49,8 @@ jobs: env: LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }} + Jwt__Key: "lighthouse-ci-test-jwt-key-minimum-32-characters-required" + Jwt__Issuer: "atlas-lighthouse-ci" + Jwt__Audience: "atlas-lighthouse-ci" + Cors__AllowedOrigins__0: "http://localhost:3000" + Auth__DefaultCallbackPath: "/auth/callback" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7676f67d..9aba64a7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,7 +19,7 @@ jobs: - name: setup node uses: actions/setup-node@v6 with: - node-version: '20.x' + node-version: '22.x' - name: install node deps run: npm install --ignore-scripts --no-audit --no-fund - name: Semantic Release diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index f1e11f58..6b813628 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -38,6 +38,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + Jwt__Key: "sonar-ci-test-jwt-key-minimum-32-characters-required-for-build" + Jwt__Issuer: "atlas-sonar-ci" + Jwt__Audience: "atlas-sonar-ci" + Cors__AllowedOrigins__0: "http://localhost:3000" + Auth__DefaultCallbackPath: "/auth/callback" shell: powershell run: | .\.sonar\scanner\dotnet-sonarscanner begin /k:"atlas-bi_atlas-bi-library" /o:"atlas-bi" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index cba802ac..de56a593 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -17,7 +17,7 @@ jobs: - name: setup node uses: actions/setup-node@v6 with: - node-version: '18.x' + node-version: '22.x' - name: install node deps run: npm install --ignore-scripts --no-audit --no-fund @@ -41,7 +41,7 @@ jobs: - name: setup node uses: actions/setup-node@v6 with: - node-version: '18.x' + node-version: '22.x' - name: install node deps run: npm install --ignore-scripts --no-audit --no-fund - name: node build @@ -82,7 +82,7 @@ jobs: - name: setup node uses: actions/setup-node@v6 with: - node-version: '18.x' + node-version: '22.x' - name: setup java uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 with: @@ -94,7 +94,6 @@ jobs: run: npm run build - name: install dotnet deps run: | - dotnet tool install -g coverlet.console dotnet tool install -g dotnet-reportgenerator-globaltool dotnet restore - name: build @@ -109,6 +108,21 @@ jobs: BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + - name: collect coverage file + if: always() + shell: pwsh + run: | + $coverageFile = Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + + if ($coverageFile) { + Copy-Item $coverageFile.FullName coverage.cobertura.xml -Force + Write-Host "Copied coverage file from $($coverageFile.FullName)" + } else { + Write-Host "Coverage file not found under TestResults" + } + - name: console coverage if: always() run: | @@ -204,7 +218,7 @@ jobs: - name: setup node uses: actions/setup-node@v6 with: - node-version: '18.x' + node-version: '22.x' - name: setup java uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 with: @@ -216,7 +230,6 @@ jobs: run: npm run build - name: install dotnet deps run: | - dotnet tool install -g coverlet.console dotnet tool install -g dotnet-reportgenerator-globaltool dotnet restore - name: build @@ -231,6 +244,21 @@ jobs: BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + - name: collect coverage file + if: always() + shell: pwsh + run: | + $coverageFile = Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + + if ($coverageFile) { + Copy-Item $coverageFile.FullName coverage.cobertura.xml -Force + Write-Host "Copied coverage file from $($coverageFile.FullName)" + } else { + Write-Host "Coverage file not found under TestResults" + } + - name: console coverage if: always() run: | diff --git a/docker-compose.yml b/docker-compose.yml index 07ab466f..79426848 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,6 +68,10 @@ services: environment: PORT: ${WEB_PORT:-3000} SEED_DEMO: ${SEED_DEMO:-true} + DEMO_ADMIN_USERNAME: ${DEMO_ADMIN_USERNAME:-} + Jwt__Key: ${JWT_KEY} + Jwt__Issuer: ${JWT_ISSUER} + Jwt__Audience: ${JWT_AUDIENCE} expose: - ${WEB_PORT:-3000} ports: diff --git a/package.json b/package.json index 155fdf2c..86a7657e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@fontsource/rasa": "^5.0.0", "@fontsource/source-code-pro": "^5.0.0", "@fortawesome/fontawesome-free": "^7.0.0", - "@rollup/plugin-babel": "^6.0.3", + "@rollup/plugin-babel": "^7.0.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.0.0", "@rollup/plugin-multi-entry": "^7.0.0", @@ -86,9 +86,9 @@ "db:update": "dotnet ef database update --project web/web.csproj -v", "dotnet:publish": "npm run build && dotnet publish web/web.csproj -r win-x86 --self-contained false -c Release -o out", "test:report_html": "reportgenerator -reports:coverage.cobertura.xml -targetdir:coverage/ -reporttypes:html", - "test:integrationTests": "coverlet web.Tests/bin/Debug/net9.0/web.Tests.dll --target \"dotnet\" --targetargs \"test --filter IntegrationTests --no-build -e Demo=True\" --format cobertura --exclude-by-file \"**/Migrations/*\"", - "test:browserTests": "coverlet web.Tests/bin/Debug/net9.0/web.Tests.dll --target \"dotnet\" --targetargs \"test --filter=BrowserTests --no-build -e Demo=True\" --format cobertura --exclude-by-file \"**/Migrations/*\"", - "test:functionTests": "coverlet web.Tests/bin/Debug/net9.0/web.Tests.dll --target \"dotnet\" --targetargs \"test --filter=FunctionTests --no-build -e Demo=True\" --format cobertura --exclude-by-file \"**/Migrations/*\"", + "test:integrationTests": "dotnet test web.Tests/web.Tests.csproj --filter IntegrationTests --no-build -e Demo=True --collect:\"XPlat Code Coverage;Format=cobertura\" --results-directory TestResults", + "test:browserTests": "dotnet test web.Tests/web.Tests.csproj --filter BrowserTests --no-build -e Demo=True --collect:\"XPlat Code Coverage;Format=cobertura\" --results-directory TestResults", + "test:functionTests": "dotnet test web.Tests/web.Tests.csproj --filter FunctionTests --no-build -e Demo=True --collect:\"XPlat Code Coverage;Format=cobertura\" --results-directory TestResults", "lint:js": "xo web/wwwroot/js", "lint:scss": "stylelint \"web/wwwroot/css/**/*.scss\"", "lint": "npm run lint:js & npm run lint:scss", @@ -164,7 +164,7 @@ "unicorn/prefer-at": "warn" } }, - "version": "3.15.38", + "version": "3.15.39", "dependencies": { "sass": "^1.63.6" } diff --git a/web.Tests/FunctionTests/Authorization/DemoAuthHandler.Tests.cs b/web.Tests/FunctionTests/Authorization/DemoAuthHandler.Tests.cs new file mode 100644 index 00000000..82c4138d --- /dev/null +++ b/web.Tests/FunctionTests/Authorization/DemoAuthHandler.Tests.cs @@ -0,0 +1,45 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Linq; +using System.Threading.Tasks; +using Atlas_Web.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace web.Tests.FunctionTests.Authorization; + +public class DemoAuthHandlerTests +{ + [Fact] + public async Task AuthenticateAsync_UsesConfiguredDemoAdminUsername() + { + var options = new DemoSchemeOptions { Username = "local-admin" }; + var optionsMonitor = new Mock>(); + optionsMonitor.Setup(x => x.CurrentValue).Returns(options); + optionsMonitor.Setup(x => x.Get(It.IsAny())).Returns(options); + + var handler = new DemoAuthHandler( + optionsMonitor.Object, + NullLoggerFactory.Instance, + UrlEncoder.Default, + new SystemClock() + ); + + await handler.InitializeAsync( + new AuthenticationScheme("Demo", "Demo", typeof(DemoAuthHandler)), + new DefaultHttpContext() + ); + + var result = await handler.AuthenticateAsync(); + + Assert.True(result.Succeeded); + Assert.Equal( + "local-admin", + result.Principal?.Claims.Single(c => c.Type == ClaimTypes.Name).Value + ); + } +} diff --git a/web.Tests/FunctionTests/Controllers/AuthApiController.Tests.cs b/web.Tests/FunctionTests/Controllers/AuthApiController.Tests.cs new file mode 100644 index 00000000..757b2cfc --- /dev/null +++ b/web.Tests/FunctionTests/Controllers/AuthApiController.Tests.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Threading.Tasks; +using Atlas_Web.Controllers.Api; +using Atlas_Web.Models; +using Atlas_Web.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using Xunit; + +namespace web.Tests.FunctionTests.Controllers; + +public class AuthApiControllerTests +{ + [Fact] + public async Task Login_UsesConfiguredDemoAdminUsername_WhenDemoModeIsEnabled() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "auth-api-demo-admin") + .Options; + + await using var context = new Atlas_WebContext(options); + context.Users.Add( + new User + { + UserId = 99, + Username = "local-admin", + FullnameCalc = "Local Admin", + } + ); + await context.SaveChangesAsync(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["Demo"] = "True", + ["DEMO_ADMIN_USERNAME"] = "local-admin", + ["Cors:AllowedOrigins:0"] = "http://localhost:3000", + ["Auth:DefaultCallbackPath"] = "/auth/callback", + ["Jwt:Issuer"] = "atlas-test-issuer", + ["Jwt:Audience"] = "atlas-test-audience", + } + ) + .Build(); + + var signingKey = new SymmetricSecurityKey( + System.Text.Encoding.UTF8.GetBytes( + "test-jwt-secret-key-for-function-tests-32-chars-minimum" + ) + ); + var jwt = new JwtTokenService(signingKey, "atlas-test-issuer", "atlas-test-audience"); + var controller = new AuthApiController(jwt, context, config); + + var result = await controller.Login("http://localhost:3000/auth/callback"); + + var redirect = Assert.IsType(result); + var target = new System.Uri(redirect.Url!, System.UriKind.Absolute); + var token = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(target.Query)["token"] + .Single(); + var jwtToken = new JwtSecurityTokenHandler().ReadJwtToken(token); + + Assert.Equal( + "local-admin", + jwtToken.Claims.Single(c => c.Type == System.Security.Claims.ClaimTypes.Name).Value + ); + Assert.Equal("99", jwtToken.Claims.Single(c => c.Type == "UserId").Value); + } +} diff --git a/web.Tests/FunctionTests/Controllers/UsersApiController.Tests.cs b/web.Tests/FunctionTests/Controllers/UsersApiController.Tests.cs new file mode 100644 index 00000000..e7df175e --- /dev/null +++ b/web.Tests/FunctionTests/Controllers/UsersApiController.Tests.cs @@ -0,0 +1,635 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Atlas_Web.Contracts.Api.Users; +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.Configuration; +using Microsoft.Extensions.Caching.Memory; +using Xunit; + +namespace web.Tests.FunctionTests.Controllers; + +public class UsersApiControllerTests +{ + [Fact] + public async Task GetUserPage_ReturnsTargetUserAndViewerDrivenFlags() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "users-api-page") + .Options; + + await using var context = new Atlas_WebContext(options); + context.Users.AddRange( + new User + { + UserId = 1, + Username = "viewer", + FullnameCalc = "Viewer Name", + FirstnameCalc = "Viewer", + }, + new User + { + UserId = 2, + Username = "target", + FullnameCalc = "Target Name", + FirstnameCalc = "Target", + Email = "target@example.com", + } + ); + context.ReportObjectTypes.AddRange( + new ReportObjectType { ReportObjectTypeId = 10, Name = "Visible A", Visible = "Y" }, + new ReportObjectType { ReportObjectTypeId = 20, Name = "Hidden B", Visible = "N" }, + new ReportObjectType { ReportObjectTypeId = 30, Name = "Visible C", Visible = "Y" } + ); + await context.SaveChangesAsync(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["features:enable_user_profile"] = "true", + } + ) + .Build(); + + var service = new UsersApiService(context, config, new MemoryCache(new MemoryCacheOptions())); + var controller = BuildController( + service, + BuildPrincipal( + userId: 1, + username: "viewer", + permissions: new[] { "View Other User", "View Groups", "View Site Analytics" }, + roles: new[] { "Administrator" } + ) + ); + + var result = await controller.GetUserPage(2); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(ok.Value); + + Assert.Equal(2, payload.User.Id); + Assert.Equal("Target Name", payload.User.FullName); + Assert.Equal(1, payload.Viewer.Id); + Assert.False(payload.Viewer.IsCurrentUser); + Assert.True(payload.Permissions.CanViewOtherUsers); + Assert.True(payload.Tabs.GroupsVisible); + Assert.True(payload.Tabs.AnalyticsVisible); + Assert.Equal(new[] { 10, 30 }, payload.DefaultReportTypeIds); + } + + [Fact] + public async Task GetSearchHistory_ReturnsOnlyCurrentUsersHistory() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "users-api-search-history") + .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 = "other", FullnameCalc = "Other Name" } + ); + context.Analytics.AddRange( + new Analytic + { + Id = 1, + UserId = 1, + Pathname = "/search", + Search = "Query=cardiology", + }, + new Analytic + { + Id = 2, + UserId = 2, + Pathname = "/search", + Search = "Query=finance", + } + ); + await context.SaveChangesAsync(); + + var service = new UsersApiService( + context, + new ConfigurationBuilder().AddInMemoryCollection().Build(), + new MemoryCache(new MemoryCacheOptions()) + ); + var controller = BuildController(service, BuildPrincipal(userId: 1, username: "viewer")); + + var result = await controller.GetSearchHistory(); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsAssignableFrom>(ok.Value); + var item = Assert.Single(payload); + Assert.Equal("cardiology", item.SearchString); + } + + [Fact] + public async Task CreateFolder_CreatesFolderForCurrentUser() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "users-api-create-folder") + .Options; + + await using var context = new Atlas_WebContext(options); + context.Users.Add(new User { UserId = 1, Username = "viewer", FullnameCalc = "Viewer Name" }); + await context.SaveChangesAsync(); + + var service = new UsersApiService( + context, + new ConfigurationBuilder().AddInMemoryCollection().Build(), + new MemoryCache(new MemoryCacheOptions()) + ); + var controller = BuildController(service, BuildPrincipal(userId: 1, username: "viewer")); + + var result = await controller.CreateFolder( + new CreateUserFavoriteFolderRequestDto { Name = "Saved Reports" } + ); + + var created = Assert.IsType(result.Result); + var payload = Assert.IsType(created.Value); + Assert.Equal("Saved Reports", payload.Name); + + var folder = Assert.Single(context.UserFavoriteFolders); + Assert.Equal(1, folder.UserId); + Assert.Equal("Saved Reports", folder.FolderName); + } + + [Fact] + public async Task GetStars_ReturnsFoldersAndFavoriteItems() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "users-api-stars") + .Options; + + await using var context = new Atlas_WebContext(options); + context.Users.Add(new User { UserId = 1, Username = "viewer", FullnameCalc = "Viewer Name" }); + context.UserFavoriteFolders.Add( + new UserFavoriteFolder { UserFavoriteFolderId = 5, UserId = 1, FolderName = "Pinned" } + ); + context.StarredSearches.Add( + new StarredSearch + { + StarId = 9, + Ownerid = 1, + Folderid = 5, + Rank = 3, + Search = "Query=finance", + } + ); + await context.SaveChangesAsync(); + + var service = new UsersApiService( + context, + new ConfigurationBuilder().AddInMemoryCollection().Build(), + new MemoryCache(new MemoryCacheOptions()) + ); + var controller = BuildController(service, BuildPrincipal(userId: 1, username: "viewer")); + + var result = await controller.GetStars(1); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(ok.Value); + var folder = Assert.Single(payload.Folders); + var item = Assert.Single(payload.Items); + + Assert.True(payload.IsCurrentUser); + Assert.True(payload.CanEditWorkspace); + Assert.True(payload.Permissions.CanCreateFolders); + Assert.True(payload.Permissions.CanReorderFavorites); + Assert.Equal(1, payload.Summary.TotalCount); + Assert.False(payload.Summary.ShowUnsortedBucket); + Assert.False(payload.Filters.ShowQuickFilters); + Assert.Equal("Pinned", folder.Name); + Assert.Equal(1, folder.ItemCount); + Assert.True(folder.CanManage); + Assert.True(folder.CanReorder); + Assert.Equal("search", item.Type); + Assert.Equal(5, item.FolderId); + Assert.Equal("Pinned", item.FolderName); + Assert.Equal("/search?Query=finance", item.Url); + Assert.Equal("finance", item.SearchString); + Assert.True(item.CanReorder); + } + + [Fact] + public async Task GetStars_ReturnsSnippetParityFieldsForReportInitiativeAndTerm() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "users-api-stars-snippet-parity") + .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 = "other", FullnameCalc = "Other Name" } + ); + context.ReportObjectTypes.Add( + new ReportObjectType + { + ReportObjectTypeId = 3, + Name = "SSRS Report", + ShortName = "SSRS", + Visible = "Y", + } + ); + context.Tags.Add( + new Tag { TagId = 4, Name = "Analytics Certified", ShowInHeader = "Y" } + ); + context.Initiatives.Add( + new Initiative { InitiativeId = 8, Name = "Quality", Description = "Initiative body" } + ); + context.Collections.Add( + new Collection + { + CollectionId = 9, + InitiativeId = 8, + Name = "Operations", + Description = "Collection body", + } + ); + context.Terms.Add( + new Term + { + TermId = 12, + Name = "Census", + Summary = "Approved term summary", + ApprovedYn = "Y", + } + ); + context.ReportObjects.Add( + new ReportObject + { + ReportObjectId = 6, + Name = "Revenue Report", + DisplayTitle = "Revenue Snapshot", + Description = "Report body", + ReportObjectTypeId = 3, + ReportObjectType = context.ReportObjectTypes.Local.Single(x => x.ReportObjectTypeId == 3), + SourceDb = "warehouse", + SourceTable = "finance.revenue", + ReportServerPath = "/Finance/Revenue", + SourceServer = "reports", + } + ); + context.ReportObjectDocs.Add( + new ReportObjectDoc + { + ReportObjectId = 6, + DeveloperDescription = "Developer summary for the report", + EnabledForHyperspace = "Y", + } + ); + context.ReportTagLinks.Add( + new ReportTagLink + { + ReportTagLinkId = 7, + ReportId = 6, + TagId = 4, + ShowInHeader = "Y", + } + ); + context.StarredReports.AddRange( + new StarredReport { StarId = 21, Ownerid = 1, Reportid = 6 }, + new StarredReport { StarId = 22, Ownerid = 2, Reportid = 6 } + ); + context.StarredInitiatives.Add( + new StarredInitiative { StarId = 23, Ownerid = 1, Initiativeid = 8 } + ); + context.StarredTerms.Add(new StarredTerm { StarId = 24, Ownerid = 1, Termid = 12 }); + await context.SaveChangesAsync(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["AppSettings:org_domain"] = "example.org", + ["features:enable_sharing"] = "true", + ["features:enable_request_access"] = "true", + } + ) + .Build(); + + var service = new UsersApiService( + context, + config, + new MemoryCache(new MemoryCacheOptions()) + ); + var controller = BuildController( + service, + BuildPrincipal(userId: 1, username: "viewer", permissions: new[] { "Open In Editor" }) + ); + + var result = await controller.GetStars(1); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(ok.Value); + var report = Assert.Single(payload.Items.Where(x => x.Type == "report")); + var initiative = Assert.Single(payload.Items.Where(x => x.Type == "initiative")); + var term = Assert.Single(payload.Items.Where(x => x.Type == "term")); + + Assert.Equal("Revenue Snapshot", report.Name); + Assert.Equal("SSRS", report.TypeLabel); + Assert.True(report.IsCertified); + Assert.Equal(2, report.StarCount); + Assert.True(report.CanOpenProfile); + Assert.Equal("report-profile-6", report.ProfileTargetId); + Assert.True(report.CanShare); + Assert.Equal("report-share-6", report.ShareTargetId); + Assert.True(report.CanEditInEditor); + Assert.Equal( + "reportbuilder:Action=Edit&ItemPath=%2FFinance%2FRevenue&Endpoint=https%3A%2F%2Freports.example.org%3A443%2FReportServer", + report.EditUrl + ); + Assert.Equal( + "https://reports.example.org/Reports/manage/catalogitem/properties/Finance/Revenue", + report.ManageUrl + ); + Assert.Contains(report.Tags, x => x.Name == "Analytics Certified" && x.ShowInHeader); + Assert.Equal("Developer summary for the report... ", report.BodyText); + + Assert.Equal("initiative", initiative.TypeLabel); + Assert.Contains("Operations", initiative.RelatedCollectionNames); + Assert.Equal("Initiative body... ", initiative.BodyText); + Assert.True(initiative.CanShare); + + Assert.Equal("term", term.TypeLabel); + Assert.True(term.IsApproved); + Assert.Equal("Approved term summary... ", term.BodyText); + Assert.True(term.CanOpenProfile); + Assert.Equal("term-profile-12", term.ProfileTargetId); + } + + [Fact] + public async Task UpdateFavoriteFolder_MovesFavoriteIntoFolder() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "users-api-move-favorite") + .Options; + + await using var context = new Atlas_WebContext(options); + context.Users.Add(new User { UserId = 1, Username = "viewer", FullnameCalc = "Viewer Name" }); + context.UserFavoriteFolders.Add( + new UserFavoriteFolder { UserFavoriteFolderId = 7, UserId = 1, FolderName = "Saved" } + ); + context.StarredSearches.Add( + new StarredSearch + { + StarId = 11, + Ownerid = 1, + Folderid = null, + Search = "Query=quality", + } + ); + await context.SaveChangesAsync(); + + var service = new UsersApiService( + context, + new ConfigurationBuilder().AddInMemoryCollection().Build(), + new MemoryCache(new MemoryCacheOptions()) + ); + var controller = BuildController(service, BuildPrincipal(userId: 1, username: "viewer")); + + var result = await controller.UpdateFavoriteFolder( + new UpdateUserFavoriteFolderAssignmentRequestDto + { + FavoriteId = 11, + FavoriteType = "search", + FolderId = 7, + } + ); + + Assert.IsType(result); + Assert.Equal(7, context.StarredSearches.Single().Folderid); + } + + [Fact] + public async Task RemoveSharedObject_DeletesUsersSharedItem() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "users-api-remove-share") + .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 = "other", FullnameCalc = "Other User" } + ); + context.SharedItems.Add( + new SharedItem + { + Id = 14, + SharedFromUserId = 1, + SharedToUserId = 2, + Name = "Revenue Report", + Url = "/reports?id=4", + } + ); + await context.SaveChangesAsync(); + + var service = new UsersApiService( + context, + new ConfigurationBuilder().AddInMemoryCollection().Build(), + new MemoryCache(new MemoryCacheOptions()) + ); + var controller = BuildController(service, BuildPrincipal(userId: 1, username: "viewer")); + + var result = await controller.RemoveSharedObject(14); + + Assert.IsType(result); + Assert.Empty(context.SharedItems); + } + + [Fact] + public async Task ToggleFavorite_TogglesSearchFavoriteAndReturnsCount() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "users-api-toggle-search-favorite") + .Options; + + await using var context = new Atlas_WebContext(options); + context.Users.Add(new User { UserId = 1, Username = "viewer", FullnameCalc = "Viewer Name" }); + await context.SaveChangesAsync(); + + var service = new UsersApiService( + context, + new ConfigurationBuilder().AddInMemoryCollection().Build(), + new MemoryCache(new MemoryCacheOptions()) + ); + var controller = BuildController(service, BuildPrincipal(userId: 1, username: "viewer")); + + var first = await controller.ToggleFavorite( + new ToggleUserFavoriteRequestDto { Type = "search", Search = "Query=finance" } + ); + var firstOk = Assert.IsType(first.Result); + var firstPayload = Assert.IsType(firstOk.Value); + Assert.True(firstPayload.IsStarred); + Assert.Equal(1, firstPayload.StarCount); + + var second = await controller.ToggleFavorite( + new ToggleUserFavoriteRequestDto { Type = "search", Search = "Query=finance" } + ); + var secondOk = Assert.IsType(second.Result); + var secondPayload = Assert.IsType(secondOk.Value); + Assert.False(secondPayload.IsStarred); + Assert.Equal(0, secondPayload.StarCount); + } + + [Fact] + public async Task ToggleAdminMode_CreatesAndRemovesAdminDisabledPreference() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "users-api-toggle-admin-mode") + .Options; + + await using var context = new Atlas_WebContext(options); + context.Users.Add(new User { UserId = 1, Username = "viewer", FullnameCalc = "Viewer Name" }); + await context.SaveChangesAsync(); + + var service = new UsersApiService( + context, + new ConfigurationBuilder().AddInMemoryCollection().Build(), + new MemoryCache(new MemoryCacheOptions()) + ); + var controller = BuildController( + service, + BuildPrincipal(userId: 1, username: "viewer", roles: new[] { "Administrator" }) + ); + + var first = await controller.ToggleAdminMode(); + var firstOk = Assert.IsType(first.Result); + var firstPayload = Assert.IsType(firstOk.Value); + Assert.Equal("N", firstPayload.AdminEnabled); + Assert.Single(context.UserPreferences); + + var second = await controller.ToggleAdminMode(); + var secondOk = Assert.IsType(second.Result); + var secondPayload = Assert.IsType(secondOk.Value); + Assert.Equal("Y", secondPayload.AdminEnabled); + Assert.Empty(context.UserPreferences); + } + + [Fact] + public async Task CreateFolderForUser_AllowsEditorToManageOtherUsersWorkspace() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "users-api-create-folder-other-user") + .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 = "target", FullnameCalc = "Target Name" } + ); + await context.SaveChangesAsync(); + + var service = new UsersApiService( + context, + new ConfigurationBuilder().AddInMemoryCollection().Build(), + new MemoryCache(new MemoryCacheOptions()) + ); + var controller = BuildController( + service, + BuildPrincipal(userId: 1, username: "viewer", permissions: new[] { "Edit Other Users" }) + ); + + var result = await controller.CreateFolderForUser( + 2, + new CreateUserFavoriteFolderRequestDto { Name = "Managed Folder" } + ); + + var created = Assert.IsType(result.Result); + var payload = Assert.IsType(created.Value); + Assert.Equal("Managed Folder", payload.Name); + Assert.Equal(2, Assert.Single(context.UserFavoriteFolders).UserId); + } + + [Fact] + public async Task ReorderFavoritesForUser_ForbidsEditingOtherUsersOrder() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "users-api-reorder-other-user-forbid") + .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 = "target", FullnameCalc = "Target Name" } + ); + await context.SaveChangesAsync(); + + var service = new UsersApiService( + context, + new ConfigurationBuilder().AddInMemoryCollection().Build(), + new MemoryCache(new MemoryCacheOptions()) + ); + var controller = BuildController( + service, + BuildPrincipal(userId: 1, username: "viewer", permissions: new[] { "Edit Other Users" }) + ); + + var result = await controller.ReorderFavoritesForUser( + 2, + new[] + { + new ReorderUserFavoriteItemDto + { + FavoriteId = "11", + FavoriteType = "search", + FavoriteRank = 1, + }, + } + ); + + Assert.IsType(result); + } + + private static UsersApiController BuildController( + IUsersApiService service, + ClaimsPrincipal principal + ) + { + var controller = new UsersApiController(service) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = principal }, + }, + }; + + return controller; + } + + private static ClaimsPrincipal BuildPrincipal( + int userId, + string username, + IEnumerable permissions = null, + IEnumerable roles = 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))); + } + + if (roles != null) + { + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + } + + return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + } +} diff --git a/web.Tests/IntegrationTests/Utilities/WebFactory.cs b/web.Tests/IntegrationTests/Utilities/WebFactory.cs index 640508fe..059f4b9f 100644 --- a/web.Tests/IntegrationTests/Utilities/WebFactory.cs +++ b/web.Tests/IntegrationTests/Utilities/WebFactory.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; using Atlas_Web.Models; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -17,6 +19,18 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Test"); + builder.ConfigureAppConfiguration((context, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["Jwt:Key"] = "test-jwt-secret-key-for-integration-tests-32-chars-minimum", + ["Jwt:Issuer"] = "atlas-test-issuer", + ["Jwt:Audience"] = "atlas-test-audience", + ["Cors:AllowedOrigins:0"] = "http://localhost:3000", + ["Auth:DefaultCallbackPath"] = "/auth/callback" + }); + }); + builder.ConfigureTestServices(services => { // Add InMemory database for testing diff --git a/web.Tests/web.Tests.csproj b/web.Tests/web.Tests.csproj index 135216a2..4ca14547 100644 --- a/web.Tests/web.Tests.csproj +++ b/web.Tests/web.Tests.csproj @@ -10,26 +10,26 @@ true - - - + + + - + - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/web/Authorization/DemoAuthHandler.cs b/web/Authorization/DemoAuthHandler.cs index 7d5d36ee..fc2d41dd 100644 --- a/web/Authorization/DemoAuthHandler.cs +++ b/web/Authorization/DemoAuthHandler.cs @@ -6,7 +6,10 @@ namespace Atlas_Web.Authentication { #pragma warning disable S2094 - public class DemoSchemeOptions : AuthenticationSchemeOptions { } + public class DemoSchemeOptions : AuthenticationSchemeOptions + { + public string Username { get; set; } = "Default"; + } public class DemoAuthHandler : AuthenticationHandler { @@ -20,14 +23,17 @@ ISystemClock clock protected override Task HandleAuthenticateAsync() { + var username = string.IsNullOrWhiteSpace(Options.Username) + ? "Default" + : Options.Username.Trim(); var claims = new[] { - new Claim(ClaimTypes.Name, "Default"), + new Claim(ClaimTypes.Name, username), new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()), }; - var identity = new ClaimsIdentity(claims, "Default"); + var identity = new ClaimsIdentity(claims, "Demo"); var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket(principal, "Default"); + var ticket = new AuthenticationTicket(principal, "Demo"); var result = AuthenticateResult.Success(ticket); diff --git a/web/Contracts/Api/Collections/CollectionDtos.cs b/web/Contracts/Api/Collections/CollectionDtos.cs new file mode 100644 index 00000000..3c57aba3 --- /dev/null +++ b/web/Contracts/Api/Collections/CollectionDtos.cs @@ -0,0 +1,121 @@ +using System.ComponentModel.DataAnnotations; + +namespace Atlas_Web.Contracts.Api.Collections; + +public sealed class CollectionListResponseDto +{ + public IReadOnlyList Collections { get; init; } = + Array.Empty(); + public int Total { get; init; } + public int Page { get; init; } + public int PageSize { get; init; } +} + +public sealed class CollectionListItemDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Description { get; init; } + public string Purpose { get; init; } + public string Hidden { get; init; } + public DateTime? LastModified { get; init; } + public int StarCount { get; init; } + public bool IsStarred { get; init; } +} + +public sealed class CollectionDetailDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Description { get; init; } + public string Purpose { get; init; } + public string Hidden { get; init; } + public DateTime? LastModified { get; init; } + public string LastModifiedDisplay { get; init; } + public bool IsStarred { get; init; } + public int StarCount { get; init; } + public bool CanCreateCollection { get; init; } + public bool CanEditCollection { get; init; } + public bool CanDeleteCollection { get; init; } + public bool CanViewUserProfiles { get; init; } + public CollectionFeatureFlagsDto Features { get; init; } + public CollectionUserSummaryDto LastUpdatedBy { get; init; } + public InitiativeSummaryDto Initiative { get; init; } + public IReadOnlyList Terms { get; init; } = Array.Empty(); + public IReadOnlyList Reports { get; init; } = + Array.Empty(); +} + +public sealed class CollectionFeatureFlagsDto +{ + public bool TermsEnabled { get; init; } + public bool UserProfilesEnabled { get; init; } + public bool FeedbackEnabled { get; init; } + public bool SharingEnabled { get; init; } +} + +public sealed class CollectionUserSummaryDto +{ + public int Id { get; init; } + public string Username { get; init; } + public string FullName { get; init; } + public string Email { get; init; } +} + +public sealed class InitiativeSummaryDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Description { get; init; } +} + +public sealed class CollectionTermDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Summary { get; init; } + public int? Rank { get; init; } +} + +public sealed class CollectionReportDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Description { get; init; } + public string Type { get; init; } + public string Url { get; init; } + public DateTime? LastModified { get; init; } + public int AttachmentCount { get; init; } + public int? Rank { get; init; } + public bool CanRun { get; set; } + public bool IsStarred { get; init; } +} + +public sealed class CollectionSearchResultDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Description { get; init; } +} + +public sealed class CreateCollectionRequestDto +{ + [Required] + public string Name { get; init; } + public string Description { get; init; } + public string Purpose { get; init; } + public string Hidden { get; init; } + public IReadOnlyList TermIds { get; init; } = Array.Empty(); + public IReadOnlyList ReportIds { get; init; } = Array.Empty(); +} + +public sealed class UpdateCollectionRequestDto +{ + [Required] + public string Name { get; init; } + public string Description { get; init; } + public string Purpose { get; init; } + public string Hidden { get; init; } + public IReadOnlyList TermIds { get; init; } = Array.Empty(); + public IReadOnlyList ReportIds { get; init; } = Array.Empty(); +} diff --git a/web/Contracts/Api/Interactions/InteractionDtos.cs b/web/Contracts/Api/Interactions/InteractionDtos.cs new file mode 100644 index 00000000..42da668e --- /dev/null +++ b/web/Contracts/Api/Interactions/InteractionDtos.cs @@ -0,0 +1,55 @@ +namespace Atlas_Web.Contracts.Api.Interactions; + +public sealed class ToggleStarRequestDto +{ + public string Type { get; init; } + public int? Id { get; init; } +} + +public sealed class ToggleStarResponseDto +{ + public string Type { get; init; } + public int Id { get; init; } + public bool IsStarred { get; init; } + public int Count { get; init; } +} + +public sealed class ShareMailRequestDto +{ + public int? DraftId { get; init; } + public IReadOnlyList To { get; init; } = Array.Empty(); + public string Subject { get; init; } + public string Message { get; init; } + public string Text { get; init; } + public bool Share { get; init; } + public string ShareName { get; init; } + public string ShareUrl { get; init; } +} + +public sealed class ShareRecipientDto +{ + public int? UserId { get; init; } + public string Type { get; init; } +} + +public sealed class ShareMailResponseDto +{ + public string Message { get; init; } + public int RecipientCount { get; init; } + public int ShareCount { get; init; } +} + +public sealed class ShareFeedbackRequestDto +{ + public string ReportName { get; init; } + public string ReportUrl { get; init; } + public string Description { get; init; } +} + +public sealed class RecipientSearchResultDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Type { get; init; } + public string Email { get; init; } +} diff --git a/web/Contracts/Api/Profile/ProfileDtos.cs b/web/Contracts/Api/Profile/ProfileDtos.cs new file mode 100644 index 00000000..2e8c31b2 --- /dev/null +++ b/web/Contracts/Api/Profile/ProfileDtos.cs @@ -0,0 +1,58 @@ +namespace Atlas_Web.Contracts.Api.Profile; + +public sealed class ProfileChartResponseDto +{ + public int Runs { get; init; } + public int Users { get; init; } + public double RunTime { get; init; } + public IReadOnlyList History { get; init; } = + Array.Empty(); +} + +public sealed class ProfileRunHistoryPointDto +{ + public string Date { get; init; } + public int Runs { get; init; } + public int Users { get; init; } + public double RunTime { get; init; } +} + +public sealed class ProfileBarItemDto +{ + public string Key { get; init; } + public string Href { get; init; } + public string TitleOne { get; init; } + public string TitleTwo { get; init; } + public string Date { get; init; } + public string DateTitle { get; init; } + public double Count { get; init; } + public double? Percent { get; init; } +} + +public sealed class ProfileRunListItemDto +{ + public string Name { get; init; } + public string Type { get; init; } + public string Url { get; init; } + public int Runs { get; init; } + public string LastRun { get; init; } +} + +public sealed class ProfileStarUserDto +{ + public int Id { get; init; } + public string FullName { get; init; } + public string Email { get; init; } +} + +public sealed class ProfileSubscriptionDto +{ + public int Id { get; init; } + public int? UserId { get; init; } + public string UserName { get; init; } + public string EmailList { get; init; } + public string Description { get; init; } + public string LastStatus { get; init; } + public DateTime? LastRunTime { get; init; } + public string SubscriptionTo { get; init; } +} diff --git a/web/Contracts/Api/Profile/ProfileQueryRequestDto.cs b/web/Contracts/Api/Profile/ProfileQueryRequestDto.cs new file mode 100644 index 00000000..7adf3e7e --- /dev/null +++ b/web/Contracts/Api/Profile/ProfileQueryRequestDto.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Atlas_Web.Contracts.Api.Profile; + +public sealed class ProfileQueryRequestDto +{ + [FromQuery(Name = "id")] + public int Id { get; init; } + + [FromQuery(Name = "type")] + public string Type { get; init; } + + [FromQuery(Name = "start_at")] + public double StartAt { get; init; } = -31536000; + + [FromQuery(Name = "end_at")] + public double EndAt { get; init; } + + [FromQuery(Name = "server")] + public List Server { get; init; } + + [FromQuery(Name = "database")] + public List Database { get; init; } + + [FromQuery(Name = "masterFile")] + public List MasterFile { get; init; } + + [FromQuery(Name = "visible")] + public List Visible { get; init; } + + [FromQuery(Name = "certification")] + public List Certification { get; init; } + + [FromQuery(Name = "availability")] + public List Availability { get; init; } + + [FromQuery(Name = "reportType")] + public List ReportType { get; init; } +} diff --git a/web/Contracts/Api/Reports/ReportDtos.cs b/web/Contracts/Api/Reports/ReportDtos.cs new file mode 100644 index 00000000..d3621fd1 --- /dev/null +++ b/web/Contracts/Api/Reports/ReportDtos.cs @@ -0,0 +1,302 @@ +namespace Atlas_Web.Contracts.Api.Reports; + +public sealed class ReportListResponseDto +{ + public IReadOnlyList Reports { get; init; } = Array.Empty(); + public int Total { get; init; } + public int Page { get; init; } + public int PageSize { get; init; } +} + +public sealed class ReportListItemDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Description { get; init; } + public string Type { get; init; } + public string Url { get; init; } + public DateTime? LastModified { get; init; } + public bool CanRun { get; set; } +} + +public sealed class ReportDetailDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string DisplayTitle { get; init; } + public string DisplayName { get; init; } + public string Description { get; init; } + public string DetailedDescription { get; init; } + public string TypeName { get; init; } + public string TypeShortName { get; init; } + public string Url { get; init; } + public string EpicMasterFile { get; init; } + public decimal? EpicRecordId { get; init; } + public decimal? EpicReportTemplateId { get; init; } + public string ReportServerPath { get; init; } + public string Availability { get; init; } + public bool VisibleInSearch { get; init; } + public string OrphanedReportObjectYn { get; init; } + public string RepositoryDescription { get; init; } + public int? Runs { get; init; } + public DateTime? LastModified { get; init; } + public DateTime? LastLoadDate { get; init; } + public bool CanRun { get; set; } + public bool CanEditDocumentation { get; set; } + public bool CanViewGroups { get; set; } + public bool CanViewUserProfiles { get; set; } + public bool IsStarred { get; init; } + public string RunUrl { get; set; } + public string RecordViewerUrl { get; set; } + public string EditReportUrl { get; set; } + public string ManageReportUrl { get; set; } + public ReportFeatureFlagsDto Features { get; set; } + public UserSummaryDto Author { get; init; } + public UserSummaryDto LastModifiedBy { get; init; } + public ReportDocumentDto Document { get; set; } + public IReadOnlyList HeaderTags { get; init; } = Array.Empty(); + public IReadOnlyList ObjectTags { get; init; } = + Array.Empty(); + public IReadOnlyList Attachments { get; init; } = + Array.Empty(); + public IReadOnlyList Images { get; init; } = Array.Empty(); + public IReadOnlyList Groups { get; set; } = Array.Empty(); + public IReadOnlyList Collections { get; init; } = + Array.Empty(); + public IReadOnlyList Parameters { get; set; } = + Array.Empty(); + public IReadOnlyList Queries { get; set; } = Array.Empty(); + public IReadOnlyList ComponentQueries { get; set; } = + Array.Empty(); + public IReadOnlyList Terms { get; set; } = Array.Empty(); + public IReadOnlyList Children { get; set; } = + Array.Empty(); + public IReadOnlyList Parents { get; set; } = + Array.Empty(); + public ReportMaintenanceStatusDto MaintenanceStatus { get; set; } + public int StarCount { get; init; } +} + +public sealed class ReportFeatureFlagsDto +{ + public bool TermsEnabled { get; init; } + public bool UserProfilesEnabled { get; init; } + public bool FeedbackEnabled { get; init; } + public bool RequestAccessEnabled { get; init; } + public bool SharingEnabled { get; init; } +} + +public sealed class ReportDocumentDto +{ + public int ReportObjectId { get; init; } + public string GitLabProjectUrl { get; init; } + public string DeveloperDescription { get; init; } + public string KeyAssumptions { get; init; } + public string ExecutiveVisibilityYn { get; init; } + public DateTime? LastUpdateDateTime { get; init; } + public DateTime? CreatedDateTime { get; init; } + public string EnabledForHyperspace { get; init; } + public string DoNotPurge { get; init; } + public string Hidden { get; init; } + public string DeveloperNotes { get; init; } + public LookupDto OrganizationalValue { get; init; } + public LookupDto EstimatedRunFrequency { get; init; } + public LookupDto Fragility { get; init; } + public LookupDto MaintenanceSchedule { get; init; } + public UserSummaryDto OperationalOwner { get; init; } + public UserSummaryDto Requester { get; init; } + public UserSummaryDto UpdatedBy { get; init; } + public IReadOnlyList FragilityTags { get; init; } = Array.Empty(); + public IReadOnlyList MaintenanceLogs { get; init; } = + Array.Empty(); + public IReadOnlyList ServiceRequests { get; init; } = + Array.Empty(); +} + +public sealed class UserSummaryDto +{ + public int Id { get; init; } + public string Username { get; init; } + public string FullName { get; init; } + public string Email { get; init; } +} + +public sealed class LookupDto +{ + public int Id { get; init; } + public string Name { get; init; } +} + +public sealed class ReportTagDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Description { get; init; } + public int? Priority { get; init; } + public string ShowInHeader { get; init; } +} + +public sealed class ReportObjectTagDto +{ + public int Id { get; init; } + public string Name { get; init; } + public int? Line { get; init; } +} + +public sealed class ReportAttachmentDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Path { get; init; } + public string Source { get; init; } + public string Type { get; init; } + public DateTime? CreationDate { get; init; } + public string RunUrl { get; set; } +} + +public sealed class ReportImageDto +{ + public int Id { get; init; } + public int Ordinal { get; init; } + public string Source { get; init; } +} + +public sealed class GroupSummaryDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Email { get; init; } + public string Type { get; init; } +} + +public sealed class CollectionSummaryDto +{ + public int Id { get; init; } + public string Name { get; init; } + public int? Rank { get; init; } +} + +public sealed class ReportParameterDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Value { get; init; } +} + +public sealed class ReportQueryDto +{ + public int Id { get; init; } + public int ReportObjectId { get; init; } + public string Name { get; init; } + public string Language { get; init; } + public string SourceServer { get; init; } + public string Query { get; init; } + public DateTime? LastLoadDate { get; init; } +} + +public sealed class TermSummaryDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Summary { get; init; } +} + +public sealed class ReportLinkSummaryDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Type { get; init; } + public string Url { get; init; } + public DateTime? LastModified { get; init; } + public int AttachmentCount { get; init; } +} + +public sealed class ReportMaintenanceLogDto +{ + public int Id { get; init; } + public DateTime? MaintenanceDate { get; init; } + public string Comment { get; init; } + public LookupDto Status { get; init; } + public UserSummaryDto Maintainer { get; init; } +} + +public sealed class ReportServiceRequestDto +{ + public int Id { get; init; } + public string TicketNumber { get; init; } + public string Description { get; init; } + public string TicketUrl { get; init; } +} + +public sealed class ReportMaintenanceStatusDto +{ + public bool IsRequired { get; init; } + public string Message { get; init; } + public DateTime? LastMaintenanceDate { get; init; } + public DateTime? NextMaintenanceDate { get; init; } + public LookupDto Schedule { get; init; } +} + +public sealed class ReportQueriesResponseDto +{ + public IReadOnlyList Queries { get; init; } = Array.Empty(); + public IReadOnlyList ComponentQueries { get; init; } = + Array.Empty(); +} + +public sealed class ReportRelationshipsResponseDto +{ + public bool CanViewGroups { get; init; } + public IReadOnlyList Groups { get; init; } = Array.Empty(); + public IReadOnlyList Collections { get; init; } = + Array.Empty(); + public IReadOnlyList Children { get; init; } = + Array.Empty(); + public IReadOnlyList Parents { get; init; } = + Array.Empty(); +} + +public sealed class UpdateReportDocumentRequestDto +{ + public string GitLabProjectUrl { get; init; } + public string DeveloperDescription { get; init; } + public string KeyAssumptions { get; init; } + public int? OperationalOwnerUserId { get; init; } + public int? RequesterUserId { get; init; } + public int? OrganizationalValueId { get; init; } + public int? EstimatedRunFrequencyId { get; init; } + public int? FragilityId { get; init; } + public string ExecutiveVisibilityYn { get; init; } + public int? MaintenanceScheduleId { get; init; } + public string EnabledForHyperspace { get; init; } + public string DoNotPurge { get; init; } + public string Hidden { get; init; } + public string DeveloperNotes { get; init; } + public IReadOnlyList TermIds { get; init; } = Array.Empty(); + public IReadOnlyList CollectionIds { get; init; } = Array.Empty(); + public IReadOnlyList FragilityTagIds { get; init; } = Array.Empty(); + public IReadOnlyList ImageIds { get; init; } = Array.Empty(); + public IReadOnlyList ServiceRequestIds { get; init; } = Array.Empty(); + public NewReportServiceRequestDto NewServiceRequest { get; init; } + public NewMaintenanceLogDto NewMaintenanceLog { get; init; } +} + +public sealed class NewReportServiceRequestDto +{ + public string TicketNumber { get; init; } + public string Description { get; init; } + public string TicketUrl { get; init; } +} + +public sealed class NewMaintenanceLogDto +{ + public int? MaintenanceLogStatusId { get; init; } + public string Comment { get; init; } +} + +public sealed class ReportSearchResultDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Description { get; init; } +} diff --git a/web/Contracts/Api/Search/SearchDtos.cs b/web/Contracts/Api/Search/SearchDtos.cs new file mode 100644 index 00000000..64cd06f5 --- /dev/null +++ b/web/Contracts/Api/Search/SearchDtos.cs @@ -0,0 +1,66 @@ +namespace Atlas_Web.Contracts.Api.Search; + +public sealed class SearchResponseDto +{ + public IReadOnlyList Results { get; init; } = []; + public IReadOnlyList Facets { get; init; } = []; + public IReadOnlyList Highlights { get; init; } = []; + public IReadOnlyList FilterFields { get; init; } = []; + public long Total { get; init; } + public int Page { get; init; } + public int PageSize { get; init; } + public int QTime { get; init; } + public bool IsAdvancedSearch { get; init; } +} + +public sealed class SearchResultDto +{ + public string Id { get; init; } + public int AtlasId { get; init; } + public string Type { get; init; } + public string Name { get; init; } + public string Description { get; init; } + public string Url { get; init; } + public string ReportType { get; init; } + public string Email { get; init; } + public string EpicMasterFile { get; init; } + public string EpicRecordId { get; init; } + public string EpicTemplateId { get; init; } + public string ReportServerPath { get; init; } + public string ExecutiveVisibility { get; init; } + public string SourceServer { get; init; } + public string GroupType { get; init; } + public bool IsStarred { get; set; } + public IReadOnlyList Certifications { get; init; } = []; + public string Documented { get; init; } +} + +public sealed class FacetDto +{ + public string Key { get; init; } + public IReadOnlyList Values { get; init; } = []; +} + +public sealed class FacetValueDto +{ + public string Value { get; init; } + public int Count { get; init; } +} + +public sealed class HighlightDto +{ + public string Id { get; init; } + public IReadOnlyList Fields { get; init; } = []; +} + +public sealed class HighlightFieldDto +{ + public string Field { get; init; } + public string Snippet { get; init; } +} + +public sealed class FilterFieldDto +{ + public string Key { get; init; } + public string Label { get; init; } +} diff --git a/web/Contracts/Api/Users/UserDtos.cs b/web/Contracts/Api/Users/UserDtos.cs new file mode 100644 index 00000000..bdc3f745 --- /dev/null +++ b/web/Contracts/Api/Users/UserDtos.cs @@ -0,0 +1,311 @@ +using System.ComponentModel.DataAnnotations; + +namespace Atlas_Web.Contracts.Api.Users; + +public sealed class UserPageDto +{ + public UserPageUserDto User { get; init; } + public UserPageViewerDto Viewer { get; init; } + public UserPagePermissionsDto Permissions { get; init; } + public UserPageTabsDto Tabs { get; init; } + public UserPageFeaturesDto Features { get; init; } + public IReadOnlyList DefaultReportTypeIds { get; init; } = Array.Empty(); +} + +public sealed class UserPageUserDto +{ + public int Id { get; init; } + public string Username { get; init; } + public string FullName { get; init; } + public string FirstName { get; init; } + public string DisplayName { get; init; } + public string Email { get; init; } + public string Department { get; init; } + public string Title { get; init; } + public string Phone { get; init; } + public string ProfilePhoto { get; init; } +} + +public sealed class UserPageViewerDto +{ + public int Id { get; init; } + public bool IsCurrentUser { get; init; } + public bool IsAdministrator { get; init; } + public string AdminEnabled { get; init; } +} + +public sealed class UserPagePermissionsDto +{ + public bool CanViewOtherUsers { get; init; } + public bool CanViewGroups { get; init; } + public bool CanViewAnalytics { get; init; } + public bool CanEditOtherUsers { get; init; } + public bool CanToggleAdminMode { get; init; } + public bool CanEditWorkspace { get; init; } +} + +public sealed class UserPageTabsDto +{ + public bool StarsVisible { get; init; } + public bool SubscriptionsVisible { get; init; } + public bool ActivityVisible { get; init; } + public bool RunListVisible { get; init; } + public bool AtlasHistoryVisible { get; init; } + public bool GroupsVisible { get; init; } + public bool AnalyticsVisible { get; init; } +} + +public sealed class UserPageFeaturesDto +{ + public bool UserProfilesEnabled { get; init; } +} + +public sealed class UserGroupDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Type { get; init; } + public string Source { get; init; } +} + +public sealed class UserSubscriptionDto +{ + public int? ReportId { get; init; } + public string Name { get; init; } + public string EmailList { get; init; } + public string Description { get; init; } + public string LastStatus { get; init; } + public string LastRun { get; init; } + public string SentTo { get; init; } +} + +public sealed class UserHistorySectionDto +{ + public IReadOnlyList AtlasHistory { get; init; } = + Array.Empty(); + public IReadOnlyList ReportEdits { get; init; } = + Array.Empty(); + public IReadOnlyList InitiativeEdits { get; init; } = + Array.Empty(); + public IReadOnlyList CollectionEdits { get; init; } = + Array.Empty(); + public IReadOnlyList TermEdits { get; init; } = + Array.Empty(); +} + +public sealed class UserHistoryItemDto +{ + public string Name { get; init; } + public string Type { get; init; } + public string Url { get; init; } + public string Date { get; init; } +} + +public sealed class UserSharedObjectsDto +{ + public IReadOnlyList SharedToMe { get; init; } = + Array.Empty(); + public IReadOnlyList SharedFromMe { get; init; } = + Array.Empty(); +} + +public sealed class UserSharedObjectDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string ShareDate { get; init; } + public string SharedFrom { get; init; } + public string Url { get; init; } +} + +public sealed class UserSearchHistoryItemDto +{ + public string SearchUrl { get; init; } + public string SearchString { get; init; } +} + +public sealed class UserStarsDto +{ + public int UserId { get; init; } + public int ViewerUserId { get; init; } + public bool IsCurrentUser { get; init; } + public bool CanEditWorkspace { get; init; } + public UserWorkspacePermissionsDto Permissions { get; init; } + public UserWorkspaceSummaryDto Summary { get; init; } + public UserWorkspaceFilterStateDto Filters { get; init; } + public IReadOnlyList Folders { get; init; } = + Array.Empty(); + public IReadOnlyList Items { get; init; } = + Array.Empty(); + public IReadOnlyList SuggestedReports { get; init; } = + Array.Empty(); +} + +public sealed class UserWorkspacePermissionsDto +{ + public bool CanCreateFolders { get; init; } + public bool CanRenameFolders { get; init; } + public bool CanDeleteFolders { get; init; } + public bool CanReorderFolders { get; init; } + public bool CanReorderFavorites { get; init; } + public bool CanMoveFavoritesToFolders { get; init; } + public bool CanToggleFavorites { get; init; } +} + +public sealed class UserWorkspaceSummaryDto +{ + public int TotalCount { get; init; } + public int UnsortedCount { get; init; } + public bool HasFolders { get; init; } + public bool ShowUnsortedBucket { get; init; } +} + +public sealed class UserWorkspaceFilterStateDto +{ + public bool HasReports { get; init; } + public bool HasCollections { get; init; } + public bool HasInitiatives { get; init; } + public bool HasTerms { get; init; } + public bool HasUsers { get; init; } + public bool HasGroups { get; init; } + public bool HasSearches { get; init; } + public bool ShowQuickFilters { get; init; } +} + +public sealed class UserFavoriteFolderDto +{ + public int Id { get; init; } + public string Name { get; init; } + public int? Rank { get; init; } + public int ItemCount { get; init; } + public bool CanManage { get; init; } + public bool CanReorder { get; init; } +} + +public sealed class UserFavoriteItemDto +{ + public int StarId { get; init; } + public string Type { get; init; } + public string TypeLabel { get; init; } + public int? FolderId { get; init; } + public int? Rank { get; init; } + public int? ItemId { get; init; } + public string Name { get; init; } + public string Description { get; init; } + public string Url { get; init; } + public string SecondaryText { get; init; } + public string FolderName { get; init; } + public int? FolderRank { get; init; } + public string SearchString { get; init; } + public bool CanReorder { get; init; } + public bool IsStarred { get; init; } + public int StarCount { get; init; } + public string BodyText { get; init; } + public string PlaceholderImageUrl { get; init; } + public string ThumbnailUrl { get; init; } + public string FullImageUrl { get; init; } + public bool IsCertified { get; init; } + public bool IsApproved { get; init; } + public bool CanOpenProfile { get; init; } + public string ProfileTargetId { get; init; } + public bool CanShare { get; init; } + public string ShareTargetId { get; init; } + public string ShareName { get; init; } + public string ShareType { get; init; } + public bool CanRequestAccess { get; init; } + public string RequestAccessTargetId { get; init; } + public bool CanRun { get; init; } + public string RunUrl { get; init; } + public bool OpensRunModal { get; init; } + public string RunModalTargetId { get; init; } + public string RunDisabledReason { get; init; } + public bool CanEditInEditor { get; init; } + public string EditUrl { get; init; } + public bool CanManageInEditor { get; init; } + public string ManageUrl { get; init; } + public string ReportObjectUrl { get; init; } + public string ReportServerPath { get; init; } + public string SourceServer { get; init; } + public string EpicMasterFile { get; init; } + public decimal? EpicRecordId { get; init; } + public decimal? EpicReportTemplateId { get; init; } + public string EnabledForHyperspace { get; init; } + public IReadOnlyList Tags { get; init; } = Array.Empty(); + public IReadOnlyList RelatedCollectionNames { get; init; } = Array.Empty(); +} + +public sealed class UserFavoriteTagDto +{ + public string Name { get; init; } + public string Slug { get; init; } + public bool ShowInHeader { get; init; } +} + +public sealed class UserSuggestedReportDto +{ + public int Id { get; init; } + public string Name { get; init; } + public string Description { get; init; } + public string Url { get; init; } + public string Type { get; init; } +} + +public sealed class CreateUserFavoriteFolderRequestDto +{ + [Required] + public string Name { get; init; } +} + +public sealed class UpdateUserFavoriteFolderRequestDto +{ + [Required] + public string Name { get; init; } +} + +public sealed class ReorderUserFavoriteFolderItemDto +{ + [Required] + public string FolderId { get; init; } + public int FolderRank { get; init; } +} + +public sealed class ReorderUserFavoriteItemDto +{ + [Required] + public string FavoriteId { get; init; } + + [Required] + public string FavoriteType { get; init; } + public int FavoriteRank { get; init; } +} + +public sealed class UpdateUserFavoriteFolderAssignmentRequestDto +{ + public int FavoriteId { get; init; } + + [Required] + public string FavoriteType { get; init; } + public int? FolderId { get; init; } +} + +public sealed class ToggleUserFavoriteRequestDto +{ + [Required] + public string Type { get; init; } + public int? Id { get; init; } + public string Search { get; init; } +} + +public sealed class ToggleUserFavoriteResponseDto +{ + public string Type { get; init; } + public int? Id { get; init; } + public string Search { get; init; } + public bool IsStarred { get; init; } + public int StarCount { get; init; } +} + +public sealed class ToggleAdminModeResponseDto +{ + public string AdminEnabled { get; init; } +} diff --git a/web/Controllers/Api/AuthApiController.cs b/web/Controllers/Api/AuthApiController.cs new file mode 100644 index 00000000..e1f34c7a --- /dev/null +++ b/web/Controllers/Api/AuthApiController.cs @@ -0,0 +1,220 @@ +#nullable enable +using System.Diagnostics.CodeAnalysis; +using System.Security.Claims; +using Atlas_Web.Models; +using Atlas_Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.WebUtilities; + +namespace Atlas_Web.Controllers.Api; + +[ApiController] +[Route("api/auth")] +public class AuthApiController : ControllerBase +{ + private readonly JwtTokenService _jwt; + private readonly Atlas_WebContext _context; + private readonly IConfiguration _config; + + public AuthApiController(JwtTokenService jwt, Atlas_WebContext context, IConfiguration config) + { + _jwt = jwt; + _context = context; + _config = config; + } + + [AllowAnonymous] + [HttpGet("login")] + [SuppressMessage("Security", "S5146:HTTP request redirections should not be open to forging attacks", Justification = "URL is validated against CORS allowlist in GetSafeRedirectUrl before redirect")] +#pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context - False positive: #nullable enable is set at file scope + public async Task Login([FromQuery] string? returnUrl = null) +#pragma warning restore CS8632 + { + var safeReturnUrlResult = GetSafeRedirectUrl(returnUrl); + if (safeReturnUrlResult is BadRequestObjectResult) + { + return safeReturnUrlResult; + } + + var safeReturnUrl = ((OkObjectResult)safeReturnUrlResult).Value as string ?? string.Empty; + + if (_config["Demo"] == "True") + { + var demoUsername = _config["DEMO_ADMIN_USERNAME"]; + var selectedDemoUsername = string.IsNullOrWhiteSpace(demoUsername) + ? "Default" + : demoUsername.Trim(); + var user = await _context.Users.FirstOrDefaultAsync( + x => x.Username == selectedDemoUsername + ); + + if (user == null) + { + return NotFound($"Demo user '{selectedDemoUsername}' not found."); + } + + var demoToken = _jwt.IssueToken( + user.Username ?? "Default", + user.FullnameCalc ?? "Guest", + user.UserId + ); + + return Redirect(BuildTokenRedirectUrl(safeReturnUrl, demoToken)); + } + + if (User.Identity?.IsAuthenticated != true) + { + return Redirect(BuildSamlLoginUrl(safeReturnUrl)); + } + + var apiUser = await ResolveAuthenticatedUserAsync(User); + if (apiUser == null) + { + return Unauthorized(new { error = "Authenticated SAML user could not be resolved." }); + } + + var token = _jwt.IssueToken( + apiUser.Username ?? User.Identity?.Name ?? "Guest", + apiUser.FullnameCalc ?? User.FindFirstValue("Fullname") ?? apiUser.Username ?? "Guest", + apiUser.UserId + ); + + return Redirect(BuildTokenRedirectUrl(safeReturnUrl, token)); + } + + private IActionResult GetSafeRedirectUrl(string? returnUrl) + { + var allowedOrigins = _config.GetSection("Cors:AllowedOrigins").Get() ?? Array.Empty(); + var defaultCallbackPath = _config["Auth:DefaultCallbackPath"]; + + if (string.IsNullOrWhiteSpace(defaultCallbackPath)) + { + return BadRequest("Auth:DefaultCallbackPath is not configured."); + } + + if (string.IsNullOrWhiteSpace(returnUrl)) + { + return BuildDefaultRedirectUrl(allowedOrigins, defaultCallbackPath); + } + + if (!Uri.TryCreate(returnUrl, UriKind.Absolute, out var parsedReturnUrl)) + { + return BuildDefaultRedirectUrl(allowedOrigins, defaultCallbackPath); + } + + if (IsUrlAllowed(parsedReturnUrl, allowedOrigins)) + { + return Ok(returnUrl); + } + + return BuildDefaultRedirectUrl(allowedOrigins, defaultCallbackPath); + } + + private bool IsUrlAllowed(Uri url, string[] allowedOrigins) + { + foreach (var origin in allowedOrigins) + { + if (Uri.TryCreate(origin, UriKind.Absolute, out var parsedOrigin) + && string.Equals(parsedOrigin.Scheme, url.Scheme, StringComparison.OrdinalIgnoreCase) + && string.Equals(parsedOrigin.Host, url.Host, StringComparison.OrdinalIgnoreCase) + && parsedOrigin.Port == url.Port) + { + return true; + } + } + return false; + } + + private IActionResult BuildDefaultRedirectUrl(string[] allowedOrigins, string defaultCallbackPath) + { + var defaultOrigin = allowedOrigins.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(defaultOrigin)) + { + return BadRequest("No allowed origins configured."); + } + + var safeUrl = defaultOrigin.TrimEnd('/') + defaultCallbackPath; + return Ok(safeUrl); + } + + private async Task ResolveAuthenticatedUserAsync(ClaimsPrincipal principal) + { + var userIdClaim = principal.FindFirstValue("UserId"); + if (int.TryParse(userIdClaim, out var userId)) + { + return await _context.Users.FirstOrDefaultAsync(x => x.UserId == userId); + } + + var identityName = principal.Identity?.Name; + if (!string.IsNullOrWhiteSpace(identityName)) + { + var user = await FindUserByIdentityAsync(identityName); + if (user != null) + { + return user; + } + } + + var email = principal.FindFirstValue(ClaimTypes.Email); + if (!string.IsNullOrWhiteSpace(email)) + { + return await FindUserByIdentityAsync(email); + } + + return null; + } + + private Task FindUserByIdentityAsync(string identity) + { + if (identity.Contains("@")) + { + return _context.Users.FirstOrDefaultAsync(x => x.Email == identity || x.Username == identity); + } + + return _context.Users.FirstOrDefaultAsync(x => x.Username == identity); + } + + private string BuildSamlLoginUrl(string safeReturnUrl) + { + var apiLoginUrl = QueryHelpers.AddQueryString( + Url.Content("~/api/auth/login"), + "returnUrl", + safeReturnUrl + ); + + return QueryHelpers.AddQueryString( + Url.Content("~/Auth/Login"), + "returnUrl", + apiLoginUrl + ); + } + + private static string BuildTokenRedirectUrl(string safeReturnUrl, string token) + { + return QueryHelpers.AddQueryString(safeReturnUrl, "token", token); + } + + [Authorize(AuthenticationSchemes = "Bearer")] + [HttpGet("me")] + public IActionResult Me() + { + return Ok(new + { + username = User.Identity?.Name, + fullname = User.FindFirst("Fullname")?.Value, + userId = User.FindFirst("UserId")?.Value, + roles = User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray(), + permissions = User.FindAll("Permission").Select(c => c.Value).ToArray(), + adminEnabled = User.FindFirst("AdminEnabled")?.Value == "Y", + }); + } + + [AllowAnonymous] + [HttpPost("logout")] + public IActionResult Logout() + { + return Ok(new { ok = true }); + } +} diff --git a/web/Controllers/Api/CollectionsApiController.cs b/web/Controllers/Api/CollectionsApiController.cs new file mode 100644 index 00000000..39bb1f0b --- /dev/null +++ b/web/Controllers/Api/CollectionsApiController.cs @@ -0,0 +1,158 @@ +using System.ComponentModel.DataAnnotations; +using Atlas_Web.Authorization; +using Atlas_Web.Contracts.Api.Collections; +using Atlas_Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Atlas_Web.Controllers.Api; + +[ApiController] +[Route("api/collections")] +[Authorize(AuthenticationSchemes = "Bearer")] +public class CollectionsApiController : ControllerBase +{ + private readonly ICollectionsApiService _collectionsApiService; + + public CollectionsApiController(ICollectionsApiService collectionsApiService) + { + _collectionsApiService = collectionsApiService; + } + + [HttpGet] + public async Task> GetCollections( + [FromQuery] + [Range(1, int.MaxValue)] + int page = 1, + [FromQuery] + [Range(1, 100)] + int pageSize = 20, + CancellationToken cancellationToken = default + ) + { + var response = await _collectionsApiService.GetCollectionsAsync( + User, + page, + pageSize, + cancellationToken + ); + return Ok(response); + } + + [HttpGet("{id:int}")] + public async Task> GetCollection( + int id, + CancellationToken cancellationToken = default + ) + { + var collection = await _collectionsApiService.GetCollectionAsync( + User, + id, + cancellationToken + ); + if (collection == null) + { + return NotFound(); + } + + return Ok(collection); + } + + [HttpPost] + public async Task> CreateCollection( + [FromBody] CreateCollectionRequestDto request, + CancellationToken cancellationToken = default + ) + { + if (!User.HasPermission("Create Collection")) + { + return Forbid(); + } + + try + { + var collection = await _collectionsApiService.CreateCollectionAsync( + User, + request, + cancellationToken + ); + + return CreatedAtAction(nameof(GetCollection), new { id = collection.Id }, collection); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpPut("{id:int}")] + public async Task> UpdateCollection( + int id, + [FromBody] UpdateCollectionRequestDto request, + CancellationToken cancellationToken = default + ) + { + if (!User.HasPermission("Edit Collection")) + { + return Forbid(); + } + + try + { + var collection = await _collectionsApiService.UpdateCollectionAsync( + User, + id, + request, + cancellationToken + ); + if (collection == null) + { + return NotFound(); + } + + return Ok(collection); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpDelete("{id:int}")] + public async Task DeleteCollection( + int id, + CancellationToken cancellationToken = default + ) + { + if (!User.HasPermission("Delete Collection")) + { + return Forbid(); + } + + var deleted = await _collectionsApiService.DeleteCollectionAsync(id, cancellationToken); + if (!deleted) + { + return NotFound(); + } + + return NoContent(); + } + + [HttpGet("search/terms")] + public async Task>> SearchTerms( + [FromQuery] string q, + CancellationToken cancellationToken = default + ) + { + return Ok(await _collectionsApiService.SearchTermsAsync(q, cancellationToken)); + } + + [HttpGet("search/reports")] + public async Task>> SearchReports( + [FromQuery] string q, + CancellationToken cancellationToken = default + ) + { + return Ok(await _collectionsApiService.SearchReportsAsync(q, cancellationToken)); + } +} diff --git a/web/Controllers/Api/InteractionsApiController.cs b/web/Controllers/Api/InteractionsApiController.cs new file mode 100644 index 00000000..54364711 --- /dev/null +++ b/web/Controllers/Api/InteractionsApiController.cs @@ -0,0 +1,97 @@ +using Atlas_Web.Contracts.Api.Interactions; +using Atlas_Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Atlas_Web.Controllers.Api; + +[ApiController] +[Route("api/interactions")] +[Authorize(AuthenticationSchemes = "Bearer")] +public class InteractionsApiController : ControllerBase +{ + private readonly IInteractionsApiService _interactionsApiService; + + public InteractionsApiController(IInteractionsApiService interactionsApiService) + { + _interactionsApiService = interactionsApiService; + } + + [HttpPost("stars/toggle")] + public async Task> ToggleStar( + [FromBody] ToggleStarRequestDto request, + CancellationToken cancellationToken = default + ) + { + try + { + var response = await _interactionsApiService.ToggleStarAsync( + User, + request, + cancellationToken + ); + if (response == null) + { + return NotFound(); + } + + return Ok(response); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpPost("share-mail")] + public async Task> ShareMail( + [FromBody] ShareMailRequestDto request, + CancellationToken cancellationToken = default + ) + { + try + { + return Ok( + await _interactionsApiService.SendShareMailAsync(User, request, cancellationToken) + ); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpPost("feedback")] + public async Task ShareFeedback( + [FromBody] ShareFeedbackRequestDto request, + CancellationToken cancellationToken = default + ) + { + try + { + return Ok( + await _interactionsApiService.SendFeedbackAsync(User, request, cancellationToken) + ); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpGet("search/recipients")] + public async Task>> SearchRecipients( + [FromQuery] string q, + [FromQuery] bool includeGroups = true, + CancellationToken cancellationToken = default + ) + { + return Ok( + await _interactionsApiService.SearchRecipientsAsync( + q, + includeGroups, + cancellationToken + ) + ); + } +} diff --git a/web/Controllers/Api/ProfileApiController.cs b/web/Controllers/Api/ProfileApiController.cs new file mode 100644 index 00000000..8bfadb6a --- /dev/null +++ b/web/Controllers/Api/ProfileApiController.cs @@ -0,0 +1,86 @@ +using Atlas_Web.Contracts.Api.Profile; +using Atlas_Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Atlas_Web.Controllers.Api; + +[ApiController] +[Route("api/profile")] +[Authorize(AuthenticationSchemes = "Bearer")] +public class ProfileApiController : ControllerBase +{ + private readonly IProfileApiService _profileApiService; + + public ProfileApiController(IProfileApiService profileApiService) + { + _profileApiService = profileApiService; + } + + [HttpGet("chart")] + public async Task> GetChart( + [FromQuery] ProfileQueryRequestDto request, + CancellationToken cancellationToken = default + ) + { + return Ok(await _profileApiService.GetChartAsync(request, cancellationToken)); + } + + [HttpGet("users")] + public async Task>> GetUsers( + [FromQuery] ProfileQueryRequestDto request, + CancellationToken cancellationToken = default + ) + { + return Ok(await _profileApiService.GetUsersAsync(request, cancellationToken)); + } + + [HttpGet("reports")] + public async Task>> GetReports( + [FromQuery] ProfileQueryRequestDto request, + CancellationToken cancellationToken = default + ) + { + return Ok(await _profileApiService.GetReportsAsync(request, cancellationToken)); + } + + [HttpGet("fails")] + public async Task>> GetFails( + [FromQuery] ProfileQueryRequestDto request, + CancellationToken cancellationToken = default + ) + { + return Ok(await _profileApiService.GetFailsAsync(request, cancellationToken)); + } + + [HttpGet("run-list")] + public async Task>> GetRunList( + [FromQuery] int id = -1, + [FromQuery] string type = "user", + [FromQuery] List reportType = null, + CancellationToken cancellationToken = default + ) + { + return Ok(await _profileApiService.GetRunListAsync(id, type, reportType, cancellationToken)); + } + + [HttpGet("stars")] + public async Task>> GetStars( + [FromQuery] int id, + [FromQuery] string type, + CancellationToken cancellationToken = default + ) + { + return Ok(await _profileApiService.GetStarsAsync(id, type, cancellationToken)); + } + + [HttpGet("subscriptions")] + public async Task>> GetSubscriptions( + [FromQuery] int id, + [FromQuery] string type, + CancellationToken cancellationToken = default + ) + { + return Ok(await _profileApiService.GetSubscriptionsAsync(id, type, cancellationToken)); + } +} diff --git a/web/Controllers/Api/ReportsApiController.cs b/web/Controllers/Api/ReportsApiController.cs new file mode 100644 index 00000000..a6105ea9 --- /dev/null +++ b/web/Controllers/Api/ReportsApiController.cs @@ -0,0 +1,223 @@ +using System.ComponentModel.DataAnnotations; +using Atlas_Web.Authorization; +using Atlas_Web.Contracts.Api.Reports; +using Atlas_Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Atlas_Web.Controllers.Api; + +[ApiController] +[Route("api/reports")] +[Authorize(AuthenticationSchemes = "Bearer")] +public class ReportsApiController : ControllerBase +{ + private readonly IReportsApiService _reportsApiService; + + public ReportsApiController(IReportsApiService reportsApiService) + { + _reportsApiService = reportsApiService; + } + + [HttpGet] + public async Task> GetReports( + [FromQuery] + [Range(1, int.MaxValue)] + int page = 1, + [FromQuery] + [Range(1, 100)] + int pageSize = 20, + CancellationToken cancellationToken = default + ) + { + var response = await _reportsApiService.GetReportsAsync( + User, + page, + pageSize, + cancellationToken + ); + return Ok(response); + } + + [HttpGet("{id:int}")] + public async Task> GetReport( + int id, + CancellationToken cancellationToken = default + ) + { + var report = await _reportsApiService.GetReportAsync(User, id, cancellationToken); + if (report == null) + { + return NotFound(); + } + + return Ok(report); + } + + [HttpGet("{id:int}/terms")] + public async Task>> GetReportTerms( + int id, + CancellationToken cancellationToken = default + ) + { + var report = await _reportsApiService.GetReportAsync(User, id, cancellationToken); + if (report == null) + { + return NotFound(); + } + + return Ok(await _reportsApiService.GetReportTermsAsync(User, id, cancellationToken)); + } + + [HttpGet("{id:int}/queries")] + public async Task> GetReportQueries( + int id, + CancellationToken cancellationToken = default + ) + { + var response = await _reportsApiService.GetReportQueriesAsync(User, id, cancellationToken); + if (response == null) + { + return NotFound(); + } + + return Ok(response); + } + + [HttpGet("{id:int}/relationships")] + public async Task> GetReportRelationships( + int id, + CancellationToken cancellationToken = default + ) + { + var response = await _reportsApiService.GetReportRelationshipsAsync( + User, + id, + cancellationToken + ); + if (response == null) + { + return NotFound(); + } + + return Ok(response); + } + + [HttpGet("{id:int}/maintenance-status")] + public async Task> GetReportMaintenanceStatus( + int id, + CancellationToken cancellationToken = default + ) + { + var response = await _reportsApiService.GetReportMaintenanceStatusAsync( + User, + id, + cancellationToken + ); + if (response == null) + { + return NotFound(); + } + + return Ok(response); + } + + [HttpPut("{id:int}")] + public async Task> UpdateReport( + int id, + [FromBody] UpdateReportDocumentRequestDto request, + CancellationToken cancellationToken = default + ) + { + if (!User.HasPermission("Edit Report Documentation")) + { + return Forbid(); + } + + try + { + var report = await _reportsApiService.UpdateReportAsync( + User, + id, + request, + cancellationToken + ); + if (report == null) + { + return NotFound(); + } + + return Ok(report); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpPost("{id:int}/images")] + [RequestSizeLimit(1024 * 1024)] + public async Task> AddImage( + int id, + IFormFile file, + CancellationToken cancellationToken = default + ) + { + if (!User.HasPermission("Edit Report Documentation")) + { + return Forbid(); + } + + try + { + var image = await _reportsApiService.AddImageAsync(User, id, file, cancellationToken); + if (image == null) + { + return NotFound(); + } + + return Ok(image); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpGet("lookups/{lookupArea}")] + public async Task>> GetLookupValues( + string lookupArea, + CancellationToken cancellationToken = default + ) + { + var values = await _reportsApiService.GetLookupValuesAsync(lookupArea, cancellationToken); + return Ok(values); + } + + [HttpGet("search/terms")] + public async Task>> SearchTerms( + [FromQuery] string q, + CancellationToken cancellationToken = default + ) + { + return Ok(await _reportsApiService.SearchTermsAsync(q, cancellationToken)); + } + + [HttpGet("search/collections")] + public async Task>> SearchCollections( + [FromQuery] string q, + CancellationToken cancellationToken = default + ) + { + return Ok(await _reportsApiService.SearchCollectionsAsync(q, cancellationToken)); + } + + [HttpGet("search/users")] + public async Task>> SearchUsers( + [FromQuery] string q, + CancellationToken cancellationToken = default + ) + { + return Ok(await _reportsApiService.SearchUsersAsync(q, cancellationToken)); + } +} diff --git a/web/Controllers/Api/SearchApiController.cs b/web/Controllers/Api/SearchApiController.cs new file mode 100644 index 00000000..72604ed6 --- /dev/null +++ b/web/Controllers/Api/SearchApiController.cs @@ -0,0 +1,59 @@ +using System.ComponentModel.DataAnnotations; +using Atlas_Web.Contracts.Api.Search; +using Atlas_Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Atlas_Web.Controllers.Api; + +[ApiController] +[Route("api/search")] +[Authorize(AuthenticationSchemes = "Bearer")] +public class SearchApiController : ControllerBase +{ + private static readonly HashSet ReservedKeys = new(StringComparer.OrdinalIgnoreCase) + { + "q", "type", "page", "pageSize", "field", "advanced", + }; + + private readonly ISearchApiService _searchApiService; + + public SearchApiController(ISearchApiService searchApiService) + { + _searchApiService = searchApiService; + } + + [HttpGet] + public async Task> Search( + [FromQuery] string q = "", + [FromQuery] string type = "query", + [FromQuery] + [Range(1, int.MaxValue)] + int page = 1, + [FromQuery] + [Range(1, 100)] + int pageSize = 20, + [FromQuery] string field = null, + [FromQuery] string advanced = null, + CancellationToken cancellationToken = default + ) + { + var filters = Request.Query + .Where(kv => !ReservedKeys.Contains(kv.Key)) + .ToDictionary(kv => kv.Key, kv => kv.Value.ToString()); + + var response = await _searchApiService.SearchAsync( + User, + q ?? string.Empty, + type, + page, + pageSize, + field, + advanced == "Y", + filters, + cancellationToken + ); + + return Ok(response); + } +} diff --git a/web/Controllers/Api/UsersApiController.cs b/web/Controllers/Api/UsersApiController.cs new file mode 100644 index 00000000..eadad354 --- /dev/null +++ b/web/Controllers/Api/UsersApiController.cs @@ -0,0 +1,348 @@ +using Atlas_Web.Contracts.Api.Users; +using Atlas_Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Atlas_Web.Controllers.Api; + +[ApiController] +[Route("api/users")] +[Authorize(AuthenticationSchemes = "Bearer")] +public class UsersApiController : ControllerBase +{ + private readonly IUsersApiService _usersApiService; + + public UsersApiController(IUsersApiService usersApiService) + { + _usersApiService = usersApiService; + } + + private int CurrentUserId => Int32.Parse(User.FindFirst("UserId")!.Value); + + private bool CanManageWorkspaceFor(int userId) + { + return userId == CurrentUserId || User.HasClaim("Permission", "Edit Other Users"); + } + + [HttpGet("{id:int}")] + public async Task> GetUserPage( + int id, + CancellationToken cancellationToken = default + ) + { + var userPage = await _usersApiService.GetUserPageAsync(User, id, cancellationToken); + if (userPage == null) + { + return NotFound(); + } + + return Ok(userPage); + } + + [HttpGet("{id:int}/stars")] + public async Task> GetStars( + int id, + CancellationToken cancellationToken = default + ) + { + return Ok(await _usersApiService.GetStarsAsync(User, id, cancellationToken)); + } + + [HttpGet("{id:int}/groups")] + public async Task>> GetGroups( + int id, + CancellationToken cancellationToken = default + ) + { + return Ok(await _usersApiService.GetGroupsAsync(User, id, cancellationToken)); + } + + [HttpGet("{id:int}/subscriptions")] + public async Task>> GetSubscriptions( + int id, + CancellationToken cancellationToken = default + ) + { + return Ok(await _usersApiService.GetSubscriptionsAsync(User, id, cancellationToken)); + } + + [HttpGet("{id:int}/history")] + public async Task> GetHistory( + int id, + CancellationToken cancellationToken = default + ) + { + return Ok(await _usersApiService.GetHistoryAsync(User, id, cancellationToken)); + } + + [HttpGet("me/shared-objects")] + public async Task> GetSharedObjects( + CancellationToken cancellationToken = default + ) + { + return Ok(await _usersApiService.GetSharedObjectsAsync(User, cancellationToken)); + } + + [HttpGet("me/search-history")] + public async Task>> GetSearchHistory( + CancellationToken cancellationToken = default + ) + { + return Ok(await _usersApiService.GetSearchHistoryAsync(User, cancellationToken)); + } + + [HttpPost("me/folders")] + public async Task> CreateFolder( + [FromBody] CreateUserFavoriteFolderRequestDto request, + CancellationToken cancellationToken = default + ) + { + var folder = await _usersApiService.CreateFolderAsync(CurrentUserId, request, cancellationToken); + return CreatedAtAction(nameof(GetSearchHistory), new { }, folder); + } + + [HttpPost("{id:int}/folders")] + public async Task> CreateFolderForUser( + int id, + [FromBody] CreateUserFavoriteFolderRequestDto request, + CancellationToken cancellationToken = default + ) + { + if (!CanManageWorkspaceFor(id)) + { + return Forbid(); + } + + var folder = await _usersApiService.CreateFolderAsync(id, request, cancellationToken); + return CreatedAtAction(nameof(GetStars), new { id }, folder); + } + + [HttpPut("me/folders/{id:int}")] + public async Task> UpdateFolder( + int id, + [FromBody] UpdateUserFavoriteFolderRequestDto request, + CancellationToken cancellationToken = default + ) + { + var folder = await _usersApiService.UpdateFolderAsync(CurrentUserId, id, request, cancellationToken); + if (folder == null) + { + return NotFound(); + } + + return Ok(folder); + } + + [HttpPut("{userId:int}/folders/{id:int}")] + public async Task> UpdateFolderForUser( + int userId, + int id, + [FromBody] UpdateUserFavoriteFolderRequestDto request, + CancellationToken cancellationToken = default + ) + { + if (!CanManageWorkspaceFor(userId)) + { + return Forbid(); + } + + var folder = await _usersApiService.UpdateFolderAsync(userId, id, request, cancellationToken); + if (folder == null) + { + return NotFound(); + } + + return Ok(folder); + } + + [HttpDelete("me/folders/{id:int}")] + public async Task DeleteFolder(int id, CancellationToken cancellationToken = default) + { + var deleted = await _usersApiService.DeleteFolderAsync(CurrentUserId, id, cancellationToken); + if (!deleted) + { + return NotFound(); + } + + return NoContent(); + } + + [HttpDelete("{userId:int}/folders/{id:int}")] + public async Task DeleteFolderForUser( + int userId, + int id, + CancellationToken cancellationToken = default + ) + { + if (!CanManageWorkspaceFor(userId)) + { + return Forbid(); + } + + var deleted = await _usersApiService.DeleteFolderAsync(userId, id, cancellationToken); + if (!deleted) + { + return NotFound(); + } + + return NoContent(); + } + + [HttpPost("me/folders/reorder")] + public async Task ReorderFolders( + [FromBody] IReadOnlyList request, + CancellationToken cancellationToken = default + ) + { + await _usersApiService.ReorderFoldersAsync(CurrentUserId, request, cancellationToken); + return NoContent(); + } + + [HttpPost("{id:int}/folders/reorder")] + public async Task ReorderFoldersForUser( + int id, + [FromBody] IReadOnlyList request, + CancellationToken cancellationToken = default + ) + { + if (id != CurrentUserId) + { + return Forbid(); + } + + await _usersApiService.ReorderFoldersAsync(id, request, cancellationToken); + return NoContent(); + } + + [HttpPost("me/favorites/reorder")] + public async Task ReorderFavorites( + [FromBody] IReadOnlyList request, + CancellationToken cancellationToken = default + ) + { + await _usersApiService.ReorderFavoritesAsync(CurrentUserId, request, cancellationToken); + return NoContent(); + } + + [HttpPost("{id:int}/favorites/reorder")] + public async Task ReorderFavoritesForUser( + int id, + [FromBody] IReadOnlyList request, + CancellationToken cancellationToken = default + ) + { + if (id != CurrentUserId) + { + return Forbid(); + } + + await _usersApiService.ReorderFavoritesAsync(id, request, cancellationToken); + return NoContent(); + } + + [HttpPut("me/favorites/folder")] + public async Task UpdateFavoriteFolder( + [FromBody] UpdateUserFavoriteFolderAssignmentRequestDto request, + CancellationToken cancellationToken = default + ) + { + var updated = await _usersApiService.UpdateFavoriteFolderAsync( + CurrentUserId, + request, + cancellationToken + ); + if (!updated) + { + return NotFound(); + } + + return NoContent(); + } + + [HttpPut("{id:int}/favorites/folder")] + public async Task UpdateFavoriteFolderForUser( + int id, + [FromBody] UpdateUserFavoriteFolderAssignmentRequestDto request, + CancellationToken cancellationToken = default + ) + { + if (!CanManageWorkspaceFor(id)) + { + return Forbid(); + } + + var updated = await _usersApiService.UpdateFavoriteFolderAsync(id, request, cancellationToken); + if (!updated) + { + return NotFound(); + } + + return NoContent(); + } + + [HttpDelete("me/shared-objects/{id:int}")] + public async Task RemoveSharedObject( + int id, + CancellationToken cancellationToken = default + ) + { + var removed = await _usersApiService.RemoveSharedObjectAsync(User, id, cancellationToken); + if (!removed) + { + return NotFound(); + } + + return NoContent(); + } + + [HttpPost("me/favorites/toggle")] + public async Task> ToggleFavorite( + [FromBody] ToggleUserFavoriteRequestDto request, + CancellationToken cancellationToken = default + ) + { + try + { + return Ok(await _usersApiService.ToggleFavoriteAsync(CurrentUserId, request, cancellationToken)); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpPost("{id:int}/favorites/toggle")] + public async Task> ToggleFavoriteForUser( + int id, + [FromBody] ToggleUserFavoriteRequestDto request, + CancellationToken cancellationToken = default + ) + { + if (!CanManageWorkspaceFor(id)) + { + return Forbid(); + } + + try + { + return Ok(await _usersApiService.ToggleFavoriteAsync(id, request, cancellationToken)); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpPost("me/admin-mode/toggle")] + public async Task> ToggleAdminMode( + CancellationToken cancellationToken = default + ) + { + if (!User.IsInRole("Administrator")) + { + return Forbid(); + } + + return Ok(await _usersApiService.ToggleAdminModeAsync(User, cancellationToken)); + } +} diff --git a/web/Models/Atlas_WebContextFactory.cs b/web/Models/Atlas_WebContextFactory.cs new file mode 100644 index 00000000..9dd5bb52 --- /dev/null +++ b/web/Models/Atlas_WebContextFactory.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Atlas_Web.Models; + +public class Atlas_WebContextFactory : IDesignTimeDbContextFactory +{ + public Atlas_WebContext CreateDbContext(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false) + .AddJsonFile("appsettings.cust.json", optional: true) + .AddEnvironmentVariables() + .Build(); + + var optionsBuilder = new DbContextOptionsBuilder(); + var connectionString = configuration.GetConnectionString("AtlasDatabase"); + + optionsBuilder.UseSqlServer(connectionString); + + return new Atlas_WebContext(optionsBuilder.Options); + } +} diff --git a/web/Pages/Reports/Edit.cshtml.cs b/web/Pages/Reports/Edit.cshtml.cs index c4c6fcbd..1b43087f 100644 --- a/web/Pages/Reports/Edit.cshtml.cs +++ b/web/Pages/Reports/Edit.cshtml.cs @@ -45,7 +45,7 @@ public EditModel(Atlas_WebContext context, IMemoryCache cache) public async Task OnGetAsync(int id) { - if (!User.HasPermission("Edit Collection")) + if (!User.HasPermission("Edit Report Documentation")) { return RedirectToPage( "/Reports/Index", diff --git a/web/Program.cs b/web/Program.cs index 3ae1873f..b88d5967 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -1,13 +1,17 @@ using System.Data.SqlClient; using System.IO.Compression; using System.Security.Cryptography.X509Certificates; +using System.Text; using System.Text.RegularExpressions; +using Atlas_Web; using Atlas_Web.Authentication; using Atlas_Web.Authorization; using Atlas_Web.Middleware; using Atlas_Web.Models; using Atlas_Web.Services; using Hangfire; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; using ITfoxtec.Identity.Saml2; using ITfoxtec.Identity.Saml2.MvcCore; using ITfoxtec.Identity.Saml2.MvcCore.Configuration; @@ -58,6 +62,8 @@ }); builder.Services.AddResponseCaching(); +ProgramConfiguration.ConfigureCors(builder); + // for linq queries - conditionally register based on environment if (!builder.Environment.IsEnvironment("Test")) { @@ -197,19 +203,15 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); - -if (builder.Configuration["Demo"] == "True") -{ -# pragma warning disable S1116 - builder - .Services.AddAuthentication(options => options.DefaultScheme = "Demo") - .AddScheme("Demo", options => { }); - ; -} -else -{ - builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme).AddNegotiate(); -} +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHttpContextAccessor(); + +ProgramConfiguration.ConfigureJwtAuthentication(builder); if (builder.Configuration.GetSection("Saml2").Exists()) { builder.Services.AddHttpClient(); @@ -344,6 +346,7 @@ var signingCertificate in entityDescriptor.IdPSsoDescriptor.SigningCertificates app.UseETagger(); app.UseRouting(); +app.UseCors("NextJs"); app.UseAuthentication(); app.UseAuthorization(); @@ -353,7 +356,7 @@ var signingCertificate in entityDescriptor.IdPSsoDescriptor.SigningCertificates app.Use( async (context, next) => { - context.Response.Headers.Add("Content-Security-Policy", "frame-ancestors 'self' *;"); + context.Response.Headers["Content-Security-Policy"] = "frame-ancestors 'self';"; await next(); } ); diff --git a/web/ProgramConfiguration.cs b/web/ProgramConfiguration.cs new file mode 100644 index 00000000..a441a08e --- /dev/null +++ b/web/ProgramConfiguration.cs @@ -0,0 +1,135 @@ +using System.Text; +using Atlas_Web.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.Negotiate; +using Microsoft.IdentityModel.Tokens; +using Atlas_Web.Authentication; + +namespace Atlas_Web; + +public static class ProgramConfiguration +{ + public static void ConfigureCors(WebApplicationBuilder builder) + { + builder.Services.AddCors(options => + { + options.AddPolicy("NextJs", policy => + { + var origins = builder.Configuration + .GetSection("Cors:AllowedOrigins") + .Get(); + + if (origins == null || origins.Length == 0) + { + if (builder.Environment.IsEnvironment("Test")) + { + origins = new[] { "http://localhost:3000" }; + } + else + { + throw new InvalidOperationException( + "CORS allowed origins are not configured. Please set Cors:AllowedOrigins in configuration." + ); + } + } + + policy.WithOrigins(origins) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + }); + }); + } + + public static void ConfigureJwtAuthentication(WebApplicationBuilder builder) + { + var jwtKey = builder.Configuration["Jwt:Key"]; + var jwtIssuer = builder.Configuration["Jwt:Issuer"]; + var jwtAudience = builder.Configuration["Jwt:Audience"]; + + if (string.IsNullOrWhiteSpace(jwtKey) || string.IsNullOrWhiteSpace(jwtIssuer) || string.IsNullOrWhiteSpace(jwtAudience)) + { + if (builder.Environment.IsEnvironment("Test")) + { +#pragma warning disable S6781 // JWT secret keys should not be disclosed - Test defaults only, not production secrets + jwtKey = "test-jwt-secret-key-for-ci-testing-minimum-32-characters"; + jwtIssuer = "atlas-test-issuer"; + jwtAudience = "atlas-test-audience"; +#pragma warning restore S6781 + } + else + { + throw new InvalidOperationException( + "JWT configuration is missing. Please set Jwt:Key, Jwt:Issuer, and Jwt:Audience via environment variables or configuration files." + ); + } + } + +#pragma warning disable S6781 // JWT secret keys should not be disclosed - Key from config or test defaults, not hardcoded + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); +#pragma warning restore S6781 + builder.Services.AddScoped(_ => new JwtTokenService(signingKey, jwtIssuer, jwtAudience)); + + if (builder.Configuration["Demo"] == "True") + { + ConfigureDemoAuthentication(builder, jwtIssuer, jwtAudience, signingKey); + } + else + { + ConfigureNegotiateAuthentication(builder, jwtIssuer, jwtAudience, signingKey); + } + } + + private static void ConfigureDemoAuthentication( + WebApplicationBuilder builder, + string jwtIssuer, + string jwtAudience, + SymmetricSecurityKey signingKey) + { +#pragma warning disable S1116 + builder + .Services.AddAuthentication(options => options.DefaultScheme = "Demo") + .AddScheme("Demo", options => + { + options.Username = builder.Configuration["DEMO_ADMIN_USERNAME"] ?? "Default"; + }) + .AddJwtBearer("Bearer", options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtIssuer, + ValidAudience = jwtAudience, + IssuerSigningKey = signingKey, + }; + }); + ; +#pragma warning restore S1116 + } + + private static void ConfigureNegotiateAuthentication( + WebApplicationBuilder builder, + string jwtIssuer, + string jwtAudience, + SymmetricSecurityKey signingKey) + { + builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme) + .AddNegotiate() + .AddJwtBearer("Bearer", options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtIssuer, + ValidAudience = jwtAudience, + IssuerSigningKey = signingKey, + }; + }); + } +} diff --git a/web/Services/Collections/CollectionsApiService.cs b/web/Services/Collections/CollectionsApiService.cs new file mode 100644 index 00000000..bda1346d --- /dev/null +++ b/web/Services/Collections/CollectionsApiService.cs @@ -0,0 +1,548 @@ +using Atlas_Web.Authorization; +using Atlas_Web.Contracts.Api.Collections; +using Atlas_Web.Helpers; +using Atlas_Web.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using System.Security.Claims; + +namespace Atlas_Web.Services; + +public interface ICollectionsApiService +{ + Task GetCollectionsAsync( + ClaimsPrincipal user, + int page, + int pageSize, + CancellationToken cancellationToken + ); + Task GetCollectionAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ); + Task CreateCollectionAsync( + ClaimsPrincipal user, + CreateCollectionRequestDto request, + CancellationToken cancellationToken + ); + Task UpdateCollectionAsync( + ClaimsPrincipal user, + int id, + UpdateCollectionRequestDto request, + CancellationToken cancellationToken + ); + Task DeleteCollectionAsync( + int id, + CancellationToken cancellationToken + ); + Task> SearchTermsAsync( + string search, + CancellationToken cancellationToken + ); + Task> SearchReportsAsync( + string search, + CancellationToken cancellationToken + ); +} + +public sealed class CollectionsApiService : ICollectionsApiService +{ + private const int MaxPageSize = 100; + private readonly Atlas_WebContext _context; + private readonly IAuthorizationService _authorizationService; + private readonly IConfiguration _configuration; + private readonly IMemoryCache _cache; + + public CollectionsApiService( + Atlas_WebContext context, + IAuthorizationService authorizationService, + IConfiguration configuration, + IMemoryCache cache + ) + { + _context = context; + _authorizationService = authorizationService; + _configuration = configuration; + _cache = cache; + } + + public async Task GetCollectionsAsync( + ClaimsPrincipal user, + int page, + int pageSize, + CancellationToken cancellationToken + ) + { + var currentUserId = user.GetUserId(); + var safePage = Math.Max(page, 1); + var safePageSize = Math.Clamp(pageSize, 1, MaxPageSize); + var query = _context.Collections.AsNoTracking(); + + var total = await query.CountAsync(cancellationToken); + var collections = await query + .OrderBy(x => x.Name) + .Select(x => new CollectionListItemDto + { + Id = x.CollectionId, + Name = x.Name, + Description = x.Description, + Purpose = x.Purpose, + Hidden = x.Hidden, + LastModified = x.LastUpdateDate, + StarCount = x.StarredCollections.Count, + IsStarred = x.StarredCollections.Any(y => y.Ownerid == currentUserId), + }) + .Skip((safePage - 1) * safePageSize) + .Take(safePageSize) + .ToListAsync(cancellationToken); + + return new CollectionListResponseDto + { + Collections = collections, + Total = total, + Page = safePage, + PageSize = safePageSize, + }; + } + + public async Task GetCollectionAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ) + { + var features = BuildFeatureFlags(); + var canViewUserProfiles = user.HasPermission("View Other User") + && features.UserProfilesEnabled; + var currentUserId = user.GetUserId(); + + var collection = await _context + .Collections.AsNoTracking() + .Where(x => x.CollectionId == id) + .Select(x => new CollectionDetailDto + { + Id = x.CollectionId, + Name = x.Name, + Description = x.Description, + Purpose = x.Purpose, + Hidden = x.Hidden, + LastModified = x.LastUpdateDate, + LastModifiedDisplay = ModelHelpers.RelativeDate(x.LastUpdateDate), + IsStarred = x.StarredCollections.Any(y => y.Ownerid == currentUserId), + StarCount = x.StarredCollections.Count, + CanCreateCollection = user.HasPermission("Create Collection"), + CanEditCollection = user.HasPermission("Edit Collection"), + CanDeleteCollection = user.HasPermission("Delete Collection"), + CanViewUserProfiles = canViewUserProfiles, + Features = features, + LastUpdatedBy = x.LastUpdateUserNavigation == null + ? null + : new CollectionUserSummaryDto + { + Id = x.LastUpdateUserNavigation.UserId, + Username = x.LastUpdateUserNavigation.Username, + FullName = x.LastUpdateUserNavigation.FullnameCalc + ?? x.LastUpdateUserNavigation.DisplayName, + Email = x.LastUpdateUserNavigation.Email, + }, + Initiative = x.Initiative == null + ? null + : new InitiativeSummaryDto + { + Id = x.Initiative.InitiativeId, + Name = x.Initiative.Name, + Description = x.Initiative.Description, + }, + Terms = features.TermsEnabled + ? x.CollectionTerms.OrderBy(y => y.Rank).ThenBy(y => y.Term.Name) + .Select(y => new CollectionTermDto + { + Id = y.TermId, + Name = y.Term.Name, + Summary = y.Term.Summary, + Rank = y.Rank, + }) + .ToList() + : new List(), + Reports = x.CollectionReports.OrderBy(y => y.Rank).ThenBy(y => y.Report.Name) + .Select(y => new CollectionReportDto + { + Id = y.ReportId, + Name = y.Report.DisplayTitle ?? y.Report.Name, + Description = y.Report.Description, + Type = y.Report.ReportObjectType != null + ? y.Report.ReportObjectType.ShortName + : null, + Url = y.Report.ReportObjectUrl, + LastModified = y.Report.LastModifiedDate, + AttachmentCount = y.Report.ReportObjectAttachments.Count, + Rank = y.Rank, + IsStarred = y.Report.StarredReports.Any(z => z.Ownerid == currentUserId), + }) + .ToList(), + }) + .SingleOrDefaultAsync(cancellationToken); + + if (collection == null) + { + return null; + } + + await PopulateRunAuthorizationAsync(user, collection.Reports, cancellationToken); + return collection; + } + + public async Task CreateCollectionAsync( + ClaimsPrincipal user, + CreateCollectionRequestDto request, + CancellationToken cancellationToken + ) + { + await ValidateLinkedIdsAsync(request.TermIds, request.ReportIds, cancellationToken); + + var collection = new Collection + { + Name = request.Name, + Description = request.Description, + Purpose = request.Purpose, + Hidden = NormalizeFlag(request.Hidden), + LastUpdateUser = user.GetUserId(), + LastUpdateDate = DateTime.Now, + }; + + await _context.Collections.AddAsync(collection, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + + await SynchronizeTermsAsync(collection.CollectionId, request.TermIds, cancellationToken); + await SynchronizeReportsAsync(collection.CollectionId, request.ReportIds, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + InvalidateCollectionCaches(collection.CollectionId, request.TermIds, request.ReportIds); + + return await GetCollectionAsync(user, collection.CollectionId, cancellationToken); + } + + public async Task UpdateCollectionAsync( + ClaimsPrincipal user, + int id, + UpdateCollectionRequestDto request, + CancellationToken cancellationToken + ) + { + var collection = await _context.Collections.SingleOrDefaultAsync( + x => x.CollectionId == id, + cancellationToken + ); + if (collection == null) + { + return null; + } + + var existingTermIds = await _context.CollectionTerms.Where(x => x.CollectionId == id) + .Select(x => x.TermId) + .ToListAsync(cancellationToken); + var existingReportIds = await _context.CollectionReports.Where(x => x.CollectionId == id) + .Select(x => x.ReportId) + .ToListAsync(cancellationToken); + + await ValidateLinkedIdsAsync(request.TermIds, request.ReportIds, cancellationToken); + + collection.Name = request.Name; + collection.Description = request.Description; + collection.Purpose = request.Purpose; + collection.Hidden = NormalizeFlag(request.Hidden); + collection.LastUpdateUser = user.GetUserId(); + collection.LastUpdateDate = DateTime.Now; + + await SynchronizeTermsAsync(collection.CollectionId, request.TermIds, cancellationToken); + await SynchronizeReportsAsync(collection.CollectionId, request.ReportIds, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + InvalidateCollectionCaches( + collection.CollectionId, + existingTermIds.Union(request.TermIds).ToArray(), + existingReportIds.Union(request.ReportIds).ToArray() + ); + + return await GetCollectionAsync(user, collection.CollectionId, cancellationToken); + } + + public async Task DeleteCollectionAsync(int id, CancellationToken cancellationToken) + { + var relatedTermIds = await _context.CollectionTerms.Where(x => x.CollectionId == id) + .Select(x => x.TermId) + .ToListAsync(cancellationToken); + var relatedReportIds = await _context.CollectionReports.Where(x => x.CollectionId == id) + .Select(x => x.ReportId) + .ToListAsync(cancellationToken); + var collection = await _context.Collections.SingleOrDefaultAsync( + x => x.CollectionId == id, + cancellationToken + ); + if (collection == null) + { + return false; + } + + var collectionReports = await _context.CollectionReports.Where(x => x.CollectionId == id) + .ToListAsync(cancellationToken); + var collectionTerms = await _context.CollectionTerms.Where(x => x.CollectionId == id) + .ToListAsync(cancellationToken); + + _context.CollectionReports.RemoveRange(collectionReports); + _context.CollectionTerms.RemoveRange(collectionTerms); + _context.Collections.Remove(collection); + await _context.SaveChangesAsync(cancellationToken); + InvalidateCollectionCaches(id, relatedTermIds, relatedReportIds); + + return true; + } + + public async Task> SearchTermsAsync( + string search, + CancellationToken cancellationToken + ) + { + if (string.IsNullOrWhiteSpace(search)) + { + return Array.Empty(); + } + + var termSearch = search.Trim(); + return await _context.Terms.AsNoTracking() + .Where(x => x.Name.Contains(termSearch) || x.Summary.Contains(termSearch)) + .OrderBy(x => x.Name) + .Select(x => new CollectionSearchResultDto + { + Id = x.TermId, + Name = x.Name, + Description = x.Summary, + }) + .Take(10) + .ToListAsync(cancellationToken); + } + + public async Task> SearchReportsAsync( + string search, + CancellationToken cancellationToken + ) + { + if (string.IsNullOrWhiteSpace(search)) + { + return Array.Empty(); + } + + var reportSearch = search.Trim(); + return await _context.ReportObjects.AsNoTracking() + .Where(x => + (x.DisplayTitle ?? x.Name).Contains(reportSearch) + || x.Description.Contains(reportSearch) + ) + .OrderBy(x => x.DisplayTitle ?? x.Name) + .Select(x => new CollectionSearchResultDto + { + Id = x.ReportObjectId, + Name = x.DisplayTitle ?? x.Name, + Description = x.Description, + }) + .Take(10) + .ToListAsync(cancellationToken); + } + + private CollectionFeatureFlagsDto BuildFeatureFlags() + { + return new CollectionFeatureFlagsDto + { + TermsEnabled = IsFeatureEnabled("features:enable_terms"), + UserProfilesEnabled = IsFeatureEnabled("features:enable_user_profile"), + FeedbackEnabled = IsFeatureEnabled("features:enable_feedback"), + SharingEnabled = IsFeatureEnabled("features:enable_sharing"), + }; + } + + private bool IsFeatureEnabled(string key) + { + var value = _configuration[key]; + return string.IsNullOrWhiteSpace(value) + || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } + + private async Task PopulateRunAuthorizationAsync( + ClaimsPrincipal user, + IReadOnlyList reports, + CancellationToken cancellationToken + ) + { + if (reports.Count == 0) + { + return; + } + + var reportIds = reports.Select(x => x.Id).ToArray(); + var authorizationReports = await _context.ReportObjects.AsNoTracking() + .Where(x => reportIds.Contains(x.ReportObjectId)) + .Include(x => x.ReportObjectType) + .Include(x => x.ReportGroupsMemberships) + .Include(x => x.ReportObjectHierarchyChildReportObjects) + .ThenInclude(x => x.ParentReportObject) + .ThenInclude(x => x.ReportGroupsMemberships) + .ToListAsync(cancellationToken); + var authorizationLookup = authorizationReports.ToDictionary(x => x.ReportObjectId); + + foreach (var report in reports) + { + report.CanRun = + authorizationLookup.TryGetValue(report.Id, out var authorizationReport) + && ( + await _authorizationService.AuthorizeAsync( + user, + authorizationReport, + "ReportRunPolicy" + ) + ).Succeeded; + } + } + + private async Task SynchronizeTermsAsync( + int collectionId, + IReadOnlyList termIds, + CancellationToken cancellationToken + ) + { + var normalizedIds = termIds.Distinct().ToList(); + var existing = await _context.CollectionTerms.Where(x => x.CollectionId == collectionId) + .ToListAsync(cancellationToken); + + _context.CollectionTerms.RemoveRange(existing.Where(x => !normalizedIds.Contains(x.TermId))); + + for (var index = 0; index < normalizedIds.Count; index++) + { + var termId = normalizedIds[index]; + var existingLink = existing.FirstOrDefault(x => x.TermId == termId); + if (existingLink == null) + { + await _context.CollectionTerms.AddAsync( + new CollectionTerm + { + CollectionId = collectionId, + TermId = termId, + Rank = index, + }, + cancellationToken + ); + } + else + { + existingLink.Rank = index; + } + } + } + + private async Task SynchronizeReportsAsync( + int collectionId, + IReadOnlyList reportIds, + CancellationToken cancellationToken + ) + { + var normalizedIds = reportIds.Distinct().ToList(); + var existing = await _context.CollectionReports.Where(x => x.CollectionId == collectionId) + .ToListAsync(cancellationToken); + + _context.CollectionReports.RemoveRange( + existing.Where(x => !normalizedIds.Contains(x.ReportId)) + ); + + for (var index = 0; index < normalizedIds.Count; index++) + { + var reportId = normalizedIds[index]; + var existingLink = existing.FirstOrDefault(x => x.ReportId == reportId); + if (existingLink == null) + { + await _context.CollectionReports.AddAsync( + new CollectionReport + { + CollectionId = collectionId, + ReportId = reportId, + Rank = index, + }, + cancellationToken + ); + } + else + { + existingLink.Rank = index; + } + } + } + + private static string NormalizeFlag(string value) + { + return string.Equals(value, "Y", StringComparison.OrdinalIgnoreCase) ? "Y" : "N"; + } + + private async Task ValidateLinkedIdsAsync( + IReadOnlyList termIds, + IReadOnlyList reportIds, + CancellationToken cancellationToken + ) + { + var normalizedTermIds = termIds.Distinct().ToArray(); + if (normalizedTermIds.Length > 0) + { + var existingTermIds = await _context.Terms.AsNoTracking() + .Where(x => normalizedTermIds.Contains(x.TermId)) + .Select(x => x.TermId) + .ToListAsync(cancellationToken); + var missingTermIds = normalizedTermIds.Except(existingTermIds).ToArray(); + if (missingTermIds.Length > 0) + { + throw new InvalidOperationException( + $"Unknown term ids: {string.Join(", ", missingTermIds)}" + ); + } + } + + var normalizedReportIds = reportIds.Distinct().ToArray(); + if (normalizedReportIds.Length > 0) + { + var existingReportIds = await _context.ReportObjects.AsNoTracking() + .Where(x => normalizedReportIds.Contains(x.ReportObjectId)) + .Select(x => x.ReportObjectId) + .ToListAsync(cancellationToken); + var missingReportIds = normalizedReportIds.Except(existingReportIds).ToArray(); + if (missingReportIds.Length > 0) + { + throw new InvalidOperationException( + $"Unknown report ids: {string.Join(", ", missingReportIds)}" + ); + } + } + } + + private void InvalidateCollectionCaches( + int collectionId, + IEnumerable termIds, + IEnumerable reportIds + ) + { + _cache.Remove("collections"); + _cache.Remove("collection-" + collectionId); + _cache.Remove("search-collection-" + collectionId); + _cache.Remove("terms"); + + foreach (var termId in termIds.Distinct()) + { + _cache.Remove("term-" + termId); + } + + foreach (var reportId in reportIds.Distinct()) + { + _cache.Remove("report-" + reportId); + _cache.Remove("report-terms-" + reportId); + _cache.Remove("report-comp-queries-" + reportId); + _cache.Remove("report-children-" + reportId); + _cache.Remove("report-parents-" + reportId); + _cache.Remove("search-report-" + reportId); + } + } +} diff --git a/web/Services/Interactions/InteractionsApiService.cs b/web/Services/Interactions/InteractionsApiService.cs new file mode 100644 index 00000000..6236a739 --- /dev/null +++ b/web/Services/Interactions/InteractionsApiService.cs @@ -0,0 +1,482 @@ +using System.Text.Json; +using Atlas_Web.Authorization; +using Atlas_Web.Contracts.Api.Interactions; +using Atlas_Web.Helpers; +using Atlas_Web.Models; +using Atlas_Web.Pages.Search; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using SolrNet; +using SolrNet.Commands.Parameters; +using System.Security.Claims; + +namespace Atlas_Web.Services; + +public interface IInteractionsApiService +{ + Task ToggleStarAsync( + ClaimsPrincipal user, + ToggleStarRequestDto request, + CancellationToken cancellationToken + ); + Task SendShareMailAsync( + ClaimsPrincipal user, + ShareMailRequestDto request, + CancellationToken cancellationToken + ); + Task SendFeedbackAsync( + ClaimsPrincipal user, + ShareFeedbackRequestDto request, + CancellationToken cancellationToken + ); + Task> SearchRecipientsAsync( + string search, + bool includeGroups, + CancellationToken cancellationToken + ); +} + +public sealed class InteractionsApiService : IInteractionsApiService +{ + private readonly Atlas_WebContext _context; + private readonly IConfiguration _config; + private readonly IRazorPartialToStringRenderer _renderer; + private readonly IEmailService _emailer; + private readonly IMemoryCache _cache; + private readonly ISolrReadOnlyOperations _solr; + + public InteractionsApiService( + Atlas_WebContext context, + IConfiguration config, + IRazorPartialToStringRenderer renderer, + IEmailService emailer, + IMemoryCache cache, + ISolrReadOnlyOperations solr + ) + { + _context = context; + _config = config; + _renderer = renderer; + _emailer = emailer; + _cache = cache; + _solr = solr; + } + + public async Task ToggleStarAsync( + ClaimsPrincipal user, + ToggleStarRequestDto request, + CancellationToken cancellationToken + ) + { + if (request == null || request.Id <= 0 || string.IsNullOrWhiteSpace(request.Type)) + { + throw new InvalidOperationException("A valid star target is required."); + } + + var userId = user.GetUserId(); + var type = request.Type.Trim().ToLowerInvariant(); + + return type switch + { + "report" => await ToggleReportStarAsync(userId, request.Id!.Value, cancellationToken), + "collection" => await ToggleCollectionStarAsync( + userId, + request.Id!.Value, + cancellationToken + ), + _ => throw new InvalidOperationException("Unsupported star target type."), + }; + } + + public async Task SendShareMailAsync( + ClaimsPrincipal user, + ShareMailRequestDto request, + CancellationToken cancellationToken + ) + { + if (request == null) + { + throw new InvalidOperationException("Request body is required."); + } + + var recipients = request.To.Where(x => x != null && x.UserId > 0).ToList(); + if (recipients.Count == 0) + { + throw new InvalidOperationException("No recipients specified."); + } + + var userIds = recipients + .Where(x => !string.Equals(x.Type, "g", StringComparison.OrdinalIgnoreCase)) + .Select(x => x.UserId!.Value) + .Distinct() + .ToList(); + var groupIds = recipients + .Where(x => string.Equals(x.Type, "g", StringComparison.OrdinalIgnoreCase)) + .Select(x => x.UserId!.Value) + .Distinct() + .ToList(); + + var directUsers = await _context.Users.Where(x => userIds.Contains(x.UserId)) + .ToListAsync(cancellationToken); + var groupUsers = await _context.UserGroupsMemberships.Include(x => x.User) + .Where(x => groupIds.Contains(x.GroupId)) + .ToListAsync(cancellationToken); + + if (directUsers.Count == 0 && groupUsers.Count == 0) + { + throw new InvalidOperationException("No recipients specified."); + } + + var message = new MailMessage + { + Subject = request.Subject ?? string.Empty, + Message = request.Message ?? string.Empty, + MessagePlainText = request.Text ?? string.Empty, + SendDate = DateTime.Now, + FromUserId = user.GetUserId(), + }; + + await _context.MailMessages.AddAsync(message, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + + await _context.MailRecipients.AddRangeAsync( + directUsers.Select(x => new MailRecipient + { + MessageId = message.MessageId, + ToUserId = x.UserId, + }), + cancellationToken + ); + await _context.MailRecipients.AddRangeAsync( + groupUsers.Select(x => new MailRecipient + { + MessageId = message.MessageId, + ToUserId = x.UserId, + ToGroupId = x.GroupId, + }), + cancellationToken + ); + + if (request.DraftId.GetValueOrDefault() >= 0) + { + _context.RemoveRange(_context.MailDrafts.Where(x => x.DraftId == request.DraftId.Value)); + } + + await _context.SaveChangesAsync(cancellationToken); + + var shareCount = 0; + if (request.Share) + { + shareCount = await CreateSharesAsync( + user.GetUserId(), + request, + directUsers, + groupUsers, + cancellationToken + ); + } + + return new ShareMailResponseDto + { + Message = "Successfully shared.", + RecipientCount = directUsers.Count + groupUsers.Count, + ShareCount = shareCount, + }; + } + + public async Task SendFeedbackAsync( + ClaimsPrincipal user, + ShareFeedbackRequestDto request, + CancellationToken cancellationToken + ) + { + if (request == null || string.IsNullOrWhiteSpace(request.ReportName)) + { + throw new InvalidOperationException("Feedback target is required."); + } + + using var handler = new HttpClientHandler(); + using var client = new HttpClient(handler) + { + DefaultRequestVersion = new Version(1, 1), + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, + }; + + client.DefaultRequestHeaders.Add("Accept", "application/vnd.manageengine.sdp.v3+json"); + client.DefaultRequestHeaders.Add( + "authtoken", + _config["AppSettings:manage_engine_tech_key"] + ); + + var payload = new + { + request = new + { + subject = "Atlas Feedback", + description = + $"Atlas feedback on {request.ReportName}

{request.Description}

", + requester = BuildRequester(user), + template = new { name = "WebAPI" }, + status = new { name = "Open" }, + category = new { name = "Epic Request" }, + subcategory = new { name = "Atlas" }, + item = new { name = "Feedback" }, + udf_fields = new + { + udf_sline_5791 = request.ReportName, + udf_sline_5790 = request.ReportUrl, + }, + }, + }; + + var json = JsonSerializer.Serialize(payload); + using var content = new FormUrlEncodedContent( + new Dictionary { { "input_data", json } } + ); + + var url = _config["AppSettings:manage_engine_server"] + "/api/v3/requests"; + using var response = await client.PostAsync(url, content, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + return JsonDocument.Parse(responseBody).RootElement.Clone(); + } + + public Task> SearchRecipientsAsync( + string search, + bool includeGroups, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var queryString = IndexModel.BuildSearchString( + search ?? string.Empty, + new QueryCollection() + ); + var results = QueryRecipients(queryString, "/users", "u"); + if (includeGroups) + { + results.AddRange(QueryRecipients(queryString, "/groups", "g")); + } + + return Task.FromResult>( + results.GroupBy(x => new { x.Type, x.Id }).Select(x => x.First()).ToList() + ); + } + + private async Task ToggleReportStarAsync( + int userId, + int reportId, + CancellationToken cancellationToken + ) + { + if ( + !await _context.ReportObjects.AnyAsync(x => x.ReportObjectId == reportId, cancellationToken) + ) + { + return null; + } + + var existing = await _context.StarredReports.Where(x => + x.Ownerid == userId && x.Reportid == reportId + ) + .ToListAsync(cancellationToken); + var isStarred = existing.Count == 0; + + if (isStarred) + { + await _context.StarredReports.AddAsync( + new StarredReport { Ownerid = userId, Reportid = reportId }, + cancellationToken + ); + } + else + { + _context.StarredReports.RemoveRange(existing); + } + + await _context.SaveChangesAsync(cancellationToken); + _cache.Remove($"report-{reportId}"); + + return new ToggleStarResponseDto + { + Type = "report", + Id = reportId, + IsStarred = isStarred, + Count = await _context.StarredReports.CountAsync( + x => x.Reportid == reportId, + cancellationToken + ), + }; + } + + private async Task ToggleCollectionStarAsync( + int userId, + int collectionId, + CancellationToken cancellationToken + ) + { + if ( + !await _context.Collections.AnyAsync(x => x.CollectionId == collectionId, cancellationToken) + ) + { + return null; + } + + var existing = await _context.StarredCollections.Where(x => + x.Ownerid == userId && x.Collectionid == collectionId + ) + .ToListAsync(cancellationToken); + var isStarred = existing.Count == 0; + + if (isStarred) + { + await _context.StarredCollections.AddAsync( + new StarredCollection { Ownerid = userId, Collectionid = collectionId }, + cancellationToken + ); + } + else + { + _context.StarredCollections.RemoveRange(existing); + } + + await _context.SaveChangesAsync(cancellationToken); + _cache.Remove($"collection-{collectionId}"); + _cache.Remove("collections"); + + return new ToggleStarResponseDto + { + Type = "collection", + Id = collectionId, + IsStarred = isStarred, + Count = await _context.StarredCollections.CountAsync( + x => x.Collectionid == collectionId, + cancellationToken + ), + }; + } + + private async Task CreateSharesAsync( + int senderUserId, + ShareMailRequestDto request, + IReadOnlyList directUsers, + IReadOnlyList groupUsers, + CancellationToken cancellationToken + ) + { + var sender = await _context.Users.SingleAsync(x => x.UserId == senderUserId, cancellationToken); + var shareCount = 0; + + foreach (var recipient in directUsers) + { + await CreateShareAsync(sender, recipient, request, cancellationToken); + shareCount++; + } + + foreach (var recipient in groupUsers.Select(groupRecipient => groupRecipient.User)) + { + if (recipient == null) + { + continue; + } + + await CreateShareAsync(sender, recipient, request, cancellationToken); + shareCount++; + } + + return shareCount; + } + + private async Task CreateShareAsync( + User sender, + User recipient, + ShareMailRequestDto request, + CancellationToken cancellationToken + ) + { + await _context.SharedItems.AddAsync( + new SharedItem + { + SharedFromUserId = sender.UserId, + SharedToUserId = recipient.UserId, + ShareDate = DateTime.Now, + Name = request.ShareName, + Url = request.ShareUrl, + }, + cancellationToken + ); + await _context.SaveChangesAsync(cancellationToken); + + var setting = await _context.UserSettings.Where(x => + x.Name == "share_notification" && x.UserId == recipient.UserId + ) + .Select(x => x.Value) + .FirstOrDefaultAsync(cancellationToken); + + if (string.IsNullOrEmpty(recipient.Email) || setting == "N") + { + return; + } + + var viewData = new ViewDataDictionary( + new EmptyModelMetadataProvider(), + new ModelStateDictionary() + ) + { + ["Subject"] = $"New share from {sender.FullnameCalc}", + ["Body"] = HtmlHelpers.MarkdownToHtml(request.Message ?? string.Empty, _config), + ["Sender"] = sender, + ["Receiver"] = recipient, + }; + + var body = await _renderer.RenderPartialToStringAsync("_EmailTemplate", viewData); + await _emailer.SendAsync( + $"New share from {sender.FullnameCalc}", + HtmlHelpers.MinifyHtml(body), + sender.Email, + recipient.Email + ); + } + + private static object BuildRequester(ClaimsPrincipal user) + { + var email = user.GetUserEmail(); + var name = user.GetUserName(); + + if (string.IsNullOrWhiteSpace(email)) + { + return new { name }; + } + + return new { email_id = email }; + } + + private List QueryRecipients( + string queryString, + string handler, + string type + ) + { + return _solr + .Query( + new SolrQuery(queryString), + new QueryOptions + { + RequestHandler = new RequestHandlerParameters(handler), + StartOrCursor = new StartOrCursor.Start(0), + Rows = 20, + } + ) + .Select(x => new RecipientSearchResultDto + { + Id = x.AtlasId, + Name = x.Name, + Type = type, + Email = x.Email, + }) + .ToList(); + } +} diff --git a/web/Services/JwtTokenService.cs b/web/Services/JwtTokenService.cs new file mode 100644 index 00000000..c291f123 --- /dev/null +++ b/web/Services/JwtTokenService.cs @@ -0,0 +1,44 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace Atlas_Web.Services; + +public class JwtTokenService +{ + private readonly SymmetricSecurityKey _signingKey; + private readonly string _issuer; + private readonly string _audience; + + public JwtTokenService(SymmetricSecurityKey signingKey, string issuer, string audience) + { + _signingKey = signingKey ?? throw new ArgumentNullException(nameof(signingKey)); + _issuer = issuer ?? throw new ArgumentNullException(nameof(issuer)); + _audience = audience ?? throw new ArgumentNullException(nameof(audience)); + } + + public string IssueToken(string username, string fullname, int userId) + { +#pragma warning disable S6781 // JWT secret keys should not be disclosed - False positive: key is injected at startup, not read from config + var creds = new SigningCredentials(_signingKey, SecurityAlgorithms.HmacSha256); +#pragma warning restore S6781 + + var claims = new[] + { + new Claim(ClaimTypes.Name, username), + new Claim("Fullname", fullname), + new Claim("UserId", userId.ToString()), + }; + + var token = new JwtSecurityToken( + issuer: _issuer, + audience: _audience, + claims: claims, + expires: DateTime.UtcNow.AddHours(8), + signingCredentials: creds + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/web/Services/Profile/ProfileApiService.cs b/web/Services/Profile/ProfileApiService.cs new file mode 100644 index 00000000..fb45565a --- /dev/null +++ b/web/Services/Profile/ProfileApiService.cs @@ -0,0 +1,697 @@ +using System.Text.RegularExpressions; +using Atlas_Web.Contracts.Api.Profile; +using Atlas_Web.Models; +using Microsoft.EntityFrameworkCore; + +namespace Atlas_Web.Services; + +public interface IProfileApiService +{ + Task GetChartAsync( + ProfileQueryRequestDto request, + CancellationToken cancellationToken + ); + Task> GetUsersAsync( + ProfileQueryRequestDto request, + CancellationToken cancellationToken + ); + Task> GetReportsAsync( + ProfileQueryRequestDto request, + CancellationToken cancellationToken + ); + Task> GetFailsAsync( + ProfileQueryRequestDto request, + CancellationToken cancellationToken + ); + Task> GetRunListAsync( + int id, + string type, + List reportType, + CancellationToken cancellationToken + ); + Task> GetStarsAsync( + int id, + string type, + CancellationToken cancellationToken + ); + Task> GetSubscriptionsAsync( + int id, + string type, + CancellationToken cancellationToken + ); +} + +public sealed class ProfileApiService : IProfileApiService +{ + private const string ReportType = "report"; + private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1); + private static readonly Regex RsPrefixRegex = new(@"^rs", RegexOptions.Multiline, RegexTimeout); + private static readonly Regex SplitCamelCaseRegex = new( + @"(?<=[a-z])([A-Z])", + RegexOptions.None, + RegexTimeout + ); + + private sealed class ProfileRunRow + { + public int? RunUserId { get; init; } + public User RunUser { get; init; } + public DateTime RunStartTime { get; init; } + public DateTime RunStartTime_Hour { get; init; } + public DateTime RunStartTime_Day { get; init; } + public DateTime RunStartTime_Month { get; init; } + public int RunDurationSeconds { get; init; } + public string RunStatus { get; init; } + public int Runs { get; init; } + public int ReportObjectId { get; init; } + public string Name { get; init; } + public string DisplayTitle { get; init; } + } + + private sealed class ProfileQueryOptions + { + public int Id { get; init; } + public string Type { get; init; } + public double StartAt { get; init; } + public double EndAt { get; init; } + public List Server { get; init; } = new(); + public List Database { get; init; } = new(); + public List MasterFile { get; init; } = new(); + public List Visible { get; init; } = new(); + public List Certification { get; init; } = new(); + public List Availability { get; init; } = new(); + public List ReportTypes { get; init; } = new(); + } + + private sealed class ProfileSubqueryResult + { + public IQueryable Flattened { get; init; } + public IQueryable> Grouped { get; init; } + public string DateFormat { get; init; } + } + + private readonly Atlas_WebContext _context; + private readonly IConfiguration _config; + + public ProfileApiService(Atlas_WebContext context, IConfiguration config) + { + _context = context; + _config = config; + } + + public async Task GetChartAsync( + ProfileQueryRequestDto request, + CancellationToken cancellationToken + ) + { + var query = ToQueryOptions(request); + var subqueryResult = await BuildSubqueriesAsync(query, cancellationToken); + var subquery = subqueryResult.Flattened; + var subqueryGroup = subqueryResult.Grouped; + var dateFormat = subqueryResult.DateFormat; + + var history = await ( + from grp in subqueryGroup + orderby grp.Key + select new ProfileRunHistoryPointDto + { + Date = grp.Key.ToString(dateFormat), + Users = grp.Select(x => x.RunUserId).Distinct().Count(), + Runs = grp.Sum(x => x.Runs), + RunTime = Math.Round(grp.Average(x => x.RunDurationSeconds), 1), + } + ) + .AsNoTracking() + .ToListAsync(cancellationToken); + + var totalRuns = history.Sum(x => x.Runs); + var distinctUsers = await subquery.Select(x => x.RunUserId).Distinct().CountAsync(cancellationToken); + var averageRunTime = totalRuns > 0 + ? Math.Round(await subquery.AverageAsync(x => x.RunDurationSeconds, cancellationToken), 2) + : 0; + + return new ProfileChartResponseDto + { + Runs = totalRuns, + Users = distinctUsers, + RunTime = averageRunTime, + History = history, + }; + } + + public async Task> GetUsersAsync( + ProfileQueryRequestDto request, + CancellationToken cancellationToken + ) + { + var subquery = (await BuildSubqueriesAsync(ToQueryOptions(request), cancellationToken)).Flattened; + + var total = await subquery.SumAsync(x => x.Runs, cancellationToken); + return await ( + from a in subquery + group a by new { a.RunUserId, a.RunUser.FullnameCalc } into grp + select new ProfileBarItemDto + { + Key = grp.Key.FullnameCalc, + Count = grp.Sum(x => x.Runs), + Percent = total == 0 ? 0 : (double)grp.Sum(x => x.Runs) / total, + TitleOne = "Top Users", + Date = grp.Max(x => x.RunStartTime).ToShortDateString(), + DateTitle = "Last Run", + TitleTwo = "Runs", + Href = IsUserProfileEnabled() ? "/users?id=" + grp.Key.RunUserId : null, + } + ) + .OrderByDescending(x => x.Count) + .Take(20) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task> GetReportsAsync( + ProfileQueryRequestDto request, + CancellationToken cancellationToken + ) + { + var subquery = (await BuildSubqueriesAsync(ToQueryOptions(request), cancellationToken)).Flattened; + + var total = await subquery.SumAsync(x => x.Runs, cancellationToken); + return await ( + from a in subquery + group a by new + { + a.ReportObjectId, + Name = string.IsNullOrEmpty(a.DisplayTitle) ? a.Name : a.DisplayTitle, + } into grp + select new ProfileBarItemDto + { + Key = grp.Key.Name, + Count = grp.Sum(x => x.Runs), + Percent = total == 0 ? 0 : (double)grp.Sum(x => x.Runs) / total, + TitleOne = "Top Reports", + Date = grp.Max(x => x.RunStartTime).ToShortDateString(), + DateTitle = "Last Run", + TitleTwo = "Runs", + Href = "/reports?id=" + grp.Key.ReportObjectId, + } + ) + .OrderByDescending(x => x.Count) + .Take(20) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task> GetFailsAsync( + ProfileQueryRequestDto request, + CancellationToken cancellationToken + ) + { + var subquery = (await BuildSubqueriesAsync(ToQueryOptions(request), cancellationToken)).Flattened; + + var total = await subquery.SumAsync(x => x.Runs, cancellationToken); + return await ( + from a in subquery + where a.RunStatus != "Success" + group a by a.RunStatus into grp + select new ProfileBarItemDto + { + Key = SplitCamelCaseRegex.Replace( + RsPrefixRegex.Replace(grp.Key, string.Empty), + " $1" + ), + Count = grp.Sum(x => x.Runs), + Percent = total == 0 ? 0 : (double)grp.Sum(x => x.Runs) / total, + TitleOne = "Failed Runs", + TitleTwo = "Fails", + } + ) + .OrderByDescending(x => x.Count) + .Take(20) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task> GetRunListAsync( + int id, + string type, + List reportType, + CancellationToken cancellationToken + ) + { + reportType ??= new List(); + var runData = _context.ReportObjectRunDatas.AsQueryable(); + + if (string.Equals(type, ReportType, StringComparison.OrdinalIgnoreCase)) + { + return await ( + from b in runData + from d in b.ReportObjectRunDataBridges + where d.ReportObjectId == id + group new { b, d } by new { b.RunUserId, b.RunUser.FullnameCalc } into grp + orderby grp.Max(x => x.b.RunStartTime) descending + select new ProfileRunListItemDto + { + Name = grp.Key.FullnameCalc, + Url = IsUserProfileEnabled() ? "\\users?id=" + grp.Key.RunUserId : null, + Runs = grp.Sum(x => x.d.Runs), + LastRun = grp.Max(x => x.b.RunStartTime).ToShortDateString(), + } + ) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + if (string.Equals(type, "user", StringComparison.OrdinalIgnoreCase)) + { + runData = runData.Where(x => x.RunUserId == id); + } + else if (string.Equals(type, "group", StringComparison.OrdinalIgnoreCase)) + { + runData = runData.Where(x => x.RunUser.UserGroupsMemberships.Any(g => g.GroupId == id)); + } + + var reports = _context.ReportObjects.AsQueryable(); + if (reportType.Count > 0) + { + reports = reports.Where(x => reportType.Contains((int)x.ReportObjectTypeId)); + } + + return await ( + from r in reports + join b in _context.ReportObjectRunDataBridges on r.ReportObjectId equals b.ReportObjectId + join d in runData on b.RunId equals d.RunDataId + where b.Inherited == 0 + group new { r, b, d } by new + { + r.ReportObjectId, + Name = string.IsNullOrEmpty(r.DisplayTitle) ? r.Name : r.DisplayTitle, + r.ReportObjectType.ShortName, + ReportTypeName = r.ReportObjectType.Name, + } into grp + orderby grp.Max(x => x.d.RunStartTime) descending + select new ProfileRunListItemDto + { + Name = grp.Key.Name, + Type = string.IsNullOrEmpty(grp.Key.ShortName) + ? grp.Key.ReportTypeName + : grp.Key.ShortName, + Url = "\\reports?id=" + grp.Key.ReportObjectId, + Runs = grp.Sum(x => x.b.Runs), + LastRun = grp.Max(x => x.d.RunStartTime).ToShortDateString(), + } + ) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task> GetStarsAsync( + int id, + string type, + CancellationToken cancellationToken + ) + { + var starsQuery = await BuildStarsQueryAsync(id, type, cancellationToken); + return await starsQuery.Select(x => new ProfileStarUserDto + { + Id = x.UserId, + FullName = x.FullnameCalc, + Email = x.Email, + }) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task> GetSubscriptionsAsync( + int id, + string type, + CancellationToken cancellationToken + ) + { + if ( + !string.Equals(type, ReportType, StringComparison.OrdinalIgnoreCase) + || !await _context.ReportObjects.AnyAsync(x => x.ReportObjectId == id, cancellationToken) + ) + { + throw InvalidType(type, id); + } + + return await _context.ReportObjectSubscriptions.Where(r => r.ReportObjectId == id) + .Include(x => x.User) + .Select(x => new ProfileSubscriptionDto + { + Id = x.ReportObjectSubscriptionsId, + UserId = x.UserId, + UserName = x.User != null ? x.User.FullnameCalc : null, + EmailList = x.EmailList, + Description = x.Description, + LastStatus = x.LastStatus, + LastRunTime = x.LastRunTime, + SubscriptionTo = x.SubscriptionTo, + }) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + private async Task BuildSubqueriesAsync( + ProfileQueryOptions query, + CancellationToken cancellationToken + ) + { + var now = DateTime.Now; + var start = now.AddSeconds(query.StartAt); + var end = now.AddSeconds(query.EndAt); + + var runData = _context.ReportObjectRunDatas.AsQueryable(); + var reports = _context.ReportObjects.AsQueryable(); + + (runData, reports) = await ApplyProfileScopeAsync( + runData, + reports, + query, + cancellationToken + ); + + var joined = from d in runData + join b in _context.ReportObjectRunDataBridges on d.RunDataId equals b.RunId + join r in reports on b.ReportObjectId equals r.ReportObjectId + select new ProfileRunRow + { + RunUserId = d.RunUserId, + RunUser = d.RunUser, + RunStartTime = d.RunStartTime, + RunStartTime_Hour = d.RunStartTime_Hour, + RunStartTime_Day = d.RunStartTime_Day, + RunStartTime_Month = d.RunStartTime_Month, + RunDurationSeconds = d.RunDurationSeconds ?? 0, + RunStatus = d.RunStatus, + Runs = b.Runs, + ReportObjectId = b.ReportObjectId, + Name = r.Name, + DisplayTitle = r.DisplayTitle, + }; + + var (grouped, dateFormat) = BuildDateGrouping(joined, query, start, end); + + var flattened = grouped.SelectMany(x => x); + return new ProfileSubqueryResult + { + Flattened = flattened, + Grouped = grouped, + DateFormat = dateFormat, + }; + } + + private async Task> BuildStarsQueryAsync( + int id, + string type, + CancellationToken cancellationToken + ) + { + if (string.Equals(type, ReportType, StringComparison.OrdinalIgnoreCase)) + { + if (!await _context.ReportObjects.AnyAsync(x => x.ReportObjectId == id, cancellationToken)) + { + throw InvalidType(type, id); + } + + return _context.Users.Where(x => x.StarredReports.Any(r => r.Reportid == id)); + } + + if (string.Equals(type, "term", StringComparison.OrdinalIgnoreCase)) + { + if (!await _context.Terms.AnyAsync(x => x.TermId == id, cancellationToken)) + { + throw InvalidType(type, id); + } + + return _context.Users.Where(x => x.StarredTerms.Any(r => r.Termid == id)); + } + + if (string.Equals(type, "collection", StringComparison.OrdinalIgnoreCase)) + { + if (!await _context.Collections.AnyAsync(x => x.CollectionId == id, cancellationToken)) + { + throw InvalidType(type, id); + } + + return _context.Users.Where(x => x.StarredCollections.Any(r => r.Collectionid == id)); + } + + throw InvalidType(type, id); + } + + private static IQueryable ApplyReportFilters( + IQueryable reports, + ProfileQueryOptions query + ) + { + if (query.Server.Count > 0) + { + reports = reports.Where(x => query.Server.Contains(x.SourceServer)); + } + + if (query.Database.Count > 0) + { + reports = reports.Where(x => query.Database.Contains(x.SourceDb)); + } + + if (query.MasterFile.Count > 0) + { + reports = reports.Where(x => + query.MasterFile.Contains(x.EpicMasterFile) + || (query.MasterFile.Contains("None") && string.IsNullOrEmpty(x.EpicMasterFile)) + ); + } + + if (query.Visible.Count > 0) + { + reports = reports.Where(x => + query.Visible.Contains(x.DefaultVisibilityYn) + || (query.Visible.Contains("Y") && string.IsNullOrEmpty(x.DefaultVisibilityYn)) + ); + } + + if (query.Certification.Count > 0) + { + reports = reports.Where(x => + query.Certification.Intersect(x.ReportTagLinks.Select(y => y.Tag.Name)).Any() + ); + } + + if (query.Availability.Count > 0) + { + reports = reports.Where(x => + query.Availability.Contains(x.Availability) + || (query.Availability.Contains("Public") && string.IsNullOrEmpty(x.Availability)) + ); + } + + if (query.ReportTypes.Count > 0) + { + reports = reports.Where(x => query.ReportTypes.Contains((int)x.ReportObjectTypeId)); + } + + return reports; + } + + private bool IsUserProfileEnabled() + { + return _config["features:enable_user_profile"] == null + || string.Equals( + _config["features:enable_user_profile"], + bool.TrueString, + StringComparison.OrdinalIgnoreCase + ); + } + + private static InvalidOperationException InvalidType(string type, int id) + { + return new InvalidOperationException( + "Wrong parameter value supplied. Type: " + type + " with Id: " + id + ); + } + + private static ProfileQueryOptions ToQueryOptions(ProfileQueryRequestDto request) + { + return new ProfileQueryOptions + { + Id = request.Id, + Type = request.Type, + StartAt = request.StartAt, + EndAt = request.EndAt, + Server = request.Server ?? new List(), + Database = request.Database ?? new List(), + MasterFile = request.MasterFile ?? new List(), + Visible = request.Visible ?? new List(), + Certification = request.Certification ?? new List(), + Availability = request.Availability ?? new List(), + ReportTypes = request.ReportType ?? new List(), + }; + } + + private async Task<(IQueryable RunData, IQueryable Reports)> ApplyProfileScopeAsync( + IQueryable runData, + IQueryable reports, + ProfileQueryOptions query, + CancellationToken cancellationToken + ) + { + if (string.Equals(query.Type, ReportType, StringComparison.OrdinalIgnoreCase)) + { + return await ApplyReportScopeAsync(runData, reports, query, cancellationToken); + } + + if (string.Equals(query.Type, "term", StringComparison.OrdinalIgnoreCase)) + { + return await ApplyTermScopeAsync(runData, reports, query, cancellationToken); + } + + if (string.Equals(query.Type, "collection", StringComparison.OrdinalIgnoreCase)) + { + return await ApplyCollectionScopeAsync(runData, reports, query, cancellationToken); + } + + if (string.Equals(query.Type, "user", StringComparison.OrdinalIgnoreCase)) + { + return await ApplyUserScopeAsync(runData, reports, query, cancellationToken); + } + + if (string.Equals(query.Type, "group", StringComparison.OrdinalIgnoreCase)) + { + return await ApplyGroupScopeAsync(runData, reports, query, cancellationToken); + } + + throw InvalidType(query.Type, query.Id); + } + + private async Task<(IQueryable RunData, IQueryable Reports)> ApplyReportScopeAsync( + IQueryable runData, + IQueryable reports, + ProfileQueryOptions query, + CancellationToken cancellationToken + ) + { + if (query.Id == -1) + { + return (runData, ApplyReportFilters(reports, query)); + } + + if (await _context.ReportObjects.AnyAsync(x => x.ReportObjectId == query.Id, cancellationToken)) + { + return (runData, reports.Where(x => x.ReportObjectId == query.Id)); + } + + throw InvalidType(query.Type, query.Id); + } + + private async Task<(IQueryable RunData, IQueryable Reports)> ApplyTermScopeAsync( + IQueryable runData, + IQueryable reports, + ProfileQueryOptions query, + CancellationToken cancellationToken + ) + { + if (!await _context.Terms.AnyAsync(x => x.TermId == query.Id, cancellationToken)) + { + throw InvalidType(query.Type, query.Id); + } + + reports = reports.Where(x => + _context.ReportObjectDocTerms.Where(t => t.TermId == query.Id) + .Select(t => t.ReportObjectId) + .Contains(x.ReportObjectId) + ); + + return (runData, reports); + } + + private async Task<(IQueryable RunData, IQueryable Reports)> ApplyCollectionScopeAsync( + IQueryable runData, + IQueryable reports, + ProfileQueryOptions query, + CancellationToken cancellationToken + ) + { + if (!await _context.Collections.AnyAsync(x => x.CollectionId == query.Id, cancellationToken)) + { + throw InvalidType(query.Type, query.Id); + } + + reports = reports.Where(x => + _context.CollectionReports.Where(c => c.CollectionId == query.Id) + .Select(c => c.ReportId) + .Contains(x.ReportObjectId) + ); + + return (runData, reports); + } + + private async Task<(IQueryable RunData, IQueryable Reports)> ApplyUserScopeAsync( + IQueryable runData, + IQueryable reports, + ProfileQueryOptions query, + CancellationToken cancellationToken + ) + { + if (!await _context.Users.AnyAsync(x => x.UserId == query.Id, cancellationToken)) + { + throw InvalidType(query.Type, query.Id); + } + + runData = runData.Where(x => x.RunUserId == query.Id); + return (runData, ApplyReportFilters(reports, query)); + } + + private async Task<(IQueryable RunData, IQueryable Reports)> ApplyGroupScopeAsync( + IQueryable runData, + IQueryable reports, + ProfileQueryOptions query, + CancellationToken cancellationToken + ) + { + if (!await _context.UserGroups.AnyAsync(x => x.GroupId == query.Id, cancellationToken)) + { + throw InvalidType(query.Type, query.Id); + } + + runData = runData.Where(x => x.RunUser.UserGroupsMemberships.Any(g => g.GroupId == query.Id)); + return (runData, ApplyReportFilters(reports, query)); + } + + private static ( + IQueryable> Grouped, + string DateFormat + ) BuildDateGrouping( + IQueryable joined, + ProfileQueryOptions query, + DateTime start, + DateTime end + ) + { + var range = query.EndAt - query.StartAt; + + if (range < 172800) + { + return ( + joined.Where(x => x.RunStartTime_Hour >= start && x.RunStartTime_Hour <= end) + .GroupBy(x => x.RunStartTime_Hour), + "h tt" + ); + } + + if (range < 31536000) + { + return ( + joined.Where(x => x.RunStartTime_Day >= start && x.RunStartTime_Day <= end) + .GroupBy(x => x.RunStartTime_Day), + range < 691200 ? "ddd M/d" : "MMM d" + ); + } + + return ( + joined.Where(x => x.RunStartTime_Month >= start && x.RunStartTime_Month <= end) + .GroupBy(x => x.RunStartTime_Month), + "MMM yy" + ); + } +} diff --git a/web/Services/Reports/ReportsApiService.Reads.cs b/web/Services/Reports/ReportsApiService.Reads.cs new file mode 100644 index 00000000..4cb66d4b --- /dev/null +++ b/web/Services/Reports/ReportsApiService.Reads.cs @@ -0,0 +1,766 @@ +using Atlas_Web.Authorization; +using Atlas_Web.Contracts.Api.Reports; +using Atlas_Web.Models; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; + +namespace Atlas_Web.Services; + +public sealed partial class ReportsApiService +{ + private async Task ReportExistsAsync(int id, CancellationToken cancellationToken) + { + return await _context.ReportObjects.AsNoTracking() + .AnyAsync(x => x.ReportObjectId == id, cancellationToken); + } + + private bool IsFeatureEnabled(string key) + { + var value = _configuration[key]; + return string.IsNullOrWhiteSpace(value) + || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } + + private ReportFeatureFlagsDto BuildFeatureFlags() + { + return new ReportFeatureFlagsDto + { + TermsEnabled = IsFeatureEnabled("features:enable_terms"), + UserProfilesEnabled = IsFeatureEnabled("features:enable_user_profile"), + FeedbackEnabled = IsFeatureEnabled("features:enable_feedback"), + RequestAccessEnabled = IsFeatureEnabled("features:enable_request_access"), + SharingEnabled = IsFeatureEnabled("features:enable_sharing"), + }; + } + + private async Task PopulateRunAuthorizationAsync( + ClaimsPrincipal user, + IReadOnlyList reports, + CancellationToken cancellationToken + ) + { + if (reports.Count == 0) + { + return; + } + + var reportIds = reports.Select(x => x.Id).ToArray(); + var authorizationReports = await LoadAuthorizationReportsAsync(reportIds, cancellationToken); + var authorizationLookup = authorizationReports.ToDictionary(x => x.ReportObjectId); + + foreach (var report in reports) + { + report.CanRun = + authorizationLookup.TryGetValue(report.Id, out var authorizationReport) + && ( + await _authorizationService.AuthorizeAsync( + user, + authorizationReport, + "ReportRunPolicy" + ) + ).Succeeded; + } + } + + private async Task CanRunReportAsync( + ClaimsPrincipal user, + int reportId, + CancellationToken cancellationToken + ) + { + var authorizationReports = await LoadAuthorizationReportsAsync( + new[] { reportId }, + cancellationToken + ); + var authorizationReport = authorizationReports.SingleOrDefault(); + if (authorizationReport == null) + { + return false; + } + + var authorizationResult = await _authorizationService.AuthorizeAsync( + user, + authorizationReport, + "ReportRunPolicy" + ); + return authorizationResult.Succeeded; + } + + private async Task> LoadAuthorizationReportsAsync( + IReadOnlyCollection reportIds, + CancellationToken cancellationToken + ) + { + return await _context + .ReportObjects.AsNoTracking() + .Where(x => reportIds.Contains(x.ReportObjectId)) + .Include(x => x.ReportObjectType) + .Include(x => x.ReportGroupsMemberships) + .Include(x => x.ReportObjectHierarchyChildReportObjects) + .ThenInclude(x => x.ParentReportObject) + .ThenInclude(x => x.ReportGroupsMemberships) + .ToListAsync(cancellationToken); + } + + private async Task GetReportCoreAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken, + bool visibleOnly + ) + { + var canEditDocumentation = user.HasPermission("Edit Report Documentation"); + var canViewGroups = user.HasPermission("View Groups"); + var canViewPurgeOption = user.HasPermission("Edit Report Purge Option"); + var canViewHiddenOption = user.HasPermission("Edit Report Hidden Option"); + var features = BuildFeatureFlags(); + var currentUserId = user.GetUserId(); + var query = _context.ReportObjects.AsNoTracking().Where(x => x.ReportObjectId == id); + + if (visibleOnly) + { + query = query + .Where(x => x.DefaultVisibilityYn == "Y") + .Where(x => (x.ReportObjectDoc.Hidden ?? "N") == "N"); + } + + var report = await query + .Select(x => new ReportDetailDto + { + Id = x.ReportObjectId, + Name = x.Name, + DisplayTitle = x.DisplayTitle, + DisplayName = x.DisplayTitle ?? x.Name, + Description = x.Description, + DetailedDescription = x.DetailedDescription, + TypeName = x.ReportObjectType != null ? x.ReportObjectType.Name : null, + TypeShortName = x.ReportObjectType != null ? x.ReportObjectType.ShortName : null, + Url = x.ReportObjectUrl, + EpicMasterFile = x.EpicMasterFile, + EpicRecordId = x.EpicRecordId, + EpicReportTemplateId = x.EpicReportTemplateId, + ReportServerPath = x.ReportServerPath, + Availability = x.Availability, + OrphanedReportObjectYn = x.OrphanedReportObjectYn, + RepositoryDescription = x.RepositoryDescription, + VisibleInSearch = + (x.OrphanedReportObjectYn ?? "N") == "N" + && x.ReportObjectType != null + && x.ReportObjectType.Visible == "Y" + && x.DefaultVisibilityYn == "Y" + && (x.ReportObjectDoc == null || (x.ReportObjectDoc.Hidden ?? "N") == "N"), + Runs = x.Runs, + LastModified = x.LastModifiedDate, + LastLoadDate = x.LastLoadDate, + CanEditDocumentation = canEditDocumentation, + IsStarred = x.StarredReports.Any(y => y.Ownerid == currentUserId), + Author = x.AuthorUser == null + ? null + : new UserSummaryDto + { + Id = x.AuthorUser.UserId, + Username = x.AuthorUser.Username, + FullName = x.AuthorUser.FullnameCalc ?? x.AuthorUser.DisplayName, + Email = x.AuthorUser.Email, + }, + LastModifiedBy = x.LastModifiedByUser == null + ? null + : new UserSummaryDto + { + Id = x.LastModifiedByUser.UserId, + Username = x.LastModifiedByUser.Username, + FullName = x.LastModifiedByUser.FullnameCalc ?? x.LastModifiedByUser.DisplayName, + Email = x.LastModifiedByUser.Email, + }, + Document = x.ReportObjectDoc == null + ? null + : new ReportDocumentDto + { + ReportObjectId = x.ReportObjectDoc.ReportObjectId, + GitLabProjectUrl = x.ReportObjectDoc.GitLabProjectUrl, + DeveloperDescription = x.ReportObjectDoc.DeveloperDescription, + KeyAssumptions = x.ReportObjectDoc.KeyAssumptions, + ExecutiveVisibilityYn = x.ReportObjectDoc.ExecutiveVisibilityYn, + LastUpdateDateTime = x.ReportObjectDoc.LastUpdateDateTime, + CreatedDateTime = x.ReportObjectDoc.CreatedDateTime, + EnabledForHyperspace = x.ReportObjectDoc.EnabledForHyperspace, + DoNotPurge = x.ReportObjectDoc.DoNotPurge, + Hidden = x.ReportObjectDoc.Hidden, + DeveloperNotes = x.ReportObjectDoc.DeveloperNotes, + OrganizationalValue = x.ReportObjectDoc.OrganizationalValue == null + ? null + : new LookupDto + { + Id = x.ReportObjectDoc.OrganizationalValue.Id, + Name = x.ReportObjectDoc.OrganizationalValue.Name, + }, + EstimatedRunFrequency = x.ReportObjectDoc.EstimatedRunFrequency == null + ? null + : new LookupDto + { + Id = x.ReportObjectDoc.EstimatedRunFrequency.Id, + Name = x.ReportObjectDoc.EstimatedRunFrequency.Name, + }, + Fragility = x.ReportObjectDoc.Fragility == null + ? null + : new LookupDto + { + Id = x.ReportObjectDoc.Fragility.Id, + Name = x.ReportObjectDoc.Fragility.Name, + }, + MaintenanceSchedule = x.ReportObjectDoc.MaintenanceSchedule == null + ? null + : new LookupDto + { + Id = x.ReportObjectDoc.MaintenanceSchedule.Id, + Name = x.ReportObjectDoc.MaintenanceSchedule.Name, + }, + OperationalOwner = x.ReportObjectDoc.OperationalOwnerUser == null + ? null + : new UserSummaryDto + { + Id = x.ReportObjectDoc.OperationalOwnerUser.UserId, + Username = x.ReportObjectDoc.OperationalOwnerUser.Username, + FullName = x.ReportObjectDoc.OperationalOwnerUser.FullnameCalc + ?? x.ReportObjectDoc.OperationalOwnerUser.DisplayName, + Email = x.ReportObjectDoc.OperationalOwnerUser.Email, + }, + Requester = x.ReportObjectDoc.RequesterNavigation == null + ? null + : new UserSummaryDto + { + Id = x.ReportObjectDoc.RequesterNavigation.UserId, + Username = x.ReportObjectDoc.RequesterNavigation.Username, + FullName = x.ReportObjectDoc.RequesterNavigation.FullnameCalc + ?? x.ReportObjectDoc.RequesterNavigation.DisplayName, + Email = x.ReportObjectDoc.RequesterNavigation.Email, + }, + UpdatedBy = x.ReportObjectDoc.UpdatedByNavigation == null + ? null + : new UserSummaryDto + { + Id = x.ReportObjectDoc.UpdatedByNavigation.UserId, + Username = x.ReportObjectDoc.UpdatedByNavigation.Username, + FullName = x.ReportObjectDoc.UpdatedByNavigation.FullnameCalc + ?? x.ReportObjectDoc.UpdatedByNavigation.DisplayName, + Email = x.ReportObjectDoc.UpdatedByNavigation.Email, + }, + FragilityTags = x.ReportObjectDoc.ReportObjectDocFragilityTags + .OrderBy(y => y.FragilityTag.Name) + .Select(y => new LookupDto + { + Id = y.FragilityTag.Id, + Name = y.FragilityTag.Name, + }) + .ToList(), + MaintenanceLogs = x.ReportObjectDoc.MaintenanceLogs + .OrderByDescending(y => y.MaintenanceDate) + .Select(y => new ReportMaintenanceLogDto + { + Id = y.MaintenanceLogId, + MaintenanceDate = y.MaintenanceDate, + Comment = y.Comment, + Status = y.MaintenanceLogStatus == null + ? null + : new LookupDto + { + Id = y.MaintenanceLogStatus.Id, + Name = y.MaintenanceLogStatus.Name, + }, + Maintainer = y.Maintainer == null + ? null + : new UserSummaryDto + { + Id = y.Maintainer.UserId, + Username = y.Maintainer.Username, + FullName = y.Maintainer.FullnameCalc ?? y.Maintainer.DisplayName, + Email = y.Maintainer.Email, + }, + }) + .ToList(), + ServiceRequests = x.ReportObjectDoc.ReportServiceRequests + .OrderByDescending(y => y.ServiceRequestId) + .Select(y => new ReportServiceRequestDto + { + Id = y.ServiceRequestId, + TicketNumber = y.TicketNumber, + Description = y.Description, + TicketUrl = y.TicketUrl, + }) + .ToList(), + }, + HeaderTags = x.ReportTagLinks + .OrderBy(y => y.Tag.Priority) + .ThenBy(y => y.Tag.Name) + .Select(y => new ReportTagDto + { + Id = y.TagId, + Name = y.Tag.Name, + Description = y.Tag.Description, + Priority = y.Tag.Priority, + ShowInHeader = y.ShowInHeader, + }) + .ToList(), + ObjectTags = x.ReportObjectTagMemberships + .OrderBy(y => y.Line) + .ThenBy(y => y.Tag.TagName) + .Select(y => new ReportObjectTagDto + { + Id = y.TagId, + Name = y.Tag.TagName, + Line = y.Line, + }) + .ToList(), + Attachments = x.ReportObjectAttachments + .OrderBy(y => y.Name) + .Select(y => new ReportAttachmentDto + { + Id = y.ReportObjectAttachmentId, + Name = y.Name, + Path = y.Path, + Source = y.Source, + Type = y.Type, + CreationDate = y.CreationDate, + }) + .ToList(), + Images = x.ReportObjectImagesDocs + .OrderBy(y => y.ImageOrdinal) + .Select(y => new ReportImageDto + { + Id = y.ImageId, + Ordinal = y.ImageOrdinal, + Source = y.ImageSource, + }) + .ToList(), + Groups = x.ReportGroupsMemberships + .OrderBy(y => y.Group.GroupName) + .Select(y => new GroupSummaryDto + { + Id = y.GroupId, + Name = y.Group.GroupName, + Email = y.Group.GroupEmail, + Type = y.Group.GroupType, + }) + .ToList(), + Collections = x.CollectionReports + .OrderBy(y => y.Rank) + .ThenBy(y => y.DataProject.Name) + .Select(y => new CollectionSummaryDto + { + Id = y.CollectionId, + Name = y.DataProject.Name, + Rank = y.Rank, + }) + .ToList(), + Parameters = x.ReportObjectParameters + .OrderBy(y => y.ParameterName) + .Select(y => new ReportParameterDto + { + Id = y.ReportObjectParameterId, + Name = y.ParameterName, + Value = y.ParameterValue, + }) + .ToList(), + Queries = x.ReportObjectQueries + .OrderBy(y => y.Name) + .Select(y => new ReportQueryDto + { + Id = y.ReportObjectQueryId, + ReportObjectId = y.ReportObjectId, + Name = y.Name, + Language = y.Language, + SourceServer = y.SourceServer, + Query = y.Query, + LastLoadDate = y.LastLoadDate, + }) + .ToList(), + StarCount = x.StarredReports.Count, + }) + .SingleOrDefaultAsync(cancellationToken); + + if (report == null) + { + return null; + } + + report.Features = features; + report.Terms = await GetTermsAsync(id, cancellationToken); + report.ComponentQueries = await GetComponentQueriesAsync(id, cancellationToken); + report.Children = await GetChildrenAsync(id, cancellationToken); + report.Parents = await GetParentsAsync(id, cancellationToken); + report.MaintenanceStatus = await GetMaintenanceStatusAsync(id, cancellationToken); + report.CanRun = await CanRunReportAsync(user, id, cancellationToken); + ApplyDetailVisibility( + report, + canEditDocumentation, + canViewGroups, + canViewPurgeOption, + canViewHiddenOption + ); + ApplyReportActions(report, user); + + return report; + } + + private async Task> GetTermsAsync( + int id, + CancellationToken cancellationToken + ) + { + return await _context + .Terms.AsNoTracking() + .Where(x => x.ReportObjectDocTerms.Any(y => y.ReportObjectId == id)) + .Union( + _context.Terms.Where(x => + x.ReportObjectDocTerms.Any(y => + y.ReportObject.ReportObject.ReportObjectHierarchyChildReportObjects.Any(z => + z.ParentReportObjectId == id + ) + ) + ) + ) + .Union( + _context.Terms.Where(x => + x.ReportObjectDocTerms.Any(y => + y.ReportObject.ReportObject.ReportObjectHierarchyChildReportObjects.Any(z => + z.ParentReportObject.ReportObjectHierarchyChildReportObjects.Any(a => + a.ParentReportObjectId == id + ) + ) + ) + ) + ) + .Union( + _context.Terms.Where(x => + x.ReportObjectDocTerms.Any(y => + y.ReportObject.ReportObject.ReportObjectHierarchyChildReportObjects.Any(z => + z.ParentReportObject.ReportObjectHierarchyChildReportObjects.Any(a => + a.ParentReportObject.ReportObjectHierarchyChildReportObjects.Any(b => + b.ParentReportObjectId == id + ) + ) + ) + ) + ) + ) + .Union( + _context.Terms.Where(x => + x.ReportObjectDocTerms.Any(y => + y.ReportObject.ReportObject.ReportObjectHierarchyChildReportObjects.Any(z => + z.ParentReportObject.ReportObjectHierarchyChildReportObjects.Any(a => + a.ParentReportObject.ReportObjectHierarchyChildReportObjects.Any(b => + b.ParentReportObject.ReportObjectHierarchyChildReportObjects.Any(c => + c.ParentReportObjectId == id + ) + ) + ) + ) + ) + ) + ) + .Distinct() + .OrderBy(x => x.Name) + .Select(x => new TermSummaryDto + { + Id = x.TermId, + Name = x.Name, + Summary = x.Summary, + }) + .ToListAsync(cancellationToken); + } + + private async Task> GetComponentQueriesAsync( + int id, + CancellationToken cancellationToken + ) + { + return await _context + .ReportObjectQueries.AsNoTracking() + .Where(x => + x.ReportObject.ReportObjectHierarchyChildReportObjects.Any(y => + y.ParentReportObject.ReportObjectHierarchyChildReportObjects.Any(z => + z.ParentReportObjectId == id && z.ParentReportObject.EpicMasterFile == "IDB" + ) + ) + ) + .OrderBy(x => x.Name) + .Select(x => new ReportQueryDto + { + Id = x.ReportObjectQueryId, + ReportObjectId = x.ReportObjectId, + Name = x.Name, + Language = x.Language, + SourceServer = x.SourceServer, + Query = x.Query, + LastLoadDate = x.LastLoadDate, + }) + .ToListAsync(cancellationToken); + } + + private async Task> GetChildrenAsync( + int id, + CancellationToken cancellationToken + ) + { + return await _context + .ReportObjects.AsNoTracking() + .Where(x => + x.ReportObjectHierarchyChildReportObjects.Any(y => y.ParentReportObjectId == id) + ) + .Where(x => x.EpicMasterFile != "IDK") + .Where(x => (x.ReportObjectDoc.Hidden ?? "N") == "N") + .Where(x => x.DefaultVisibilityYn == "Y") + .Union( + _context.ReportObjects.Where(x => + x.ReportObjectHierarchyChildReportObjects.Any(y => + y.ParentReportObject.ReportObjectHierarchyChildReportObjects.Any(z => + z.ParentReportObjectId == id + && z.ParentReportObject.DefaultVisibilityYn == "Y" + ) + && y.ParentReportObject.EpicMasterFile == "IDK" + ) + ) + .Where(x => x.EpicMasterFile == "IDN") + .Where(x => (x.ReportObjectDoc.Hidden ?? "N") == "N") + ) + .OrderBy(x => x.DisplayTitle ?? x.Name) + .Select(x => new ReportLinkSummaryDto + { + Id = x.ReportObjectId, + Name = x.DisplayTitle ?? x.Name, + Type = x.ReportObjectType != null ? x.ReportObjectType.ShortName : null, + Url = x.ReportObjectUrl, + LastModified = x.LastModifiedDate, + AttachmentCount = x.ReportObjectAttachments.Count, + }) + .ToListAsync(cancellationToken); + } + + private async Task> GetParentsAsync( + int id, + CancellationToken cancellationToken + ) + { + return await _context + .ReportObjects.AsNoTracking() + .Where(x => + x.ReportObjectHierarchyParentReportObjects.Any(y => y.ChildReportObjectId == id) + ) + .Where(x => x.ReportObjectTypeId != 12) + .Where(x => x.EpicMasterFile != "IDK") + .Where(x => x.DefaultVisibilityYn == "Y") + .Where(x => (x.ReportObjectDoc.Hidden ?? "N") == "N") + .Union( + _context.ReportObjects.Where(x => + x.ReportObjectHierarchyParentReportObjects.Any(y => + y.ChildReportObject.ReportObjectHierarchyParentReportObjects.Any(z => + z.ChildReportObjectId == id + ) + && y.ChildReportObject.EpicMasterFile == "IDK" + ) + ) + .Where(x => x.EpicMasterFile == "IDB") + .Where(x => x.DefaultVisibilityYn == "Y") + .Where(x => (x.ReportObjectDoc.Hidden ?? "N") == "N") + ) + .OrderBy(x => x.DisplayTitle ?? x.Name) + .Select(x => new ReportLinkSummaryDto + { + Id = x.ReportObjectId, + Name = x.DisplayTitle ?? x.Name, + Type = x.ReportObjectType != null ? x.ReportObjectType.ShortName : null, + Url = x.ReportObjectUrl, + LastModified = x.LastModifiedDate, + AttachmentCount = x.ReportObjectAttachments.Count, + }) + .ToListAsync(cancellationToken); + } + + private async Task GetMaintenanceStatusAsync( + int id, + CancellationToken cancellationToken + ) + { + var maintenanceData = await _context + .ReportObjectDocs.AsNoTracking() + .Where(x => x.ReportObjectId == id && (x.MaintenanceScheduleId ?? 1) != 5) + .Select(x => new + { + ScheduleId = x.MaintenanceScheduleId ?? 1, + ScheduleName = x.MaintenanceSchedule != null ? x.MaintenanceSchedule.Name : null, + LastMaintenanceDate = x.MaintenanceLogs.Max(y => y.MaintenanceDate), + }) + .SingleOrDefaultAsync(cancellationToken); + + if (maintenanceData == null) + { + return null; + } + + var today = DateTime.UtcNow; + var baseDate = maintenanceData.LastMaintenanceDate ?? today; + var nextMaintenanceDate = maintenanceData.ScheduleId switch + { + 1 => baseDate.AddMonths(3), + 2 => baseDate.AddMonths(6), + 3 => baseDate.AddYears(1), + 4 => baseDate.AddYears(2), + _ => baseDate, + }; + + var isRequired = nextMaintenanceDate < today; + return new ReportMaintenanceStatusDto + { + IsRequired = isRequired, + Message = isRequired ? "Report requires maintenance." : null, + LastMaintenanceDate = maintenanceData.LastMaintenanceDate, + NextMaintenanceDate = nextMaintenanceDate, + Schedule = new LookupDto + { + Id = maintenanceData.ScheduleId, + Name = maintenanceData.ScheduleName, + }, + }; + } + + private void ApplyReportActions( + ReportDetailDto report, + ClaimsPrincipal user + ) + { + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext == null) + { + return; + } + + var actionReport = new ReportObject + { + ReportObjectId = report.Id, + Name = report.Name, + DisplayTitle = report.DisplayTitle, + Description = report.Description, + ReportObjectUrl = report.Url, + EpicMasterFile = report.EpicMasterFile, + EpicRecordId = report.EpicRecordId, + EpicReportTemplateId = report.EpicReportTemplateId, + ReportServerPath = report.ReportServerPath, + Availability = report.Availability, + OrphanedReportObjectYn = "N", + ReportObjectType = new ReportObjectType + { + Name = report.TypeName, + ShortName = report.TypeShortName, + }, + ReportObjectDoc = report.Document == null + ? null + : new ReportObjectDoc + { + EnabledForHyperspace = report.Document.EnabledForHyperspace, + }, + }; + + report.RunUrl = actionReport.RunReportUrl( + httpContext, + _configuration, + report.CanRun + ); + report.RecordViewerUrl = actionReport.RecordViewerUrl(httpContext); + report.CanViewUserProfiles = + report.Features?.UserProfilesEnabled == true + && user.HasPermission("View Other User"); + if (user.HasPermission("Open In Editor")) + { + report.EditReportUrl = actionReport.EditReportUrl(httpContext, _configuration); + report.ManageReportUrl = actionReport.ManageReportUrl(httpContext, _configuration); + } + + var basePath = $"{httpContext.Request.Scheme}://{httpContext.Request.Host}"; + foreach (var attachment in report.Attachments) + { + attachment.RunUrl = $"{basePath}/Data/File?handler=CrystalRun&id={attachment.Id}"; + } + } + + private static void ApplyDetailVisibility( + ReportDetailDto report, + bool canEditDocumentation, + bool canViewGroups, + bool canViewPurgeOption, + bool canViewHiddenOption + ) + { + if (canEditDocumentation || report.Document == null) + { + if (report.Document != null) + { + report.Document = new ReportDocumentDto + { + ReportObjectId = report.Document.ReportObjectId, + GitLabProjectUrl = report.Document.GitLabProjectUrl, + DeveloperDescription = report.Document.DeveloperDescription, + KeyAssumptions = report.Document.KeyAssumptions, + ExecutiveVisibilityYn = report.Document.ExecutiveVisibilityYn, + LastUpdateDateTime = report.Document.LastUpdateDateTime, + CreatedDateTime = report.Document.CreatedDateTime, + EnabledForHyperspace = report.Document.EnabledForHyperspace, + DoNotPurge = canViewPurgeOption ? report.Document.DoNotPurge : null, + Hidden = canViewHiddenOption ? report.Document.Hidden : null, + DeveloperNotes = report.Document.DeveloperNotes, + OrganizationalValue = report.Document.OrganizationalValue, + EstimatedRunFrequency = report.Document.EstimatedRunFrequency, + Fragility = report.Document.Fragility, + MaintenanceSchedule = report.Document.MaintenanceSchedule, + OperationalOwner = report.Document.OperationalOwner, + Requester = report.Document.Requester, + UpdatedBy = report.Document.UpdatedBy, + FragilityTags = report.Document.FragilityTags, + MaintenanceLogs = report.Document.MaintenanceLogs, + ServiceRequests = report.Document.ServiceRequests, + }; + } + if (!canViewGroups) + { + report.Groups = Array.Empty(); + } + if (report.Features?.TermsEnabled != true) + { + report.Terms = Array.Empty(); + } + report.CanViewGroups = canViewGroups; + return; + } + + report.Document = new ReportDocumentDto + { + ReportObjectId = report.Document.ReportObjectId, + DeveloperDescription = report.Document.DeveloperDescription, + KeyAssumptions = report.Document.KeyAssumptions, + ExecutiveVisibilityYn = report.Document.ExecutiveVisibilityYn, + LastUpdateDateTime = report.Document.LastUpdateDateTime, + CreatedDateTime = report.Document.CreatedDateTime, + OrganizationalValue = report.Document.OrganizationalValue, + EstimatedRunFrequency = report.Document.EstimatedRunFrequency, + Fragility = report.Document.Fragility, + MaintenanceSchedule = report.Document.MaintenanceSchedule, + OperationalOwner = report.Document.OperationalOwner, + Requester = report.Document.Requester, + UpdatedBy = report.Document.UpdatedBy, + DoNotPurge = canViewPurgeOption ? report.Document.DoNotPurge : null, + Hidden = canViewHiddenOption ? report.Document.Hidden : null, + MaintenanceLogs = report.Document.MaintenanceLogs, + FragilityTags = report.Document.FragilityTags, + ServiceRequests = report.Document.ServiceRequests, + }; + + if (report.Features?.TermsEnabled != true) + { + report.Terms = Array.Empty(); + } + report.CanViewGroups = canViewGroups; + if (!canViewGroups) + { + report.Groups = Array.Empty(); + } + } +} diff --git a/web/Services/Reports/ReportsApiService.Writes.cs b/web/Services/Reports/ReportsApiService.Writes.cs new file mode 100644 index 00000000..f1351bf9 --- /dev/null +++ b/web/Services/Reports/ReportsApiService.Writes.cs @@ -0,0 +1,236 @@ +using Atlas_Web.Authorization; +using Atlas_Web.Contracts.Api.Reports; +using Atlas_Web.Models; +using Atlas_Web.Pages.Search; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using SolrNet; +using SolrNet.Commands.Parameters; +using System.Security.Claims; + +namespace Atlas_Web.Services; + +public sealed partial class ReportsApiService +{ + private List SearchObjects(string search, string handler) + { + var queryString = IndexModel.BuildSearchString( + search ?? string.Empty, + _httpContextAccessor.HttpContext?.Request.Query ?? new QueryCollection() + ); + + return _solr + .Query( + new SolrQuery(queryString), + new QueryOptions + { + RequestHandler = new RequestHandlerParameters(handler), + StartOrCursor = new StartOrCursor.Start(0), + Rows = 10, + } + ) + .Select(x => new ReportSearchResultDto + { + Id = x.AtlasId, + Name = x.Name, + Description = x.Description != null ? x.Description.FirstOrDefault() : string.Empty, + }) + .ToList(); + } + + private async Task SynchronizeTermsAsync( + int reportId, + IReadOnlyList termIds, + CancellationToken cancellationToken + ) + { + var normalizedIds = termIds.Distinct().ToList(); + var existing = await _context.ReportObjectDocTerms.Where(x => x.ReportObjectId == reportId) + .ToListAsync(cancellationToken); + + foreach (var termId in normalizedIds.Where(termId => existing.All(x => x.TermId != termId))) + { + await _context.ReportObjectDocTerms.AddAsync( + new ReportObjectDocTerm { ReportObjectId = reportId, TermId = termId }, + cancellationToken + ); + } + + _context.ReportObjectDocTerms.RemoveRange( + existing.Where(x => !normalizedIds.Contains(x.TermId)) + ); + } + + private async Task SynchronizeCollectionsAsync( + int reportId, + IReadOnlyList collectionIds, + CancellationToken cancellationToken + ) + { + var normalizedIds = collectionIds.Distinct().ToList(); + var existing = await _context.CollectionReports.Where(x => x.ReportId == reportId) + .ToListAsync(cancellationToken); + + foreach (var link in existing.Where(x => !normalizedIds.Contains(x.CollectionId))) + { + _context.CollectionReports.Remove(link); + } + + for (var index = 0; index < normalizedIds.Count; index++) + { + var collectionId = normalizedIds[index]; + var existingLink = existing.FirstOrDefault(x => x.CollectionId == collectionId); + if (existingLink == null) + { + await _context.CollectionReports.AddAsync( + new CollectionReport + { + ReportId = reportId, + CollectionId = collectionId, + Rank = index, + }, + cancellationToken + ); + } + else + { + existingLink.Rank = index; + } + } + } + + private async Task SynchronizeFragilityTagsAsync( + int reportId, + IReadOnlyList fragilityTagIds, + CancellationToken cancellationToken + ) + { + var normalizedIds = fragilityTagIds.Distinct().ToList(); + var existing = await _context.ReportObjectDocFragilityTags + .Where(x => x.ReportObjectId == reportId) + .ToListAsync(cancellationToken); + + foreach (var fragilityTagId in normalizedIds.Where(id => existing.All(x => x.FragilityTagId != id))) + { + await _context.ReportObjectDocFragilityTags.AddAsync( + new ReportObjectDocFragilityTag + { + ReportObjectId = reportId, + FragilityTagId = fragilityTagId, + }, + cancellationToken + ); + } + + _context.ReportObjectDocFragilityTags.RemoveRange( + existing.Where(x => !normalizedIds.Contains(x.FragilityTagId)) + ); + } + + private async Task SynchronizeImagesAsync( + int reportId, + IReadOnlyList imageIds, + CancellationToken cancellationToken + ) + { + var normalizedIds = imageIds.Distinct().ToList(); + var existing = await _context.ReportObjectImagesDocs.Where(x => x.ReportObjectId == reportId) + .ToListAsync(cancellationToken); + + _context.ReportObjectImagesDocs.RemoveRange( + existing.Where(x => !normalizedIds.Contains(x.ImageId)) + ); + + for (var index = 0; index < normalizedIds.Count; index++) + { + var image = existing.FirstOrDefault(x => x.ImageId == normalizedIds[index]); + if (image != null) + { + image.ImageOrdinal = index; + } + } + } + + private async Task SynchronizeServiceRequestsAsync( + int reportId, + IReadOnlyList serviceRequestIds, + CancellationToken cancellationToken + ) + { + var normalizedIds = serviceRequestIds.Distinct().ToList(); + var existing = await _context.ReportServiceRequests.Where(x => x.ReportObjectId == reportId) + .ToListAsync(cancellationToken); + + _context.ReportServiceRequests.RemoveRange( + existing.Where(x => !normalizedIds.Contains(x.ServiceRequestId)) + ); + } + + private async Task AddServiceRequestAsync( + int reportId, + NewReportServiceRequestDto request, + CancellationToken cancellationToken + ) + { + if (request == null || string.IsNullOrWhiteSpace(request.TicketNumber)) + { + return; + } + + await _context.ReportServiceRequests.AddAsync( + new ReportServiceRequest + { + ReportObjectId = reportId, + TicketNumber = request.TicketNumber, + Description = request.Description, + TicketUrl = request.TicketUrl, + }, + cancellationToken + ); + } + + private async Task AddMaintenanceLogAsync( + ClaimsPrincipal user, + int reportId, + NewMaintenanceLogDto request, + CancellationToken cancellationToken + ) + { + if (request?.MaintenanceLogStatusId == null) + { + return; + } + + await _context.AddAsync( + new MaintenanceLog + { + ReportId = reportId, + MaintainerId = user.GetUserId(), + MaintenanceDate = DateTime.UtcNow, + MaintenanceLogStatusId = request.MaintenanceLogStatusId, + Comment = request.Comment, + }, + cancellationToken + ); + } + + private static void ValidateImageUpload(IFormFile file) + { + var contentType = file.ContentType.ToLowerInvariant(); + if ( + contentType != "image/jpeg" + && contentType != "image/png" + && contentType != "image/gif" + ) + { + throw new InvalidOperationException("You may only upload jpeg, png or gif files."); + } + + if (file.Length > 1024 * 1024) + { + throw new InvalidOperationException( + "The file is larger than 1MB. Please use a smaller image." + ); + } + } +} diff --git a/web/Services/Reports/ReportsApiService.cs b/web/Services/Reports/ReportsApiService.cs new file mode 100644 index 00000000..5f983ea8 --- /dev/null +++ b/web/Services/Reports/ReportsApiService.cs @@ -0,0 +1,648 @@ +using Atlas_Web.Authorization; +using Atlas_Web.Contracts.Api.Reports; +using Atlas_Web.Helpers; +using Atlas_Web.Models; +using Atlas_Web.Pages.Search; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using SolrNet; +using SolrNet.Commands.Parameters; +using System.Linq.Expressions; +using System.Security.Claims; + +namespace Atlas_Web.Services; + +public interface IReportsApiService +{ + Task GetReportsAsync( + ClaimsPrincipal user, + int page, + int pageSize, + CancellationToken cancellationToken + ); + Task GetReportAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ); + Task> GetReportTermsAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ); + Task GetReportQueriesAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ); + Task GetReportRelationshipsAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ); + Task GetReportMaintenanceStatusAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ); + Task UpdateReportAsync( + ClaimsPrincipal user, + int id, + UpdateReportDocumentRequestDto request, + CancellationToken cancellationToken + ); + Task AddImageAsync( + ClaimsPrincipal user, + int id, + IFormFile file, + CancellationToken cancellationToken + ); + Task> GetLookupValuesAsync( + string lookupArea, + CancellationToken cancellationToken + ); + Task> SearchTermsAsync( + string search, + CancellationToken cancellationToken + ); + Task> SearchCollectionsAsync( + string search, + CancellationToken cancellationToken + ); + Task> SearchUsersAsync( + string search, + CancellationToken cancellationToken + ); +} + +public sealed partial class ReportsApiService : IReportsApiService +{ + private const int MaxPageSize = 100; + private readonly IAuthorizationService _authorizationService; + private readonly Atlas_WebContext _context; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IConfiguration _configuration; + private readonly IMemoryCache _cache; + private readonly ISolrReadOnlyOperations _solr; + private readonly ISolrReadOnlyOperations _solrLookup; + + public ReportsApiService( + Atlas_WebContext context, + IAuthorizationService authorizationService, + IHttpContextAccessor httpContextAccessor, + IConfiguration configuration, + IMemoryCache cache, + ISolrReadOnlyOperations solr, + ISolrReadOnlyOperations solrLookup + ) + { + _context = context; + _authorizationService = authorizationService; + _httpContextAccessor = httpContextAccessor; + _configuration = configuration; + _cache = cache; + _solr = solr; + _solrLookup = solrLookup; + } + + public async Task GetReportsAsync( + ClaimsPrincipal user, + int page, + int pageSize, + CancellationToken cancellationToken + ) + { + var safePage = Math.Max(page, 1); + var safePageSize = Math.Clamp(pageSize, 1, MaxPageSize); + + var query = _context + .ReportObjects.AsNoTracking() + .Where(x => x.DefaultVisibilityYn == "Y") + .Where(x => (x.ReportObjectDoc.Hidden ?? "N") == "N"); + + var total = await query.CountAsync(cancellationToken); + var reports = await query + .OrderBy(x => x.DisplayTitle ?? x.Name) + .Select(x => new ReportListItemDto + { + Id = x.ReportObjectId, + Name = x.DisplayTitle ?? x.Name, + Description = x.Description, + Type = x.ReportObjectType != null ? x.ReportObjectType.ShortName : null, + Url = x.ReportObjectUrl, + LastModified = x.LastModifiedDate, + }) + .Skip((safePage - 1) * safePageSize) + .Take(safePageSize) + .ToListAsync(cancellationToken); + + await PopulateRunAuthorizationAsync(user, reports, cancellationToken); + + return new ReportListResponseDto + { + Reports = reports, + Total = total, + Page = safePage, + PageSize = safePageSize, + }; + } + + public async Task GetReportAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ) + { + return await GetReportCoreAsync(user, id, cancellationToken, visibleOnly: true); + } + + public async Task> GetReportTermsAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ) + { + var exists = await ReportExistsAsync(id, cancellationToken); + if (!exists || !IsFeatureEnabled("features:enable_terms")) + { + return Array.Empty(); + } + + return await GetTermsAsync(id, cancellationToken); + } + + public async Task GetReportQueriesAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ) + { + if (!await ReportExistsAsync(id, cancellationToken)) + { + return null; + } + + var detail = await GetReportCoreAsync(user, id, cancellationToken, visibleOnly: true); + if (detail == null) + { + return null; + } + + return new ReportQueriesResponseDto + { + Queries = detail.Queries, + ComponentQueries = detail.ComponentQueries, + }; + } + + public async Task GetReportRelationshipsAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ) + { + if (!await ReportExistsAsync(id, cancellationToken)) + { + return null; + } + + var detail = await GetReportCoreAsync(user, id, cancellationToken, visibleOnly: true); + if (detail == null) + { + return null; + } + + return new ReportRelationshipsResponseDto + { + CanViewGroups = detail.CanViewGroups, + Groups = detail.Groups, + Collections = detail.Collections, + Children = detail.Children, + Parents = detail.Parents, + }; + } + + public async Task GetReportMaintenanceStatusAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ) + { + if (!await ReportExistsAsync(id, cancellationToken)) + { + return null; + } + + var detail = await GetReportCoreAsync(user, id, cancellationToken, visibleOnly: true); + return detail?.MaintenanceStatus; + } + + public async Task UpdateReportAsync( + ClaimsPrincipal user, + int id, + UpdateReportDocumentRequestDto request, + CancellationToken cancellationToken + ) + { + await ValidateUpdateRequestAsync(id, request, cancellationToken); + + var reportExists = await _context.ReportObjects.AnyAsync( + x => x.ReportObjectId == id, + cancellationToken + ); + if (!reportExists) + { + return null; + } + + var previousTermIds = await _context.ReportObjectDocTerms.Where(x => x.ReportObjectId == id) + .Select(x => x.TermId) + .ToListAsync(cancellationToken); + var previousCollectionIds = await _context.CollectionReports.Where(x => x.ReportId == id) + .Select(x => x.CollectionId) + .ToListAsync(cancellationToken); + + var existingDocument = await _context.ReportObjectDocs.SingleOrDefaultAsync( + x => x.ReportObjectId == id, + cancellationToken + ); + + if (existingDocument == null) + { + existingDocument = new ReportObjectDoc + { + ReportObjectId = id, + CreatedDateTime = DateTime.UtcNow, + CreatedBy = user.GetUserId(), + }; + await _context.ReportObjectDocs.AddAsync(existingDocument, cancellationToken); + } + + existingDocument.GitLabProjectUrl = request.GitLabProjectUrl; + existingDocument.DeveloperDescription = request.DeveloperDescription; + existingDocument.KeyAssumptions = request.KeyAssumptions; + existingDocument.OperationalOwnerUserId = request.OperationalOwnerUserId; + existingDocument.Requester = request.RequesterUserId; + existingDocument.OrganizationalValueId = request.OrganizationalValueId; + existingDocument.EstimatedRunFrequencyId = request.EstimatedRunFrequencyId; + existingDocument.FragilityId = request.FragilityId; + existingDocument.ExecutiveVisibilityYn = request.ExecutiveVisibilityYn; + existingDocument.MaintenanceScheduleId = request.MaintenanceScheduleId; + existingDocument.EnabledForHyperspace = request.EnabledForHyperspace; + existingDocument.DoNotPurge = request.DoNotPurge; + existingDocument.Hidden = request.Hidden; + existingDocument.DeveloperNotes = request.DeveloperNotes; + existingDocument.LastUpdateDateTime = DateTime.UtcNow; + existingDocument.UpdatedBy = user.GetUserId(); + + await SynchronizeTermsAsync(id, request.TermIds, cancellationToken); + await SynchronizeCollectionsAsync(id, request.CollectionIds, cancellationToken); + await SynchronizeFragilityTagsAsync(id, request.FragilityTagIds, cancellationToken); + await SynchronizeImagesAsync(id, request.ImageIds, cancellationToken); + await SynchronizeServiceRequestsAsync(id, request.ServiceRequestIds, cancellationToken); + await AddMaintenanceLogAsync(user, id, request.NewMaintenanceLog, cancellationToken); + await AddServiceRequestAsync(id, request.NewServiceRequest, cancellationToken); + + await _context.SaveChangesAsync(cancellationToken); + + InvalidateReportCaches( + id, + previousTermIds.Concat(request.TermIds).Distinct(), + previousCollectionIds.Concat(request.CollectionIds).Distinct() + ); + + return await GetReportCoreAsync(user, id, cancellationToken, visibleOnly: false); + } + + public async Task AddImageAsync( + ClaimsPrincipal user, + int id, + IFormFile file, + CancellationToken cancellationToken + ) + { + if (!await _context.ReportObjects.AnyAsync(x => x.ReportObjectId == id, cancellationToken)) + { + return null; + } + + ValidateImageUpload(file); + + var nextOrdinal = + await _context.ReportObjectImagesDocs.Where(x => x.ReportObjectId == id) + .MaxAsync(x => (int?)x.ImageOrdinal, cancellationToken) ?? -1; + + var image = new ReportObjectImagesDoc + { + ReportObjectId = id, + ImageOrdinal = nextOrdinal + 1, + }; + + await using (var stream = new MemoryStream()) + { + await file.CopyToAsync(stream, cancellationToken); + image.ImageData = stream.ToArray(); + } + + await _context.ReportObjectImagesDocs.AddAsync(image, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + InvalidateReportCaches(id, Array.Empty(), Array.Empty()); + + return new ReportImageDto + { + Id = image.ImageId, + Ordinal = image.ImageOrdinal, + Source = image.ImageSource, + }; + } + + public Task> GetLookupValuesAsync( + string lookupArea, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var indexType = lookupArea switch + { + "org-value" => "organizational_value", + "run-freq" => "run_frequency", + "fragility" => "fragility", + "maint-sched" => "maintenance_schedule", + "ro-fragility" => "fragility_tag", + "maint-log-stat" => "maintenance_log_status", + "user-roles" => "user_roles", + "financial-impact" => "financial_impact", + "strategic-importance" => "strategic_importance", + _ => null, + }; + + if (string.IsNullOrEmpty(indexType)) + { + return Task.FromResult>(Array.Empty()); + } + + var values = _solrLookup + .Query( + new SolrQuery($"item_type:({indexType})"), + new QueryOptions + { + RequestHandler = new RequestHandlerParameters("/query"), + StartOrCursor = new StartOrCursor.Start(0), + Rows = 9999, + } + ) + .Select(x => new LookupDto + { + Id = x.AtlasId, + Name = x.Name, + }) + .ToList(); + + return Task.FromResult>(values); + } + + public Task> SearchTermsAsync( + string search, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult>( + SearchObjects(search, "/aterms") + ); + } + + public Task> SearchCollectionsAsync( + string search, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult>( + SearchObjects(search, "/collections") + ); + } + + public Task> SearchUsersAsync( + string search, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var queryString = IndexModel.BuildSearchString( + search ?? string.Empty, + _httpContextAccessor.HttpContext?.Request.Query ?? new QueryCollection() + ); + var results = _solr + .Query( + new SolrQuery(queryString), + new QueryOptions + { + RequestHandler = new RequestHandlerParameters("/users"), + StartOrCursor = new StartOrCursor.Start(0), + Rows = 10, + } + ) + .Select(x => new ReportSearchResultDto + { + Id = x.AtlasId, + Name = x.Name, + Description = x.Email, + }) + .ToList(); + + return Task.FromResult>(results); + } + + private async Task ValidateUpdateRequestAsync( + int reportId, + UpdateReportDocumentRequestDto request, + CancellationToken cancellationToken + ) + { + if (request == null) + { + throw new InvalidOperationException("Request body is required."); + } + + await ValidateOptionalUserAsync( + request.OperationalOwnerUserId, + "Unknown operational owner user id.", + cancellationToken + ); + await ValidateOptionalUserAsync( + request.RequesterUserId, + "Unknown requester user id.", + cancellationToken + ); + await ValidateOptionalLookupAsync( + _context.OrganizationalValues, + request.OrganizationalValueId, + x => x.Id, + "Unknown organizational value id.", + cancellationToken + ); + await ValidateOptionalLookupAsync( + _context.EstimatedRunFrequencies, + request.EstimatedRunFrequencyId, + x => x.Id, + "Unknown estimated run frequency id.", + cancellationToken + ); + await ValidateOptionalLookupAsync( + _context.Fragilities, + request.FragilityId, + x => x.Id, + "Unknown fragility id.", + cancellationToken + ); + await ValidateOptionalLookupAsync( + _context.MaintenanceSchedules, + request.MaintenanceScheduleId, + x => x.Id, + "Unknown maintenance schedule id.", + cancellationToken + ); + await ValidateOptionalLookupAsync( + _context.MaintenanceLogStatuses, + request.NewMaintenanceLog?.MaintenanceLogStatusId, + x => x.Id, + "Unknown maintenance log status id.", + cancellationToken + ); + + await ValidateLinkedIdsAsync( + _context.Terms.Select(x => x.TermId), + request.TermIds, + "term", + cancellationToken + ); + await ValidateLinkedIdsAsync( + _context.Collections.Select(x => x.CollectionId), + request.CollectionIds, + "collection", + cancellationToken + ); + await ValidateLinkedIdsAsync( + _context.FragilityTags.Select(x => x.Id), + request.FragilityTagIds, + "fragility tag", + cancellationToken + ); + + await ValidateOwnedIdsAsync( + _context.ReportObjectImagesDocs.Where(x => x.ReportObjectId == reportId).Select(x => x.ImageId), + request.ImageIds, + "image", + cancellationToken + ); + await ValidateOwnedIdsAsync( + _context.ReportServiceRequests.Where(x => x.ReportObjectId == reportId) + .Select(x => x.ServiceRequestId), + request.ServiceRequestIds, + "service request", + cancellationToken + ); + } + + private async Task ValidateOptionalUserAsync( + int? userId, + string errorMessage, + CancellationToken cancellationToken + ) + { + if ( + userId.HasValue + && !await _context.Users.AnyAsync(x => x.UserId == userId.Value, cancellationToken) + ) + { + throw new InvalidOperationException(errorMessage); + } + } + + private static async Task ValidateOptionalLookupAsync( + IQueryable query, + int? id, + Expression> selector, + string errorMessage, + CancellationToken cancellationToken + ) + where TEntity : class + { + if (!id.HasValue) + { + return; + } + + var values = await query.Select(selector).ToListAsync(cancellationToken); + if (!values.Contains(id.Value)) + { + throw new InvalidOperationException(errorMessage); + } + } + + private static async Task ValidateLinkedIdsAsync( + IQueryable validIdsQuery, + IReadOnlyList requestedIds, + string label, + CancellationToken cancellationToken + ) + { + var normalizedIds = requestedIds.Distinct().ToList(); + if (normalizedIds.Count == 0) + { + return; + } + + var validIds = await validIdsQuery.Where(x => normalizedIds.Contains(x)) + .ToListAsync(cancellationToken); + var missingIds = normalizedIds.Except(validIds).ToList(); + if (missingIds.Count > 0) + { + throw new InvalidOperationException( + $"Unknown {label} ids: {string.Join(", ", missingIds)}" + ); + } + } + + private static async Task ValidateOwnedIdsAsync( + IQueryable validIdsQuery, + IReadOnlyList requestedIds, + string label, + CancellationToken cancellationToken + ) + { + await ValidateLinkedIdsAsync(validIdsQuery, requestedIds, label, cancellationToken); + } + + private void InvalidateReportCaches( + int reportId, + IEnumerable termIds, + IEnumerable collectionIds + ) + { + _cache.Remove($"report-{reportId}"); + _cache.Remove($"report-terms-{reportId}"); + _cache.Remove($"report-comp-queries-{reportId}"); + _cache.Remove($"report-children-{reportId}"); + _cache.Remove($"report-parents-{reportId}"); + _cache.Remove($"search-report-{reportId}"); + _cache.Remove("terms"); + _cache.Remove("collections"); + + foreach (var termId in termIds.Distinct()) + { + _cache.Remove($"term-{termId}"); + } + + foreach (var collectionId in collectionIds.Distinct()) + { + _cache.Remove($"collection-{collectionId}"); + _cache.Remove($"search-collection-{collectionId}"); + } + } +} diff --git a/web/Services/Search/SearchApiService.cs b/web/Services/Search/SearchApiService.cs new file mode 100644 index 00000000..d76034e5 --- /dev/null +++ b/web/Services/Search/SearchApiService.cs @@ -0,0 +1,411 @@ +using System.Text.RegularExpressions; +using Atlas_Web.Authorization; +using Atlas_Web.Contracts.Api.Search; +using Atlas_Web.Models; +using Microsoft.EntityFrameworkCore; +using SolrNet; +using SolrNet.Commands.Parameters; +using System.Security.Claims; + +namespace Atlas_Web.Services; + +public interface ISearchApiService +{ + Task SearchAsync( + ClaimsPrincipal user, + string q, + string type, + int page, + int pageSize, + string field, + bool advanced, + IReadOnlyDictionary filters, + CancellationToken cancellationToken + ); +} + +public sealed class SearchApiService : ISearchApiService +{ + private const int MaxPageSize = 100; + + private static readonly string[] FacetOrder = + [ + "epic_master_file_text", + "organizational_value_text", + "estimated_run_frequency_text", + "maintenance_schedule_text", + "fragility_text", + "executive_visiblity_text", + "visible_text", + "certification_text", + "report_type_text", + "type", + ]; + + private static readonly string ReRankQuery = + "(type:collections^1.2 OR type:reports^2 OR documented:Y^0.1 OR executive_visibility:Y^0.2" + + " OR certification:\"Analytics Certified\"^0.4 OR certification:\"Analytics Reviewed\"^0.4)"; + + private readonly Atlas_WebContext _context; + private readonly ISolrReadOnlyOperations _solr; + + public SearchApiService( + Atlas_WebContext context, + ISolrReadOnlyOperations solr + ) + { + _context = context; + _solr = solr; + } + + public async Task SearchAsync( + ClaimsPrincipal user, + string q, + string type, + int page, + int pageSize, + string field, + bool advanced, + IReadOnlyDictionary filters, + CancellationToken cancellationToken + ) + { + var safePage = Math.Max(page, 1); + var safePageSize = Math.Clamp(pageSize, 1, MaxPageSize); + var handler = MapTypeToHandler(type); + var searchQuery = BuildSearchQuery(q, field); + var filterQueries = BuildFilterQueries(user, advanced, filters); + + var hlField = string.IsNullOrEmpty(field) ? "*" : field; + var hlRequireMatch = string.IsNullOrEmpty(field) ? "false" : "true"; + + var solrResults = await _solr.QueryAsync( + new SolrQuery(searchQuery), + new QueryOptions + { + RequestHandler = new RequestHandlerParameters(handler), + StartOrCursor = new StartOrCursor.Start((safePage - 1) * safePageSize), + Rows = safePageSize, + FilterQueries = filterQueries, + ExtraParams = new Dictionary + { + { "rq", "{!rerank reRankQuery=$rqq reRankDocs=1000 reRankWeight=5}" }, + { "rqq", ReRankQuery }, + { "hl.fl", hlField }, + { "hl.requireFieldMatch", hlRequireMatch }, + }, + } + ); + + var isAdvanced = + advanced && user.HasPermission("Show Advanced Search"); + + var results = solrResults + .OrderBy(x => x.Type == "collections" ? 0 : 1) + .Select(x => new SearchResultDto + { + Id = x.Id, + AtlasId = x.AtlasId, + Type = x.Type, + Name = x.Name, + Description = x.Description?.FirstOrDefault(), + Url = x.ReportObjectUrl, + ReportType = x.ReportType, + Email = x.Email, + EpicMasterFile = x.EpicMasterFile, + EpicRecordId = x.EpicRecordId, + EpicTemplateId = x.EpicTemplateId, + ReportServerPath = x.ReportServerPath, + ExecutiveVisibility = x.ExecutiveVisiblity, + SourceServer = x.SourceServer, + GroupType = x.GroupType, + Certifications = x.Certification?.ToList() ?? [], + Documented = x.Documented, + }) + .ToList(); + + await EnrichWithStarredStatusAsync(user, results, cancellationToken); + + return new SearchResponseDto + { + Results = results, + Facets = BuildFacets(solrResults.FacetFields), + Highlights = BuildHighlights(solrResults.Highlights), + FilterFields = BuildFilterFields(type), + Total = solrResults.NumFound, + Page = safePage, + PageSize = safePageSize, + QTime = solrResults.Header.QTime, + IsAdvancedSearch = isAdvanced, + }; + } + + private static string MapTypeToHandler(string type) + { + return type switch + { + "reports" => "/reports", + "terms" => "/aterms", + "collections" => "/collections", + "initiatives" => "/initiatives", + "users" => "/users", + "groups" => "/groups", + _ => "/query", + }; + } + + private static ISolrQuery[] BuildFilterQueries( + ClaimsPrincipal user, + bool advanced, + IReadOnlyDictionary filters + ) + { + var filterList = new List(); + + if (!user.HasPermission("Show Advanced Search") || !advanced) + { + filterList.Add(new SolrQuery("visible_text:(Y)")); + } + + foreach (var (key, value) in filters) + { + if (!string.IsNullOrWhiteSpace(value)) + { + filterList.Add(new SolrQuery($"{{!tag={key}}}{key}:({value.Trim()})")); + } + } + + return [.. filterList]; + } + + private static IReadOnlyList BuildFacets( + IDictionary>> facetFields + ) + { + return facetFields + .OrderByDescending(x => Array.IndexOf(FacetOrder, x.Key)) + .Select(f => new FacetDto + { + Key = f.Key, + Values = f.Value + .Select(v => new FacetValueDto { Value = v.Key, Count = v.Value }) + .ToList(), + }) + .ToList(); + } + + private static IReadOnlyList BuildHighlights( + IDictionary highlights + ) + { + return highlights + .Select(h => new HighlightDto + { + Id = h.Key, + Fields = h.Value + .Select(f => new HighlightFieldDto + { + Field = f.Key, + Snippet = f.Value.FirstOrDefault(), + }) + .ToList(), + }) + .ToList(); + } + + private static IReadOnlyList BuildFilterFields(string type) + { + if (type != "reports") + { + return []; + } + + return + [ + new FilterFieldDto { Key = "name", Label = "Name" }, + new FilterFieldDto { Key = "description", Label = "Description" }, + new FilterFieldDto { Key = "query", Label = "Query" }, + new FilterFieldDto { Key = "epic_record_id", Label = "Epic ID" }, + new FilterFieldDto { Key = "epic_template", Label = "Epic Template ID" }, + ]; + } + + private async Task EnrichWithStarredStatusAsync( + ClaimsPrincipal user, + IReadOnlyList results, + CancellationToken cancellationToken + ) + { + if (results.Count == 0) + { + return; + } + + var userId = user.GetUserId(); + + var reportIds = results.Where(x => x.Type == "reports").Select(x => x.AtlasId).ToList(); + var collectionIds = results + .Where(x => x.Type == "collections") + .Select(x => x.AtlasId) + .ToList(); + var termIds = results.Where(x => x.Type == "terms").Select(x => x.AtlasId).ToList(); + var initiativeIds = results + .Where(x => x.Type == "initiatives") + .Select(x => x.AtlasId) + .ToList(); + var userIds = results.Where(x => x.Type == "users").Select(x => x.AtlasId).ToList(); + var groupIds = results.Where(x => x.Type == "groups").Select(x => x.AtlasId).ToList(); + + var starredReports = reportIds.Count > 0 + ? (await _context.StarredReports + .Where(x => x.Ownerid == userId && reportIds.Contains(x.Reportid)) + .Select(x => x.Reportid) + .ToListAsync(cancellationToken)).ToHashSet() + : []; + + var starredCollections = collectionIds.Count > 0 + ? (await _context.StarredCollections + .Where(x => x.Ownerid == userId && collectionIds.Contains(x.Collectionid)) + .Select(x => x.Collectionid) + .ToListAsync(cancellationToken)).ToHashSet() + : []; + + var starredTerms = termIds.Count > 0 + ? (await _context.StarredTerms + .Where(x => x.Ownerid == userId && termIds.Contains(x.Termid)) + .Select(x => x.Termid) + .ToListAsync(cancellationToken)).ToHashSet() + : []; + + var starredInitiatives = initiativeIds.Count > 0 + ? (await _context.StarredInitiatives + .Where(x => x.Ownerid == userId && initiativeIds.Contains(x.Initiativeid)) + .Select(x => x.Initiativeid) + .ToListAsync(cancellationToken)).ToHashSet() + : []; + + var starredUsers = userIds.Count > 0 + ? (await _context.StarredUsers + .Where(x => x.Ownerid == userId && userIds.Contains(x.Userid)) + .Select(x => x.Userid) + .ToListAsync(cancellationToken)).ToHashSet() + : []; + + var starredGroups = groupIds.Count > 0 + ? (await _context.StarredGroups + .Where(x => x.Ownerid == userId && groupIds.Contains(x.Groupid)) + .Select(x => x.Groupid) + .ToListAsync(cancellationToken)).ToHashSet() + : []; + + foreach (var result in results) + { + result.IsStarred = result.Type switch + { + "reports" => starredReports.Contains(result.AtlasId), + "collections" => starredCollections.Contains(result.AtlasId), + "terms" => starredTerms.Contains(result.AtlasId), + "initiatives" => starredInitiatives.Contains(result.AtlasId), + "users" => starredUsers.Contains(result.AtlasId), + "groups" => starredGroups.Contains(result.AtlasId), + _ => false, + }; + } + } + + // Mirrors the query-building logic from the Search Razor Page. + private static string BuildSearchQuery(string searchString, string field) + { + string[] illegalChars = + [ + "\\", "+", "-", "&&", "||", "!", "(", ")", "{", "}", "[", "]", "^", "~", "*", "?", + ":", "/", + ]; + + foreach (var ch in illegalChars) + { + searchString = searchString.Replace(ch, "\\" + ch); + } + + searchString = Regex.Replace( + searchString, + @"\b(OR|AND|NOT)\b", + m => m.ToString().ToLower(), + RegexOptions.None, + TimeSpan.FromSeconds(1) + ); + + var exactMatches = new List(); + var literals = Regex.Matches(searchString, @"("")(.+?)("")", RegexOptions.None, TimeSpan.FromSeconds(1)); + + foreach (Match literal in literals) + { + if (!string.IsNullOrEmpty(field)) + { + exactMatches.Add($"{field}:\"{literal.Groups[2].Value}\""); + } + else + { + var v = literal.Groups[2].Value; + exactMatches.Add(string.Join( + " ", + $"name:\"{v}\"^8 OR", + $"description:\"{v}\"^5 OR", + $"email:\"{v}\" OR", + $"external_url:\"{v}\" OR", + $"financial_impact:\"{v}\" OR", + $"fragility_tags:\"{v}\" OR", + $"group_type:\"{v}\" OR", + $"linked_description:\"{v}\" OR", + $"maintenance_schedule:\"{v}\" OR", + $"operations_owner:\"{v}\" OR", + $"organizational_value:\"{v}\" OR", + $"related_collections:\"{v}\" OR", + $"related_initiatives:\"{v}\" OR", + $"related_reports:\"{v}\" OR", + $"related_terms:\"{v}\" OR", + $"report_last_updated_by:\"{v}\" OR", + $"report_type:\"{v}\" OR", + $"requester:\"{v}\" OR", + $"source_database:\"{v}\" OR", + $"strategic_importance:\"{v}\" OR", + $"updated_by:\"{v}\" OR", + $"user_groups:\"{v}\" OR", + $"user_roles:\"{v}\"" + )); + } + } + + searchString = Regex.Replace(searchString, @"("".+?"")", "", RegexOptions.None, TimeSpan.FromSeconds(1)) + .Replace("\"", "\\\"") + .Trim(); + + static string Combine(string wild, List exact) + { + if (exact.Count == 0) + { + return wild; + } + + var exactPart = string.Join(" AND ", exact); + return wild == "" ? exactPart : $"{exactPart} AND ({wild})"; + } + + if (searchString == "") + { + return Combine("", exactMatches); + } + + if (!string.IsNullOrEmpty(field)) + { + return Combine($"{field}:({searchString})^60", exactMatches); + } + + return Combine( + $"name:({searchString})^12 OR name_split:({searchString})^6" + + $" OR description:({searchString})^5 OR description_split:({searchString})^3" + + $" OR ({searchString})", + exactMatches + ); + } +} diff --git a/web/Services/Users/UsersApiService.Reads.cs b/web/Services/Users/UsersApiService.Reads.cs new file mode 100644 index 00000000..74cc0667 --- /dev/null +++ b/web/Services/Users/UsersApiService.Reads.cs @@ -0,0 +1,828 @@ +using System.Security.Claims; +using Atlas_Web.Authorization; +using Atlas_Web.Contracts.Api.Users; +using Atlas_Web.Helpers; +using Atlas_Web.Models; +using Microsoft.EntityFrameworkCore; + +namespace Atlas_Web.Services; + +public sealed partial class UsersApiService +{ + public async Task GetUserPageAsync( + ClaimsPrincipal user, + int requestedId, + CancellationToken cancellationToken + ) + { + var viewerId = user.GetUserId(); + var targetUserId = ResolveTargetUserId(user, requestedId); + var targetUser = await _context.Users.AsNoTracking() + .SingleOrDefaultAsync(x => x.UserId == targetUserId, cancellationToken); + + if (targetUser == null) + { + return null; + } + + var canViewOtherUsers = user.HasPermission("View Other User"); + var canViewGroups = user.HasPermission("View Groups"); + var canViewAnalytics = user.HasPermission("View Site Analytics"); + var canEditOtherUsers = user.HasPermission("Edit Other Users"); + var isCurrentUser = targetUserId == viewerId; + var canEditWorkspace = isCurrentUser || canEditOtherUsers; + + return new UserPageDto + { + User = new UserPageUserDto + { + Id = targetUser.UserId, + Username = targetUser.Username, + FullName = targetUser.FullnameCalc ?? targetUser.FullName ?? targetUser.DisplayName, + FirstName = targetUser.FirstnameCalc ?? targetUser.FirstName, + DisplayName = targetUser.DisplayName, + Email = targetUser.Email, + Department = targetUser.Department, + Title = targetUser.Title, + Phone = targetUser.Phone, + ProfilePhoto = targetUser.ProfilePhoto, + }, + Viewer = new UserPageViewerDto + { + Id = viewerId, + IsCurrentUser = isCurrentUser, + IsAdministrator = user.IsInRole("Administrator"), + AdminEnabled = user.HasAdminEnabled(), + }, + Permissions = new UserPagePermissionsDto + { + CanViewOtherUsers = canViewOtherUsers, + CanViewGroups = canViewGroups, + CanViewAnalytics = canViewAnalytics, + CanEditOtherUsers = canEditOtherUsers, + CanToggleAdminMode = user.IsInRole("Administrator"), + CanEditWorkspace = canEditWorkspace, + }, + Tabs = new UserPageTabsDto + { + StarsVisible = true, + SubscriptionsVisible = true, + ActivityVisible = true, + RunListVisible = true, + AtlasHistoryVisible = true, + GroupsVisible = canViewGroups, + AnalyticsVisible = canViewAnalytics, + }, + Features = new UserPageFeaturesDto + { + UserProfilesEnabled = IsUserProfileEnabled(), + }, + DefaultReportTypeIds = await _context.ReportObjectTypes.AsNoTracking() + .Where(x => x.Visible == "Y") + .OrderBy(x => x.ReportObjectTypeId) + .Select(x => x.ReportObjectTypeId) + .ToListAsync(cancellationToken), + }; + } + + public async Task GetStarsAsync( + ClaimsPrincipal user, + int requestedId, + CancellationToken cancellationToken + ) + { + var viewerId = user.GetUserId(); + var targetUserId = ResolveTargetUserId(user, requestedId); + var isCurrentUser = viewerId == targetUserId; + var canManageWorkspace = isCurrentUser || user.HasPermission("Edit Other Users"); + var folderLookup = await _context.UserFavoriteFolders.AsNoTracking() + .Where(x => x.UserId == targetUserId) + .ToDictionaryAsync(x => x.UserFavoriteFolderId, cancellationToken); + + var folders = await _context.UserFavoriteFolders.AsNoTracking() + .Where(x => x.UserId == targetUserId) + .Select(x => new UserFavoriteFolderDto + { + Id = x.UserFavoriteFolderId, + Name = x.FolderName, + Rank = x.FolderRank, + ItemCount = + x.StarredCollections.Count + + x.StarredGroups.Count + + x.StarredInitiatives.Count + + x.StarredReports.Count + + x.StarredSearches.Count + + x.StarredTerms.Count + + x.StarredUsers.Count, + CanManage = canManageWorkspace, + CanReorder = isCurrentUser, + }) + .ToListAsync(cancellationToken); + + var items = new List(); + items.AddRange( + await GetStarredReportsAsync( + user, + targetUserId, + folderLookup, + isCurrentUser, + cancellationToken + ) + ); + items.AddRange(await GetStarredCollectionsAsync(targetUserId, folderLookup, isCurrentUser, cancellationToken)); + items.AddRange(await GetStarredInitiativesAsync(targetUserId, folderLookup, isCurrentUser, cancellationToken)); + items.AddRange(await GetStarredTermsAsync(targetUserId, folderLookup, isCurrentUser, cancellationToken)); + items.AddRange(await GetStarredUsersAsync(targetUserId, folderLookup, isCurrentUser, cancellationToken)); + items.AddRange(await GetStarredGroupsAsync(targetUserId, folderLookup, isCurrentUser, cancellationToken)); + items.AddRange(await GetStarredSearchesAsync(targetUserId, folderLookup, isCurrentUser, cancellationToken)); + + var hasReports = items.Any(x => x.Type == "report"); + var hasCollections = items.Any(x => x.Type == "collection"); + var hasInitiatives = items.Any(x => x.Type == "initiative"); + var hasTerms = items.Any(x => x.Type == "term"); + var hasUsers = items.Any(x => x.Type == "user"); + var hasGroups = items.Any(x => x.Type == "group"); + var hasSearches = items.Any(x => x.Type == "search"); + var unsortedCount = items.Count(x => x.FolderId == null); + var totalCount = items.Count; + + var suggestedReports = new List(); + if (items.Count == 0) + { + suggestedReports = await _context.ReportObjects.AsNoTracking() + .Where(x => + x.ReportObjectRunDataBridges.Any(y => y.RunData.RunUserId == targetUserId) + && x.ReportObjectType.Visible == "Y" + ) + .OrderByDescending(x => + x.ReportObjectRunDataBridges.Where(y => y.RunData.RunUserId == targetUserId) + .Sum(y => y.Runs) + ) + .Take(30) + .Select(x => new UserSuggestedReportDto + { + Id = x.ReportObjectId, + Name = x.DisplayTitle ?? x.DisplayName ?? x.Name, + Description = x.Description, + Url = "/reports?id=" + x.ReportObjectId, + Type = x.ReportObjectType.ShortName, + }) + .ToListAsync(cancellationToken); + } + + return new UserStarsDto + { + UserId = targetUserId, + ViewerUserId = viewerId, + IsCurrentUser = isCurrentUser, + CanEditWorkspace = canManageWorkspace, + Permissions = new UserWorkspacePermissionsDto + { + CanCreateFolders = canManageWorkspace, + CanRenameFolders = canManageWorkspace, + CanDeleteFolders = canManageWorkspace, + CanReorderFolders = isCurrentUser, + CanReorderFavorites = isCurrentUser, + CanMoveFavoritesToFolders = canManageWorkspace, + CanToggleFavorites = canManageWorkspace, + }, + Summary = new UserWorkspaceSummaryDto + { + TotalCount = totalCount, + UnsortedCount = unsortedCount, + HasFolders = folders.Count > 0, + ShowUnsortedBucket = folders.Count > 0 && unsortedCount > 0, + }, + Filters = new UserWorkspaceFilterStateDto + { + HasReports = hasReports, + HasCollections = hasCollections, + HasInitiatives = hasInitiatives, + HasTerms = hasTerms, + HasUsers = hasUsers, + HasGroups = hasGroups, + HasSearches = hasSearches, + ShowQuickFilters = + new[] { hasReports, hasCollections, hasInitiatives, hasTerms, hasUsers, hasGroups, hasSearches } + .Count(x => x) > 1, + }, + Folders = folders.OrderBy(x => x.Rank ?? 999).ToList(), + Items = items.OrderBy(x => x.Rank ?? int.MaxValue).ThenBy(x => x.Name).ToList(), + SuggestedReports = suggestedReports, + }; + } + + public async Task> GetGroupsAsync( + ClaimsPrincipal user, + int requestedId, + CancellationToken cancellationToken + ) + { + var targetUserId = ResolveTargetUserId(user, requestedId); + return await _context.UserGroupsMemberships.AsNoTracking() + .Where(x => x.UserId == targetUserId) + .Select(x => new UserGroupDto + { + Id = x.GroupId, + Name = x.Group.GroupName, + Type = x.Group.GroupType, + Source = x.Group.GroupSource, + }) + .ToListAsync(cancellationToken); + } + + public async Task> GetSubscriptionsAsync( + ClaimsPrincipal user, + int requestedId, + CancellationToken cancellationToken + ) + { + var targetUserId = ResolveTargetUserId(user, requestedId); + return await ( + from r in _context.ReportObjectSubscriptions.Where(x => x.UserId == targetUserId) + .Union( + from m in _context.UserGroupsMemberships + join s in _context.ReportObjectSubscriptions on m.Group.GroupEmail equals s.SubscriptionTo + where m.UserId == targetUserId + select s + ) + orderby r.InactiveFlags, r.LastRunTime descending + select new UserSubscriptionDto + { + ReportId = r.ReportObjectId, + Name = r.ReportObject.DisplayName, + Description = r.Description, + LastStatus = r.LastStatus.Replace(";", "; "), + LastRun = r.LastRunDisplayString, + SentTo = r.SubscriptionTo.Replace(";", "; "), + EmailList = r.EmailList, + } + ).ToListAsync(cancellationToken); + } + + public async Task GetHistoryAsync( + ClaimsPrincipal user, + int requestedId, + CancellationToken cancellationToken + ) + { + var targetUserId = ResolveTargetUserId(user, requestedId); + + var atlasHistory = await _context.Analytics.AsNoTracking() + .Where(x => + x.UserId == targetUserId + && x.AccessDateTime > DateTime.Today.AddDays(-7) + && x.Pathname != "/" + ) + .OrderByDescending(x => x.AccessDateTime) + .Select(x => new UserHistoryItemDto + { + Name = x.Pathname, + Type = ToHistoryType(x.Pathname), + Url = x.Href, + Date = x.AccessDateTimeDisplayString, + }) + .ToListAsync(cancellationToken); + + var reportEdits = await _context.ReportObjectDocs.AsNoTracking() + .Where(x => x.UpdatedBy == targetUserId && x.LastUpdateDateTime > DateTime.Today.AddDays(-30)) + .OrderByDescending(x => x.LastUpdateDateTime) + .Take(10) + .Select(x => new UserHistoryItemDto + { + Name = x.ReportObject.DisplayName, + Type = "Report", + Url = "/reports?id=" + x.ReportObjectId, + Date = x.LastUpdatedDateTimeDisplayString, + }) + .ToListAsync(cancellationToken); + + var initiativeEdits = await _context.Initiatives.AsNoTracking() + .Where(x => x.LastUpdateUser == targetUserId && x.LastUpdateDate > DateTime.Today.AddDays(-30)) + .OrderByDescending(x => x.LastUpdateDate) + .Take(10) + .Select(x => new UserHistoryItemDto + { + Name = x.Name, + Type = "Initiative", + Url = "/initiatives?id=" + x.InitiativeId, + Date = x.LastUpdatedDateDisplayString, + }) + .ToListAsync(cancellationToken); + + var collectionEdits = await _context.Collections.AsNoTracking() + .Where(x => x.LastUpdateUser == targetUserId && x.LastUpdateDate > DateTime.Today.AddDays(-30)) + .OrderByDescending(x => x.LastUpdateDate) + .Take(10) + .Select(x => new UserHistoryItemDto + { + Name = x.Name, + Type = "Collection", + Url = "/collections?id=" + x.CollectionId, + Date = x.LastUpdatedDateDisplayString, + }) + .ToListAsync(cancellationToken); + + var termEdits = await _context.Terms.AsNoTracking() + .Where(x => x.UpdatedByUserId == targetUserId && x.LastUpdatedDateTime > DateTime.Today.AddDays(-30)) + .OrderByDescending(x => x.LastUpdatedDateTime) + .Take(10) + .Select(x => new UserHistoryItemDto + { + Name = x.Name, + Type = "Term", + Url = "/terms?id=" + x.TermId, + Date = x.LastUpdatedDateTimeDisplayString, + }) + .ToListAsync(cancellationToken); + + return new UserHistorySectionDto + { + AtlasHistory = atlasHistory, + ReportEdits = reportEdits, + InitiativeEdits = initiativeEdits, + CollectionEdits = collectionEdits, + TermEdits = termEdits, + }; + } + + private static string ToHistoryType(string path) + { + return path.ToLowerInvariant() switch + { + "/reports" => "Reports", + "/terms" => "Terms", + "/projects" => "Collections", + "/collections" => "Collections", + "/initiatives" => "Initiatives", + "/users" => "Users", + "/contacts" => "Reports", + "/tasks" => "Tasks", + "/search" => "Search", + _ => "Other", + }; + } + + private async Task> GetStarredReportsAsync( + ClaimsPrincipal user, + int targetUserId, + IReadOnlyDictionary folderLookup, + bool canReorder, + CancellationToken cancellationToken + ) + { + var httpContext = GetCurrentHttpContext(); + var canOpenInEditor = user.HasPermission("Open In Editor"); + var sharingEnabled = IsFeatureEnabled("features:enable_sharing"); + var requestAccessEnabled = IsFeatureEnabled("features:enable_request_access"); + + var stars = await _context.StarredReports.AsNoTracking() + .Where(x => x.Ownerid == targetUserId) + .Include(x => x.Report) + .ThenInclude(x => x.ReportObjectDoc) + .Include(x => x.Report) + .ThenInclude(x => x.ReportObjectType) + .Include(x => x.Report) + .ThenInclude(x => x.ReportObjectAttachments) + .Include(x => x.Report) + .ThenInclude(x => x.ReportTagLinks) + .ThenInclude(x => x.Tag) + .Include(x => x.Report) + .ThenInclude(x => x.ReportGroupsMemberships) + .Include(x => x.Report) + .ThenInclude(x => x.ReportObjectHierarchyChildReportObjects) + .ThenInclude(x => x.ParentReportObject) + .ThenInclude(x => x.ReportGroupsMemberships) + .ToListAsync(cancellationToken); + var reportIds = stars.Select(x => x.Reportid).Distinct().ToArray(); + var reportTypeIds = stars + .Select(x => x.Report.ReportObjectTypeId) + .Where(x => x.HasValue) + .Select(x => x.Value) + .Distinct() + .ToArray(); + var starCounts = await _context.StarredReports.AsNoTracking() + .Where(x => reportIds.Contains(x.Reportid)) + .GroupBy(x => x.Reportid) + .Select(x => new { ReportId = x.Key, Count = x.Count() }) + .ToDictionaryAsync(x => x.ReportId, x => x.Count, cancellationToken); + var reportTypes = await _context.ReportObjectTypes.AsNoTracking() + .Where(x => reportTypeIds.Contains(x.ReportObjectTypeId)) + .ToDictionaryAsync(x => x.ReportObjectTypeId, cancellationToken); + + var items = new List(stars.Count); + foreach (var star in stars) + { + var report = star.Report; + var canRun = await CanRunFavoriteReportAsync(user, report); + var runUrl = report.RunReportUrl(httpContext, _configuration, canRun); + var editUrl = report.EditReportUrl(httpContext, _configuration); + var manageUrl = report.ManageReportUrl(httpContext, _configuration); + var hasRunAttachments = report.ReportObjectAttachments.Count > 0 && !httpContext.IsAgl(); + var reportType = + report.ReportObjectType + ?? ( + report.ReportObjectTypeId.HasValue + && reportTypes.TryGetValue(report.ReportObjectTypeId.Value, out var loadedType) + ? loadedType + : null + ); + var bodyText = + TruncateWithReadMore(report.ReportObjectDoc?.DeveloperDescription) + ?? TruncateWithReadMore(report.Description) + ?? "Open to view details."; + + items.Add( + new UserFavoriteItemDto + { + StarId = star.StarId, + Type = "report", + TypeLabel = + string.IsNullOrEmpty(reportType?.ShortName) + ? reportType?.Name + : reportType.ShortName, + FolderId = star.Folderid, + Rank = star.Rank, + ItemId = star.Reportid, + Name = report.DisplayTitle ?? report.DisplayName ?? report.Name, + Description = report.Description, + BodyText = bodyText, + Url = "/reports?id=" + star.Reportid, + SecondaryText = reportType?.ShortName, + CanReorder = canReorder, + IsStarred = true, + StarCount = starCounts.GetValueOrDefault(report.ReportObjectId), + PlaceholderImageUrl = "/img/report_placeholder_128x128.png", + ThumbnailUrl = + "/data/img?handler=Thumb&id=" + report.ReportObjectId + "&size=128x128", + FullImageUrl = + "/data/img?handler=Thumb&id=" + report.ReportObjectId + "&size=1200x2000", + IsCertified = report.ReportTagLinks.Any(x => + x.Tag.Name == "Analytics Certified" || x.Tag.Name == "Analytics Reviewed" + ), + CanOpenProfile = true, + ProfileTargetId = "report-profile-" + report.ReportObjectId, + CanShare = sharingEnabled, + ShareTargetId = "report-share-" + report.ReportObjectId, + ShareName = report.DisplayTitle ?? report.DisplayName ?? report.Name, + ShareType = "report", + CanRequestAccess = + requestAccessEnabled && (report.ReportObjectAttachments.Count > 0 || !string.IsNullOrEmpty(runUrl)), + RequestAccessTargetId = "request-access-" + report.ReportObjectId, + CanRun = !string.IsNullOrEmpty(runUrl), + RunUrl = runUrl, + OpensRunModal = hasRunAttachments, + RunModalTargetId = hasRunAttachments ? "report-run-" + report.ReportObjectId : null, + RunDisabledReason = BuildReportRunDisabledReason(report, runUrl, editUrl), + CanEditInEditor = !string.IsNullOrEmpty(editUrl) && canOpenInEditor, + EditUrl = editUrl, + CanManageInEditor = !string.IsNullOrEmpty(manageUrl) && canOpenInEditor, + ManageUrl = manageUrl, + ReportObjectUrl = report.ReportObjectUrl, + ReportServerPath = report.ReportServerPath, + SourceServer = report.SourceServer, + EpicMasterFile = report.EpicMasterFile, + EpicRecordId = report.EpicRecordId, + EpicReportTemplateId = report.EpicReportTemplateId, + EnabledForHyperspace = report.ReportObjectDoc?.EnabledForHyperspace ?? "N", + Tags = report.ReportTagLinks + .Select( + x => + new UserFavoriteTagDto + { + Name = x.Tag.Name, + Slug = HtmlHelpers.Slug(x.Tag.Name), + ShowInHeader = + x.ShowInHeader == "Y" || x.Tag.ShowInHeader == "Y", + } + ) + .ToList(), + } + ); + } + + return AttachFolderMetadata(items, folderLookup); + } + + private async Task> GetStarredCollectionsAsync( + int targetUserId, + IReadOnlyDictionary folderLookup, + bool canReorder, + CancellationToken cancellationToken + ) + { + var items = await _context.StarredCollections.AsNoTracking() + .Where(x => x.Ownerid == targetUserId) + .Select(x => new UserFavoriteItemDto + { + StarId = x.StarId, + Type = "collection", + TypeLabel = "collection", + FolderId = x.Folderid, + Rank = x.Rank, + ItemId = x.Collectionid, + Name = x.Collection.Name, + Description = x.Collection.Description, + BodyText = + TruncateWithReadMore(HtmlHelpers.MarkdownToText(x.Collection.Description)) + ?? TruncateWithReadMore(x.Collection.Purpose) + ?? "Open to view details.", + Url = "/collections?id=" + x.Collectionid, + SecondaryText = "Collection", + CanReorder = canReorder, + IsStarred = true, + StarCount = x.Collection.StarredCollections.Count, + PlaceholderImageUrl = "/img/report_placeholder_128x128.png", + IsCertified = true, + CanOpenProfile = true, + ProfileTargetId = "collection-profile-" + x.Collectionid, + CanShare = IsFeatureEnabled("features:enable_sharing"), + ShareTargetId = "collection-share-" + x.Collectionid, + ShareName = x.Collection.Name, + ShareType = "collection", + }) + .ToListAsync(cancellationToken); + return AttachFolderMetadata(items, folderLookup); + } + + private async Task> GetStarredInitiativesAsync( + int targetUserId, + IReadOnlyDictionary folderLookup, + bool canReorder, + CancellationToken cancellationToken + ) + { + var items = await _context.StarredInitiatives.AsNoTracking() + .Where(x => x.Ownerid == targetUserId) + .Select(x => new UserFavoriteItemDto + { + StarId = x.StarId, + Type = "initiative", + TypeLabel = "initiative", + FolderId = x.Folderid, + Rank = x.Rank, + ItemId = x.Initiativeid, + Name = x.Initiative.Name, + Description = x.Initiative.Description, + BodyText = + TruncateWithReadMore(x.Initiative.Description) ?? "Open to view details", + Url = "/initiatives?id=" + x.Initiativeid, + SecondaryText = "Initiative", + CanReorder = canReorder, + IsStarred = true, + StarCount = x.Initiative.StarredInitiatives.Count, + PlaceholderImageUrl = "/img/report_placeholder_128x128.png", + IsCertified = true, + CanShare = IsFeatureEnabled("features:enable_sharing"), + ShareTargetId = "initiative-share-" + x.Initiativeid, + ShareName = x.Initiative.Name, + ShareType = "initiative", + RelatedCollectionNames = x.Initiative.Collections.Select(c => c.Name).ToList(), + }) + .ToListAsync(cancellationToken); + return AttachFolderMetadata(items, folderLookup); + } + + private async Task> GetStarredTermsAsync( + int targetUserId, + IReadOnlyDictionary folderLookup, + bool canReorder, + CancellationToken cancellationToken + ) + { + var items = await _context.StarredTerms.AsNoTracking() + .Where(x => x.Ownerid == targetUserId) + .Select(x => new UserFavoriteItemDto + { + StarId = x.StarId, + Type = "term", + TypeLabel = "term", + FolderId = x.Folderid, + Rank = x.Rank, + ItemId = x.Termid, + Name = x.Term.Name, + Description = x.Term.Summary, + BodyText = + TruncateWithReadMore(x.Term.Summary) + ?? TruncateWithReadMore(x.Term.TechnicalDefinition) + ?? "Open to view details.", + Url = "/terms?id=" + x.Termid, + SecondaryText = "Term", + CanReorder = canReorder, + IsStarred = true, + StarCount = x.Term.StarredTerms.Count, + PlaceholderImageUrl = "/img/report_placeholder_128x128.png", + IsApproved = x.Term.ApprovedYn == "Y", + CanOpenProfile = true, + ProfileTargetId = "term-profile-" + x.Termid, + CanShare = IsFeatureEnabled("features:enable_sharing"), + ShareTargetId = "term-share-" + x.Termid, + ShareName = x.Term.Name, + ShareType = "term", + }) + .ToListAsync(cancellationToken); + return AttachFolderMetadata(items, folderLookup); + } + + private async Task> GetStarredUsersAsync( + int targetUserId, + IReadOnlyDictionary folderLookup, + bool canReorder, + CancellationToken cancellationToken + ) + { + var items = await _context.StarredUsers.AsNoTracking() + .Where(x => x.Ownerid == targetUserId) + .Select(x => new UserFavoriteItemDto + { + StarId = x.StarId, + Type = "user", + TypeLabel = "user", + FolderId = x.Folderid, + Rank = x.Rank, + ItemId = x.Userid, + Name = x.User.FullnameCalc ?? x.User.DisplayName ?? x.User.Username, + Description = x.User.Email, + BodyText = "View user profile.", + Url = "/users?id=" + x.Userid, + SecondaryText = "User", + CanReorder = canReorder, + IsStarred = true, + StarCount = x.User.StarredUserUsers.Count, + PlaceholderImageUrl = "/img/user_placeholder_128x128.png", + }) + .ToListAsync(cancellationToken); + return AttachFolderMetadata(items, folderLookup); + } + + private async Task> GetStarredGroupsAsync( + int targetUserId, + IReadOnlyDictionary folderLookup, + bool canReorder, + CancellationToken cancellationToken + ) + { + var items = await _context.StarredGroups.AsNoTracking() + .Where(x => x.Ownerid == targetUserId) + .Select(x => new UserFavoriteItemDto + { + StarId = x.StarId, + Type = "group", + TypeLabel = "group", + FolderId = x.Folderid, + Rank = x.Rank, + ItemId = x.Groupid, + Name = x.Group.GroupName, + Description = x.Group.GroupEmail, + BodyText = "View group profile.", + Url = "/groups?id=" + x.Groupid, + SecondaryText = x.Group.GroupType, + CanReorder = canReorder, + IsStarred = true, + StarCount = x.Group.StarredGroups.Count, + PlaceholderImageUrl = "/img/group_placeholder_128x128.png", + }) + .ToListAsync(cancellationToken); + return AttachFolderMetadata(items, folderLookup); + } + + private async Task> GetStarredSearchesAsync( + int targetUserId, + IReadOnlyDictionary folderLookup, + bool canReorder, + CancellationToken cancellationToken + ) + { + var items = await _context.StarredSearches.AsNoTracking() + .Where(x => x.Ownerid == targetUserId) + .Select(x => new UserFavoriteItemDto + { + StarId = x.StarId, + Type = "search", + TypeLabel = "search", + FolderId = x.Folderid, + Rank = x.Rank, + Name = DecodeSearchString(x.Search), + Description = null, + BodyText = "Open search results.", + Url = "/search?" + x.Search, + SecondaryText = "Search", + SearchString = DecodeSearchString(x.Search), + CanReorder = canReorder, + IsStarred = true, + StarCount = _context.StarredSearches.Count(y => y.Search == x.Search), + PlaceholderImageUrl = "/img/report_placeholder_128x128.png", + }) + .ToListAsync(cancellationToken); + return AttachFolderMetadata(items, folderLookup); + } + + private async Task CanRunFavoriteReportAsync( + ClaimsPrincipal user, + ReportObject report + ) + { + if (_authorizationService == null) + { + return false; + } + + var authorizationResult = await _authorizationService.AuthorizeAsync( + user, + report, + "ReportRunPolicy" + ); + return authorizationResult.Succeeded; + } + + private static string BuildReportRunDisabledReason( + ReportObject report, + string runUrl, + string editUrl + ) + { + if (!string.IsNullOrEmpty(runUrl)) + { + return null; + } + + if (report.EpicMasterFile != null && report.EpicMasterFile.Equals("IDB")) + { + return "Open a related dashboard that uses this."; + } + + if (!string.IsNullOrEmpty(editUrl)) + { + return "Open in report library."; + } + + if (report.EpicMasterFile != null) + { + return "Run from the Hyperspace report library."; + } + + return null; + } + + private static List AttachFolderMetadata( + IReadOnlyList items, + IReadOnlyDictionary folderLookup + ) + { + return items.Select(x => + { + folderLookup.TryGetValue(x.FolderId ?? 0, out var folder); + return new UserFavoriteItemDto + { + StarId = x.StarId, + Type = x.Type, + TypeLabel = x.TypeLabel, + FolderId = x.FolderId, + Rank = x.Rank, + ItemId = x.ItemId, + Name = x.Name, + Description = x.Description, + Url = x.Url, + SecondaryText = x.SecondaryText, + FolderName = folder?.FolderName, + FolderRank = folder?.FolderRank, + SearchString = x.SearchString, + CanReorder = x.CanReorder, + IsStarred = x.IsStarred, + StarCount = x.StarCount, + BodyText = x.BodyText, + PlaceholderImageUrl = x.PlaceholderImageUrl, + ThumbnailUrl = x.ThumbnailUrl, + FullImageUrl = x.FullImageUrl, + IsCertified = x.IsCertified, + IsApproved = x.IsApproved, + CanOpenProfile = x.CanOpenProfile, + ProfileTargetId = x.ProfileTargetId, + CanShare = x.CanShare, + ShareTargetId = x.ShareTargetId, + ShareName = x.ShareName, + ShareType = x.ShareType, + CanRequestAccess = x.CanRequestAccess, + RequestAccessTargetId = x.RequestAccessTargetId, + CanRun = x.CanRun, + RunUrl = x.RunUrl, + OpensRunModal = x.OpensRunModal, + RunModalTargetId = x.RunModalTargetId, + RunDisabledReason = x.RunDisabledReason, + CanEditInEditor = x.CanEditInEditor, + EditUrl = x.EditUrl, + CanManageInEditor = x.CanManageInEditor, + ManageUrl = x.ManageUrl, + ReportObjectUrl = x.ReportObjectUrl, + ReportServerPath = x.ReportServerPath, + SourceServer = x.SourceServer, + EpicMasterFile = x.EpicMasterFile, + EpicRecordId = x.EpicRecordId, + EpicReportTemplateId = x.EpicReportTemplateId, + EnabledForHyperspace = x.EnabledForHyperspace, + Tags = x.Tags, + RelatedCollectionNames = x.RelatedCollectionNames, + }; + }).ToList(); + } +} diff --git a/web/Services/Users/UsersApiService.Workspace.cs b/web/Services/Users/UsersApiService.Workspace.cs new file mode 100644 index 00000000..1903b293 --- /dev/null +++ b/web/Services/Users/UsersApiService.Workspace.cs @@ -0,0 +1,572 @@ +using System.Security.Claims; +using Atlas_Web.Authorization; +using Atlas_Web.Contracts.Api.Users; +using Atlas_Web.Models; +using Microsoft.EntityFrameworkCore; + +namespace Atlas_Web.Services; + +public sealed partial class UsersApiService +{ + public async Task GetSharedObjectsAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken + ) + { + var currentUserId = user.GetUserId(); + + var sharedToMe = await _context.SharedItems.AsNoTracking() + .Where(x => x.SharedToUserId == currentUserId) + .OrderByDescending(x => x.ShareDate) + .Select(x => new UserSharedObjectDto + { + Id = x.Id, + Name = x.Name, + ShareDate = x.ShareDate == null ? null : (x.ShareDate ?? DateTime.Now).ToString("M/d/yyyy"), + SharedFrom = x.SharedFromUser.FullnameCalc, + Url = x.Url, + }) + .ToListAsync(cancellationToken); + + var sharedFromMe = await _context.SharedItems.AsNoTracking() + .Where(x => x.SharedFromUserId == currentUserId) + .Select(x => new UserSharedObjectDto + { + Id = x.Id, + Name = x.Name, + ShareDate = x.ShareDate == null ? null : (x.ShareDate ?? DateTime.Now).ToString("M/d/yyyy"), + SharedFrom = x.SharedToUser.FullnameCalc, + Url = x.Url, + }) + .ToListAsync(cancellationToken); + + return new UserSharedObjectsDto + { + SharedToMe = sharedToMe, + SharedFromMe = sharedFromMe, + }; + } + + public async Task> GetSearchHistoryAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken + ) + { + var currentUserId = user.GetUserId(); + return await _context.Analytics.AsNoTracking() + .Where(x => x.Pathname.ToLower() == "/search" && x.UserId == currentUserId) + .OrderByDescending(x => x.AccessDateTime) + .Take(7) + .Select(x => new UserSearchHistoryItemDto + { + SearchUrl = x.Search.Replace("%25", "%"), + SearchString = DecodeSearchString(x.Search), + }) + .ToListAsync(cancellationToken); + } + + public async Task CreateFolderAsync( + int workspaceUserId, + CreateUserFavoriteFolderRequestDto request, + CancellationToken cancellationToken + ) + { + var folder = new UserFavoriteFolder + { + UserId = workspaceUserId, + FolderName = request.Name.Trim(), + }; + + await _context.UserFavoriteFolders.AddAsync(folder, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + InvalidateWorkspaceCaches(workspaceUserId); + + return ToFolderDto(folder, 0); + } + + public async Task UpdateFolderAsync( + int workspaceUserId, + int id, + UpdateUserFavoriteFolderRequestDto request, + CancellationToken cancellationToken + ) + { + var folder = await _context.UserFavoriteFolders.SingleOrDefaultAsync( + x => x.UserFavoriteFolderId == id && x.UserId == workspaceUserId, + cancellationToken + ); + if (folder == null) + { + return null; + } + + folder.FolderName = request.Name.Trim(); + await _context.SaveChangesAsync(cancellationToken); + InvalidateWorkspaceCaches(workspaceUserId); + + return ToFolderDto(folder, await CountFolderItemsAsync(id, cancellationToken)); + } + + public async Task DeleteFolderAsync( + int workspaceUserId, + int id, + CancellationToken cancellationToken + ) + { + var folder = await _context.UserFavoriteFolders.SingleOrDefaultAsync( + x => x.UserFavoriteFolderId == id && x.UserId == workspaceUserId, + cancellationToken + ); + if (folder == null) + { + return false; + } + + await ClearFolderAssignmentsAsync(workspaceUserId, id, cancellationToken); + _context.UserFavoriteFolders.Remove(folder); + await _context.SaveChangesAsync(cancellationToken); + InvalidateWorkspaceCaches(workspaceUserId); + return true; + } + + public async Task ReorderFoldersAsync( + int workspaceUserId, + IReadOnlyList request, + CancellationToken cancellationToken + ) + { + foreach (var item in request) + { + if (!Int32.TryParse(item.FolderId, out var folderId)) + { + continue; + } + + var folder = await _context.UserFavoriteFolders.SingleOrDefaultAsync( + x => x.UserFavoriteFolderId == folderId && x.UserId == workspaceUserId, + cancellationToken + ); + if (folder != null) + { + folder.FolderRank = item.FolderRank; + } + } + + await _context.SaveChangesAsync(cancellationToken); + InvalidateWorkspaceCaches(workspaceUserId); + } + + public async Task ReorderFavoritesAsync( + int workspaceUserId, + IReadOnlyList request, + CancellationToken cancellationToken + ) + { + foreach (var item in request) + { + if (!Int32.TryParse(item.FavoriteId, out var favoriteId)) + { + continue; + } + + switch (item.FavoriteType) + { + case "report": + await SetFavoriteRankAsync(_context.StarredReports, workspaceUserId, favoriteId, item.FavoriteRank, cancellationToken); + break; + case "collection": + await SetFavoriteRankAsync(_context.StarredCollections, workspaceUserId, favoriteId, item.FavoriteRank, cancellationToken); + break; + case "initiative": + await SetFavoriteRankAsync(_context.StarredInitiatives, workspaceUserId, favoriteId, item.FavoriteRank, cancellationToken); + break; + case "term": + await SetFavoriteRankAsync(_context.StarredTerms, workspaceUserId, favoriteId, item.FavoriteRank, cancellationToken); + break; + case "user": + await SetFavoriteRankAsync(_context.StarredUsers, workspaceUserId, favoriteId, item.FavoriteRank, cancellationToken); + break; + case "group": + await SetFavoriteRankAsync(_context.StarredGroups, workspaceUserId, favoriteId, item.FavoriteRank, cancellationToken); + break; + case "search": + await SetFavoriteRankAsync(_context.StarredSearches, workspaceUserId, favoriteId, item.FavoriteRank, cancellationToken); + break; + default: + continue; + } + } + + await _context.SaveChangesAsync(cancellationToken); + InvalidateWorkspaceCaches(workspaceUserId); + } + + public async Task UpdateFavoriteFolderAsync( + int workspaceUserId, + UpdateUserFavoriteFolderAssignmentRequestDto request, + CancellationToken cancellationToken + ) + { + var folderId = request.FolderId == 0 ? null : request.FolderId; + + return request.FavoriteType switch + { + "report" => await SetFavoriteFolderAsync(_context.StarredReports, workspaceUserId, request.FavoriteId, folderId, cancellationToken), + "collection" => await SetFavoriteFolderAsync(_context.StarredCollections, workspaceUserId, request.FavoriteId, folderId, cancellationToken), + "initiative" => await SetFavoriteFolderAsync(_context.StarredInitiatives, workspaceUserId, request.FavoriteId, folderId, cancellationToken), + "term" => await SetFavoriteFolderAsync(_context.StarredTerms, workspaceUserId, request.FavoriteId, folderId, cancellationToken), + "user" => await SetFavoriteFolderAsync(_context.StarredUsers, workspaceUserId, request.FavoriteId, folderId, cancellationToken), + "group" => await SetFavoriteFolderAsync(_context.StarredGroups, workspaceUserId, request.FavoriteId, folderId, cancellationToken), + "search" => await SetFavoriteFolderAsync(_context.StarredSearches, workspaceUserId, request.FavoriteId, folderId, cancellationToken), + _ => false, + }; + } + + public async Task RemoveSharedObjectAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ) + { + var currentUserId = user.GetUserId(); + var sharedItem = await _context.SharedItems.SingleOrDefaultAsync( + x => + x.Id == id + && (x.SharedFromUserId == currentUserId || x.SharedToUserId == currentUserId), + cancellationToken + ); + if (sharedItem == null) + { + return false; + } + + _context.SharedItems.Remove(sharedItem); + await _context.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task ToggleFavoriteAsync( + int workspaceUserId, + ToggleUserFavoriteRequestDto request, + CancellationToken cancellationToken + ) + { + var type = (request.Type ?? string.Empty).Trim().ToLowerInvariant(); + + return type switch + { + "report" => await ToggleReportFavoriteAsync(workspaceUserId, request.Id, cancellationToken), + "collection" => await ToggleCollectionFavoriteAsync(workspaceUserId, request.Id, cancellationToken), + "initiative" => await ToggleInitiativeFavoriteAsync(workspaceUserId, request.Id, cancellationToken), + "term" => await ToggleTermFavoriteAsync(workspaceUserId, request.Id, cancellationToken), + "user" => await ToggleUserFavoriteEntityAsync(workspaceUserId, request.Id, cancellationToken), + "group" => await ToggleGroupFavoriteAsync(workspaceUserId, request.Id, cancellationToken), + "search" => await ToggleSearchFavoriteAsync(workspaceUserId, request.Search, cancellationToken), + _ => throw new InvalidOperationException("Unsupported favorite type."), + }; + } + + public async Task ToggleAdminModeAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken + ) + { + var currentUserId = user.GetUserId(); + var adminDisabled = await _context.UserPreferences.SingleOrDefaultAsync( + x => x.UserId == currentUserId && x.ItemType == "AdminDisabled", + cancellationToken + ); + + if (adminDisabled == null) + { + await _context.UserPreferences.AddAsync( + new UserPreference { UserId = currentUserId, ItemType = "AdminDisabled" }, + cancellationToken + ); + await _context.SaveChangesAsync(cancellationToken); + return new ToggleAdminModeResponseDto { AdminEnabled = "N" }; + } + + _context.UserPreferences.RemoveRange( + _context.UserPreferences.Where(x => x.UserId == currentUserId && x.ItemType == "AdminDisabled") + ); + await _context.SaveChangesAsync(cancellationToken); + return new ToggleAdminModeResponseDto { AdminEnabled = "Y" }; + } + + private async Task ToggleReportFavoriteAsync( + int currentUserId, + int? reportId, + CancellationToken cancellationToken + ) + { + if (reportId == null) + { + throw new InvalidOperationException("Favorite id is required."); + } + + var existing = await _context.StarredReports + .Where(x => x.Ownerid == currentUserId && x.Reportid == reportId.Value) + .ToListAsync(cancellationToken); + var isStarred = existing.Count == 0; + if (isStarred) + { + await _context.StarredReports.AddAsync( + new StarredReport { Ownerid = currentUserId, Reportid = reportId.Value }, + cancellationToken + ); + } + else + { + _context.StarredReports.RemoveRange(existing); + } + + await _context.SaveChangesAsync(cancellationToken); + _cache.Remove("report-" + reportId.Value); + + return new ToggleUserFavoriteResponseDto + { + Type = "report", + Id = reportId, + IsStarred = isStarred, + StarCount = await _context.StarredReports.CountAsync(x => x.Reportid == reportId.Value, cancellationToken), + }; + } + + private async Task ToggleCollectionFavoriteAsync( + int currentUserId, + int? collectionId, + CancellationToken cancellationToken + ) + { + if (collectionId == null) + { + throw new InvalidOperationException("Favorite id is required."); + } + + var existing = await _context.StarredCollections + .Where(x => x.Ownerid == currentUserId && x.Collectionid == collectionId.Value) + .ToListAsync(cancellationToken); + var isStarred = existing.Count == 0; + if (isStarred) + { + await _context.StarredCollections.AddAsync( + new StarredCollection { Ownerid = currentUserId, Collectionid = collectionId.Value }, + cancellationToken + ); + } + else + { + _context.StarredCollections.RemoveRange(existing); + } + + await _context.SaveChangesAsync(cancellationToken); + _cache.Remove("collection-" + collectionId.Value); + _cache.Remove("collections"); + + return new ToggleUserFavoriteResponseDto + { + Type = "collection", + Id = collectionId, + IsStarred = isStarred, + StarCount = await _context.StarredCollections.CountAsync(x => x.Collectionid == collectionId.Value, cancellationToken), + }; + } + + private async Task ToggleInitiativeFavoriteAsync( + int currentUserId, + int? initiativeId, + CancellationToken cancellationToken + ) + { + if (initiativeId == null) + { + throw new InvalidOperationException("Favorite id is required."); + } + + var existing = await _context.StarredInitiatives + .Where(x => x.Ownerid == currentUserId && x.Initiativeid == initiativeId.Value) + .ToListAsync(cancellationToken); + var isStarred = existing.Count == 0; + if (isStarred) + { + await _context.StarredInitiatives.AddAsync( + new StarredInitiative { Ownerid = currentUserId, Initiativeid = initiativeId.Value }, + cancellationToken + ); + } + else + { + _context.StarredInitiatives.RemoveRange(existing); + } + + await _context.SaveChangesAsync(cancellationToken); + _cache.Remove("initiative-" + initiativeId.Value); + _cache.Remove("initatives"); + + return new ToggleUserFavoriteResponseDto + { + Type = "initiative", + Id = initiativeId, + IsStarred = isStarred, + StarCount = await _context.StarredInitiatives.CountAsync(x => x.Initiativeid == initiativeId.Value, cancellationToken), + }; + } + + private async Task ToggleTermFavoriteAsync( + int currentUserId, + int? termId, + CancellationToken cancellationToken + ) + { + if (termId == null) + { + throw new InvalidOperationException("Favorite id is required."); + } + + var existing = await _context.StarredTerms + .Where(x => x.Ownerid == currentUserId && x.Termid == termId.Value) + .ToListAsync(cancellationToken); + var isStarred = existing.Count == 0; + if (isStarred) + { + await _context.StarredTerms.AddAsync( + new StarredTerm { Ownerid = currentUserId, Termid = termId.Value }, + cancellationToken + ); + } + else + { + _context.StarredTerms.RemoveRange(existing); + } + + await _context.SaveChangesAsync(cancellationToken); + _cache.Remove("term-" + termId.Value); + _cache.Remove("terms"); + + return new ToggleUserFavoriteResponseDto + { + Type = "term", + Id = termId, + IsStarred = isStarred, + StarCount = await _context.StarredTerms.CountAsync(x => x.Termid == termId.Value, cancellationToken), + }; + } + + private async Task ToggleUserFavoriteEntityAsync( + int currentUserId, + int? userId, + CancellationToken cancellationToken + ) + { + if (userId == null) + { + throw new InvalidOperationException("Favorite id is required."); + } + + var existing = await _context.StarredUsers + .Where(x => x.Ownerid == currentUserId && x.Userid == userId.Value) + .ToListAsync(cancellationToken); + var isStarred = existing.Count == 0; + if (isStarred) + { + await _context.StarredUsers.AddAsync( + new StarredUser { Ownerid = currentUserId, Userid = userId.Value }, + cancellationToken + ); + } + else + { + _context.StarredUsers.RemoveRange(existing); + } + + await _context.SaveChangesAsync(cancellationToken); + _cache.Remove("user-" + userId.Value); + + return new ToggleUserFavoriteResponseDto + { + Type = "user", + Id = userId, + IsStarred = isStarred, + StarCount = await _context.StarredUsers.CountAsync(x => x.Userid == userId.Value, cancellationToken), + }; + } + + private async Task ToggleGroupFavoriteAsync( + int currentUserId, + int? groupId, + CancellationToken cancellationToken + ) + { + if (groupId == null) + { + throw new InvalidOperationException("Favorite id is required."); + } + + var existing = await _context.StarredGroups + .Where(x => x.Ownerid == currentUserId && x.Groupid == groupId.Value) + .ToListAsync(cancellationToken); + var isStarred = existing.Count == 0; + if (isStarred) + { + await _context.StarredGroups.AddAsync( + new StarredGroup { Ownerid = currentUserId, Groupid = groupId.Value }, + cancellationToken + ); + } + else + { + _context.StarredGroups.RemoveRange(existing); + } + + await _context.SaveChangesAsync(cancellationToken); + _cache.Remove("group-" + groupId.Value); + + return new ToggleUserFavoriteResponseDto + { + Type = "group", + Id = groupId, + IsStarred = isStarred, + StarCount = await _context.StarredGroups.CountAsync(x => x.Groupid == groupId.Value, cancellationToken), + }; + } + + private async Task ToggleSearchFavoriteAsync( + int currentUserId, + string search, + CancellationToken cancellationToken + ) + { + var normalizedSearch = (search ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(normalizedSearch)) + { + throw new InvalidOperationException("Search is required."); + } + + var existing = await _context.StarredSearches + .Where(x => x.Ownerid == currentUserId && x.Search == normalizedSearch) + .ToListAsync(cancellationToken); + var isStarred = existing.Count == 0; + if (isStarred) + { + await _context.StarredSearches.AddAsync( + new StarredSearch { Ownerid = currentUserId, Search = normalizedSearch }, + cancellationToken + ); + } + else + { + _context.StarredSearches.RemoveRange(existing); + } + + await _context.SaveChangesAsync(cancellationToken); + + return new ToggleUserFavoriteResponseDto + { + Type = "search", + Search = normalizedSearch, + IsStarred = isStarred, + StarCount = await _context.StarredSearches.CountAsync(x => x.Search == normalizedSearch, cancellationToken), + }; + } +} diff --git a/web/Services/Users/UsersApiService.cs b/web/Services/Users/UsersApiService.cs new file mode 100644 index 00000000..04bff370 --- /dev/null +++ b/web/Services/Users/UsersApiService.cs @@ -0,0 +1,252 @@ +using System.Security.Claims; +using System.Text.RegularExpressions; +using Atlas_Web.Authorization; +using Atlas_Web.Contracts.Api.Users; +using Atlas_Web.Helpers; +using Atlas_Web.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace Atlas_Web.Services; + +public interface IUsersApiService +{ + Task GetUserPageAsync( + ClaimsPrincipal user, + int requestedId, + CancellationToken cancellationToken + ); + Task GetStarsAsync( + ClaimsPrincipal user, + int requestedId, + CancellationToken cancellationToken + ); + Task> GetGroupsAsync( + ClaimsPrincipal user, + int requestedId, + CancellationToken cancellationToken + ); + Task> GetSubscriptionsAsync( + ClaimsPrincipal user, + int requestedId, + CancellationToken cancellationToken + ); + Task GetHistoryAsync( + ClaimsPrincipal user, + int requestedId, + CancellationToken cancellationToken + ); + Task GetSharedObjectsAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken + ); + Task> GetSearchHistoryAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken + ); + Task CreateFolderAsync( + int workspaceUserId, + CreateUserFavoriteFolderRequestDto request, + CancellationToken cancellationToken + ); + Task UpdateFolderAsync( + int workspaceUserId, + int id, + UpdateUserFavoriteFolderRequestDto request, + CancellationToken cancellationToken + ); + Task DeleteFolderAsync( + int workspaceUserId, + int id, + CancellationToken cancellationToken + ); + Task ReorderFoldersAsync( + int workspaceUserId, + IReadOnlyList request, + CancellationToken cancellationToken + ); + Task ReorderFavoritesAsync( + int workspaceUserId, + IReadOnlyList request, + CancellationToken cancellationToken + ); + Task UpdateFavoriteFolderAsync( + int workspaceUserId, + UpdateUserFavoriteFolderAssignmentRequestDto request, + CancellationToken cancellationToken + ); + Task RemoveSharedObjectAsync( + ClaimsPrincipal user, + int id, + CancellationToken cancellationToken + ); + Task ToggleFavoriteAsync( + int workspaceUserId, + ToggleUserFavoriteRequestDto request, + CancellationToken cancellationToken + ); + Task ToggleAdminModeAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken + ); +} + +public sealed partial class UsersApiService : IUsersApiService +{ + private static readonly Regex SearchRegex = new( + @"Query=(.*?)[&|?|\s]", + RegexOptions.None, + TimeSpan.FromSeconds(1) + ); + + private readonly Atlas_WebContext _context; + private readonly IConfiguration _configuration; + private readonly IMemoryCache _cache; + private readonly IAuthorizationService _authorizationService; + private readonly IHttpContextAccessor _httpContextAccessor; + + public UsersApiService( + Atlas_WebContext context, + IConfiguration configuration, + IMemoryCache cache, + IAuthorizationService authorizationService = null, + IHttpContextAccessor httpContextAccessor = null + ) + { + _context = context; + _configuration = configuration; + _cache = cache; + _authorizationService = authorizationService; + _httpContextAccessor = httpContextAccessor; + } + + private bool IsUserProfileEnabled() + { + var value = _configuration["features:enable_user_profile"]; + return string.IsNullOrEmpty(value) || value.Equals("true", StringComparison.OrdinalIgnoreCase); + } + + private bool IsFeatureEnabled(string key) + { + var value = _configuration[key]; + return string.IsNullOrWhiteSpace(value) + || value.Equals("true", StringComparison.OrdinalIgnoreCase); + } + + private HttpContext GetCurrentHttpContext() + { + return _httpContextAccessor?.HttpContext ?? new DefaultHttpContext(); + } + + private static string TruncateWithReadMore(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + var trimmed = text.Trim(); + return trimmed.Substring(0, Math.Min(160, trimmed.Length)) + "... "; + } + + private static int ResolveTargetUserId(ClaimsPrincipal user, int requestedId) + { + var currentUserId = user.GetUserId(); + return user.HasPermission("View Other User") ? requestedId : currentUserId; + } + + private static string DecodeSearchString(string search) + { + return SearchRegex.Match((search ?? string.Empty) + " ").Groups[1].Value + .Replace("%25", "%") + .Replace("%20", " ") + .Replace("%2C", ","); + } + + private void InvalidateWorkspaceCaches(int userId) + { + _cache.Remove("FavoriteFolders-" + userId); + _cache.Remove("FavoriteReports-" + userId); + } + + private static UserFavoriteFolderDto ToFolderDto(UserFavoriteFolder folder, int itemCount) + { + return new UserFavoriteFolderDto + { + Id = folder.UserFavoriteFolderId, + Name = folder.FolderName, + Rank = folder.FolderRank, + ItemCount = itemCount, + }; + } + + private async Task CountFolderItemsAsync(int folderId, CancellationToken cancellationToken) + { + return await _context.StarredCollections.CountAsync(x => x.Folderid == folderId, cancellationToken) + + await _context.StarredGroups.CountAsync(x => x.Folderid == folderId, cancellationToken) + + await _context.StarredInitiatives.CountAsync(x => x.Folderid == folderId, cancellationToken) + + await _context.StarredReports.CountAsync(x => x.Folderid == folderId, cancellationToken) + + await _context.StarredSearches.CountAsync(x => x.Folderid == folderId, cancellationToken) + + await _context.StarredTerms.CountAsync(x => x.Folderid == folderId, cancellationToken) + + await _context.StarredUsers.CountAsync(x => x.Folderid == folderId, cancellationToken); + } + + private async Task ClearFolderAssignmentsAsync( + int currentUserId, + int folderId, + CancellationToken cancellationToken + ) + { + await _context.StarredCollections.Where(x => x.Folderid == folderId && x.Ownerid == currentUserId) + .ForEachAsync(x => x.Folderid = null, cancellationToken); + await _context.StarredReports.Where(x => x.Folderid == folderId && x.Ownerid == currentUserId) + .ForEachAsync(x => x.Folderid = null, cancellationToken); + await _context.StarredInitiatives.Where(x => x.Folderid == folderId && x.Ownerid == currentUserId) + .ForEachAsync(x => x.Folderid = null, cancellationToken); + await _context.StarredTerms.Where(x => x.Folderid == folderId && x.Ownerid == currentUserId) + .ForEachAsync(x => x.Folderid = null, cancellationToken); + await _context.StarredUsers.Where(x => x.Folderid == folderId && x.Ownerid == currentUserId) + .ForEachAsync(x => x.Folderid = null, cancellationToken); + await _context.StarredGroups.Where(x => x.Folderid == folderId && x.Ownerid == currentUserId) + .ForEachAsync(x => x.Folderid = null, cancellationToken); + await _context.StarredSearches.Where(x => x.Folderid == folderId && x.Ownerid == currentUserId) + .ForEachAsync(x => x.Folderid = null, cancellationToken); + } + + private static async Task SetFavoriteRankAsync( + DbSet dbSet, + int currentUserId, + int favoriteId, + int favoriteRank, + CancellationToken cancellationToken + ) where T : class + { + dynamic entity = await dbSet.FindAsync([favoriteId], cancellationToken); + if (entity != null && entity.Ownerid == currentUserId) + { + entity.Rank = favoriteRank; + } + } + + private async Task SetFavoriteFolderAsync( + DbSet dbSet, + int currentUserId, + int favoriteId, + int? folderId, + CancellationToken cancellationToken + ) where T : class + { + dynamic entity = await dbSet.FindAsync([favoriteId], cancellationToken); + if (entity == null || entity.Ownerid != currentUserId) + { + return false; + } + + entity.Folderid = folderId; + await _context.SaveChangesAsync(cancellationToken); + InvalidateWorkspaceCaches(currentUserId); + return true; + } +} diff --git a/web/appsettings.json b/web/appsettings.json index e0ee960c..a2e38b1b 100644 --- a/web/appsettings.json +++ b/web/appsettings.json @@ -48,5 +48,16 @@ }, "footer": { "subtitle": "Atlas was created by the Riverside Healthcare Analytics team." + }, + "Jwt": { + "Key": "", + "Issuer": "", + "Audience": "" + }, + "Cors": { + "AllowedOrigins": [ "http://localhost:3000", "http://localhost:3001" ] + }, + "Auth": { + "DefaultCallbackPath": "/auth/callback" } } diff --git a/web/web.csproj b/web/web.csproj index 5ffd930a..fc3e84f0 100644 --- a/web/web.csproj +++ b/web/web.csproj @@ -28,6 +28,9 @@ true + + + @@ -43,32 +46,32 @@ - + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all