From 4025f41b45b293c5619e72f334fb7a206a6b04d2 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Mon, 16 Mar 2026 16:13:58 +0300 Subject: [PATCH 01/32] Add JWT authentication --- docker-compose.yml | 11 ++-- web/Controllers/Api/AuthApiController.cs | 63 +++++++++++++++++++++ web/Controllers/Api/ReportsApiController.cs | 44 ++++++++++++++ web/Program.cs | 54 +++++++++++++++++- web/Services/JwtTokenService.cs | 39 +++++++++++++ web/appsettings.json | 8 +++ web/web.csproj | 3 + 7 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 web/Controllers/Api/AuthApiController.cs create mode 100644 web/Controllers/Api/ReportsApiController.cs create mode 100644 web/Services/JwtTokenService.cs diff --git a/docker-compose.yml b/docker-compose.yml index 07ab466f..01df290a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,15 +66,18 @@ services: - solr - sqlserver environment: - PORT: ${WEB_PORT:-3000} + PORT: ${WEB_PORT:-5000} SEED_DEMO: ${SEED_DEMO:-true} + Jwt__Key: ${JWT_KEY:-atlas-dev-secret-key-change-in-production-32chars} + Jwt__Issuer: ${JWT_ISSUER:-atlas-library} + Jwt__Audience: ${JWT_AUDIENCE:-atlas-library-nextjs} expose: - - ${WEB_PORT:-3000} + - ${WEB_PORT:-5000} ports: - - '${WEB_PORT:-3000}:${WEB_PORT:-3000}' + - '${WEB_PORT:-5000}:${WEB_PORT:-5000}' healthcheck: test: - ['CMD-SHELL', 'wget -qO- http://localhost:${WEB_PORT:-3000}/ || exit 1'] + ['CMD-SHELL', 'wget -qO- http://localhost:${WEB_PORT:-5000}/ || exit 1'] interval: 15s timeout: 5s retries: 10 diff --git a/web/Controllers/Api/AuthApiController.cs b/web/Controllers/Api/AuthApiController.cs new file mode 100644 index 00000000..fe5e8162 --- /dev/null +++ b/web/Controllers/Api/AuthApiController.cs @@ -0,0 +1,63 @@ +using Atlas_Web.Models; +using Atlas_Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +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")] + public async Task Login([FromQuery] string returnUrl = "http://localhost:3000/auth/callback") + { + if (_config["Demo"] == "True") + { + var user = await _context.Users.FirstOrDefaultAsync(x => x.Username == "Default"); + if (user == null) + return NotFound("Demo user not found."); + + var token = _jwt.IssueToken( + user.Username ?? "Default", + user.FullnameCalc ?? "Guest", + user.UserId + ); + return Redirect($"{returnUrl}?token={token}"); + } + + return Unauthorized(new { error = "SAML login not configured for API flow." }); + } + + [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, + }); + } + + [AllowAnonymous] + [HttpPost("logout")] + public IActionResult Logout() + { + return Ok(new { ok = true }); + } +} diff --git a/web/Controllers/Api/ReportsApiController.cs b/web/Controllers/Api/ReportsApiController.cs new file mode 100644 index 00000000..71af9c0c --- /dev/null +++ b/web/Controllers/Api/ReportsApiController.cs @@ -0,0 +1,44 @@ +using Atlas_Web.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Atlas_Web.Controllers.Api; + +[ApiController] +[Route("api/reports")] +[Authorize(AuthenticationSchemes = "Bearer")] +public class ReportsApiController : ControllerBase +{ + private readonly Atlas_WebContext _context; + + public ReportsApiController(Atlas_WebContext context) + { + _context = context; + } + + [HttpGet] + public async Task GetReports([FromQuery] int page = 1, [FromQuery] int pageSize = 20) + { + var reports = await _context.ReportObjects + .Where(x => x.DefaultVisibilityYn == "Y") + .Include(x => x.ReportObjectType) + .OrderBy(x => x.Name) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(x => new + { + id = x.ReportObjectId, + name = x.Name ?? x.DisplayTitle, + description = x.Description, + type = x.ReportObjectType != null ? x.ReportObjectType.ShortName : null, + url = x.ReportObjectUrl, + lastModified = x.LastModifiedDate, + }) + .ToListAsync(); + + var total = await _context.ReportObjects.CountAsync(x => x.DefaultVisibilityYn == "Y"); + + return Ok(new { reports, total, page, pageSize }); + } +} diff --git a/web/Program.cs b/web/Program.cs index 3ae1873f..609915c3 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -1,6 +1,7 @@ using System.Data.SqlClient; using System.IO.Compression; using System.Security.Cryptography.X509Certificates; +using System.Text; using System.Text.RegularExpressions; using Atlas_Web.Authentication; using Atlas_Web.Authorization; @@ -8,6 +9,8 @@ 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 +61,20 @@ }); builder.Services.AddResponseCaching(); +builder.Services.AddCors(options => +{ + options.AddPolicy("NextJs", policy => + { + var origins = builder.Configuration + .GetSection("Cors:AllowedOrigins") + .Get() ?? new[] { "http://localhost:3000" }; + policy.WithOrigins(origins) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + }); +}); + // for linq queries - conditionally register based on environment if (!builder.Environment.IsEnvironment("Test")) { @@ -197,18 +214,50 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); +builder.Services.AddScoped(); + +var jwtKey = builder.Configuration["Jwt:Key"] ?? "atlas-dev-secret-key-change-in-production"; +var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "atlas-library"; +var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "atlas-library-nextjs"; if (builder.Configuration["Demo"] == "True") { # pragma warning disable S1116 builder .Services.AddAuthentication(options => options.DefaultScheme = "Demo") - .AddScheme("Demo", options => { }); + .AddScheme("Demo", options => { }) + .AddJwtBearer("Bearer", options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtIssuer, + ValidAudience = jwtAudience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + }; + }); ; } else { - builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme).AddNegotiate(); + 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 = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + }; + }); } if (builder.Configuration.GetSection("Saml2").Exists()) { @@ -344,6 +393,7 @@ var signingCertificate in entityDescriptor.IdPSsoDescriptor.SigningCertificates app.UseETagger(); app.UseRouting(); +app.UseCors("NextJs"); app.UseAuthentication(); app.UseAuthorization(); diff --git a/web/Services/JwtTokenService.cs b/web/Services/JwtTokenService.cs new file mode 100644 index 00000000..80837951 --- /dev/null +++ b/web/Services/JwtTokenService.cs @@ -0,0 +1,39 @@ +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 IConfiguration _config; + + public JwtTokenService(IConfiguration config) + { + _config = config; + } + + public string IssueToken(string username, string fullname, int userId) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]!)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(ClaimTypes.Name, username), + new Claim("Fullname", fullname), + new Claim("UserId", userId.ToString()), + }; + + var token = new JwtSecurityToken( + issuer: _config["Jwt:Issuer"], + audience: _config["Jwt:Audience"], + claims: claims, + expires: DateTime.UtcNow.AddHours(8), + signingCredentials: creds + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/web/appsettings.json b/web/appsettings.json index e0ee960c..ceac22df 100644 --- a/web/appsettings.json +++ b/web/appsettings.json @@ -48,5 +48,13 @@ }, "footer": { "subtitle": "Atlas was created by the Riverside Healthcare Analytics team." + }, + "Jwt": { + "Key": "atlas-dev-secret-key-change-in-production-32chars", + "Issuer": "atlas-library", + "Audience": "atlas-library-nextjs" + }, + "Cors": { + "AllowedOrigins": [ "http://localhost:3000" ] } } diff --git a/web/web.csproj b/web/web.csproj index 5ffd930a..f958c73c 100644 --- a/web/web.csproj +++ b/web/web.csproj @@ -28,6 +28,9 @@ true + + + From 6b76aeb6bdefd75c9086b1eb6ffabbc0bb137ae0 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Tue, 24 Mar 2026 16:20:16 +0300 Subject: [PATCH 02/32] Rollback web port to 3000 --- docker-compose.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 01df290a..ee08e059 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,18 +66,18 @@ services: - solr - sqlserver environment: - PORT: ${WEB_PORT:-5000} + PORT: ${WEB_PORT:-3000} SEED_DEMO: ${SEED_DEMO:-true} Jwt__Key: ${JWT_KEY:-atlas-dev-secret-key-change-in-production-32chars} Jwt__Issuer: ${JWT_ISSUER:-atlas-library} Jwt__Audience: ${JWT_AUDIENCE:-atlas-library-nextjs} expose: - - ${WEB_PORT:-5000} + - ${WEB_PORT:-3000} ports: - - '${WEB_PORT:-5000}:${WEB_PORT:-5000}' + - '${WEB_PORT:-3000}:${WEB_PORT:-3000}' healthcheck: test: - ['CMD-SHELL', 'wget -qO- http://localhost:${WEB_PORT:-5000}/ || exit 1'] + ['CMD-SHELL', 'wget -qO- http://localhost:${WEB_PORT:-3000}/ || exit 1'] interval: 15s timeout: 5s retries: 10 From f9dd0172dd29031eb3185198256f5e321b9084aa Mon Sep 17 00:00:00 2001 From: Semahegn Date: Tue, 24 Mar 2026 16:27:31 +0300 Subject: [PATCH 03/32] Require JWT config via environment --- docker-compose.yml | 6 +++--- web/appsettings.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ee08e059..50628a37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,9 +68,9 @@ services: environment: PORT: ${WEB_PORT:-3000} SEED_DEMO: ${SEED_DEMO:-true} - Jwt__Key: ${JWT_KEY:-atlas-dev-secret-key-change-in-production-32chars} - Jwt__Issuer: ${JWT_ISSUER:-atlas-library} - Jwt__Audience: ${JWT_AUDIENCE:-atlas-library-nextjs} + Jwt__Key: ${JWT_KEY} + Jwt__Issuer: ${JWT_ISSUER} + Jwt__Audience: ${JWT_AUDIENCE} expose: - ${WEB_PORT:-3000} ports: diff --git a/web/appsettings.json b/web/appsettings.json index ceac22df..e2891c3a 100644 --- a/web/appsettings.json +++ b/web/appsettings.json @@ -50,9 +50,9 @@ "subtitle": "Atlas was created by the Riverside Healthcare Analytics team." }, "Jwt": { - "Key": "atlas-dev-secret-key-change-in-production-32chars", - "Issuer": "atlas-library", - "Audience": "atlas-library-nextjs" + "Key": "", + "Issuer": "", + "Audience": "" }, "Cors": { "AllowedOrigins": [ "http://localhost:3000" ] From 68bdc05a6a71db0f949c8d71e15e1d6d816c8d8c Mon Sep 17 00:00:00 2001 From: Semahegn Date: Wed, 25 Mar 2026 19:36:12 +0300 Subject: [PATCH 04/32] fix: open redirect replaced with known redirects and JWT secret fallback --- web/Controllers/Api/AuthApiController.cs | 16 +++++++++++++++- web/Program.cs | 17 ++++++++++++----- web/Services/JwtTokenService.cs | 8 +++++++- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/web/Controllers/Api/AuthApiController.cs b/web/Controllers/Api/AuthApiController.cs index fe5e8162..7e7e0ce9 100644 --- a/web/Controllers/Api/AuthApiController.cs +++ b/web/Controllers/Api/AuthApiController.cs @@ -29,14 +29,28 @@ public async Task Login([FromQuery] string returnUrl = "http://lo { var user = await _context.Users.FirstOrDefaultAsync(x => x.Username == "Default"); if (user == null) + { return NotFound("Demo user not found."); + } + + var allowedOrigins = _config.GetSection("Cors:AllowedOrigins").Get() ?? Array.Empty(); + var safeReturnUrl = returnUrl; + if (!Uri.TryCreate(returnUrl, UriKind.Absolute, out var parsedReturnUrl) + || !allowedOrigins.Any(origin => Uri.TryCreate(origin, UriKind.Absolute, out var parsedOrigin) + && string.Equals(parsedOrigin.Scheme, parsedReturnUrl.Scheme, StringComparison.OrdinalIgnoreCase) + && string.Equals(parsedOrigin.Host, parsedReturnUrl.Host, StringComparison.OrdinalIgnoreCase) + && parsedOrigin.Port == parsedReturnUrl.Port)) + { + safeReturnUrl = allowedOrigins.FirstOrDefault() ?? "http://localhost:3000"; + safeReturnUrl = safeReturnUrl.TrimEnd('/') + "/auth/callback"; + } var token = _jwt.IssueToken( user.Username ?? "Default", user.FullnameCalc ?? "Guest", user.UserId ); - return Redirect($"{returnUrl}?token={token}"); + return Redirect($"{safeReturnUrl}?token={token}"); } return Unauthorized(new { error = "SAML login not configured for API flow." }); diff --git a/web/Program.cs b/web/Program.cs index 609915c3..205adf70 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -216,9 +216,16 @@ builder.Services.AddTransient(); builder.Services.AddScoped(); -var jwtKey = builder.Configuration["Jwt:Key"] ?? "atlas-dev-secret-key-change-in-production"; -var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "atlas-library"; -var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "atlas-library-nextjs"; +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)) +{ + throw new InvalidOperationException( + "JWT configuration is missing. Please set Jwt:Key, Jwt:Issuer, and Jwt:Audience via environment variables or configuration files." + ); +} if (builder.Configuration["Demo"] == "True") { @@ -236,7 +243,7 @@ ValidateIssuerSigningKey = true, ValidIssuer = jwtIssuer, ValidAudience = jwtAudience, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey!)), }; }); ; @@ -255,7 +262,7 @@ ValidateIssuerSigningKey = true, ValidIssuer = jwtIssuer, ValidAudience = jwtAudience, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey!)), }; }); } diff --git a/web/Services/JwtTokenService.cs b/web/Services/JwtTokenService.cs index 80837951..8171f1e2 100644 --- a/web/Services/JwtTokenService.cs +++ b/web/Services/JwtTokenService.cs @@ -16,7 +16,13 @@ public JwtTokenService(IConfiguration config) public string IssueToken(string username, string fullname, int userId) { - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]!)); + var jwtKey = _config["Jwt:Key"]; + if (string.IsNullOrWhiteSpace(jwtKey)) + { + throw new InvalidOperationException("JWT signing key is missing. Please set Jwt:Key via environment variables or configuration."); + } + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var claims = new[] From 8f025f8390977ef3be88eb757800d3ec5404b008 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Wed, 25 Mar 2026 19:51:47 +0300 Subject: [PATCH 05/32] fix: Sonar security issues: remove hardcoded URLs, prevent open redirects, secure JWT handling --- .../IntegrationTests/Utilities/WebFactory.cs | 12 +++++ web/Controllers/Api/AuthApiController.cs | 45 +++++++++++++++---- web/Models/Atlas_WebContextFactory.cs | 24 ++++++++++ web/Program.cs | 14 +++++- web/Services/JwtTokenService.cs | 23 +++++----- web/appsettings.json | 3 ++ 6 files changed, 97 insertions(+), 24 deletions(-) create mode 100644 web/Models/Atlas_WebContextFactory.cs diff --git a/web.Tests/IntegrationTests/Utilities/WebFactory.cs b/web.Tests/IntegrationTests/Utilities/WebFactory.cs index 640508fe..068a8727 100644 --- a/web.Tests/IntegrationTests/Utilities/WebFactory.cs +++ b/web.Tests/IntegrationTests/Utilities/WebFactory.cs @@ -17,6 +17,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/Controllers/Api/AuthApiController.cs b/web/Controllers/Api/AuthApiController.cs index 7e7e0ce9..6900fbf4 100644 --- a/web/Controllers/Api/AuthApiController.cs +++ b/web/Controllers/Api/AuthApiController.cs @@ -23,7 +23,7 @@ public AuthApiController(JwtTokenService jwt, Atlas_WebContext context, IConfigu [AllowAnonymous] [HttpGet("login")] - public async Task Login([FromQuery] string returnUrl = "http://localhost:3000/auth/callback") + public async Task Login([FromQuery] string? returnUrl = null) { if (_config["Demo"] == "True") { @@ -34,15 +34,42 @@ public async Task Login([FromQuery] string returnUrl = "http://lo } var allowedOrigins = _config.GetSection("Cors:AllowedOrigins").Get() ?? Array.Empty(); - var safeReturnUrl = returnUrl; - if (!Uri.TryCreate(returnUrl, UriKind.Absolute, out var parsedReturnUrl) - || !allowedOrigins.Any(origin => Uri.TryCreate(origin, UriKind.Absolute, out var parsedOrigin) - && string.Equals(parsedOrigin.Scheme, parsedReturnUrl.Scheme, StringComparison.OrdinalIgnoreCase) - && string.Equals(parsedOrigin.Host, parsedReturnUrl.Host, StringComparison.OrdinalIgnoreCase) - && parsedOrigin.Port == parsedReturnUrl.Port)) + var defaultCallbackPath = _config["Auth:DefaultCallbackPath"]; + + if (string.IsNullOrWhiteSpace(defaultCallbackPath)) { - safeReturnUrl = allowedOrigins.FirstOrDefault() ?? "http://localhost:3000"; - safeReturnUrl = safeReturnUrl.TrimEnd('/') + "/auth/callback"; + return BadRequest("Auth:DefaultCallbackPath is not configured."); + } + + string safeReturnUrl; + if (string.IsNullOrWhiteSpace(returnUrl)) + { + safeReturnUrl = (allowedOrigins.FirstOrDefault() ?? string.Empty).TrimEnd('/') + defaultCallbackPath; + } + else if (Uri.TryCreate(returnUrl, UriKind.Absolute, out var parsedReturnUrl)) + { + var isAllowed = false; + foreach (var origin in allowedOrigins) + { + if (Uri.TryCreate(origin, UriKind.Absolute, out var parsedOrigin) + && string.Equals(parsedOrigin.Scheme, parsedReturnUrl.Scheme, StringComparison.OrdinalIgnoreCase) + && string.Equals(parsedOrigin.Host, parsedReturnUrl.Host, StringComparison.OrdinalIgnoreCase) + && parsedOrigin.Port == parsedReturnUrl.Port) + { + isAllowed = true; + break; + } + } + safeReturnUrl = isAllowed ? returnUrl : (allowedOrigins.FirstOrDefault() ?? string.Empty).TrimEnd('/') + defaultCallbackPath; + } + else + { + safeReturnUrl = (allowedOrigins.FirstOrDefault() ?? string.Empty).TrimEnd('/') + defaultCallbackPath; + } + + if (string.IsNullOrWhiteSpace(safeReturnUrl)) + { + return BadRequest("No allowed origins configured."); } var token = _jwt.IssueToken( 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/Program.cs b/web/Program.cs index 205adf70..0681f485 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -67,7 +67,15 @@ { var origins = builder.Configuration .GetSection("Cors:AllowedOrigins") - .Get() ?? new[] { "http://localhost:3000" }; + .Get(); + + if (origins == null || origins.Length == 0) + { + throw new InvalidOperationException( + "CORS allowed origins are not configured. Please set Cors:AllowedOrigins in configuration." + ); + } + policy.WithOrigins(origins) .AllowAnyHeader() .AllowAnyMethod() @@ -214,7 +222,6 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); -builder.Services.AddScoped(); var jwtKey = builder.Configuration["Jwt:Key"]; var jwtIssuer = builder.Configuration["Jwt:Issuer"]; @@ -227,6 +234,9 @@ ); } +var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey!)); +builder.Services.AddScoped(_ => new JwtTokenService(signingKey, jwtIssuer!, jwtAudience!)); + if (builder.Configuration["Demo"] == "True") { # pragma warning disable S1116 diff --git a/web/Services/JwtTokenService.cs b/web/Services/JwtTokenService.cs index 8171f1e2..134fb8a1 100644 --- a/web/Services/JwtTokenService.cs +++ b/web/Services/JwtTokenService.cs @@ -7,23 +7,20 @@ namespace Atlas_Web.Services; public class JwtTokenService { - private readonly IConfiguration _config; + private readonly SymmetricSecurityKey _signingKey; + private readonly string _issuer; + private readonly string _audience; - public JwtTokenService(IConfiguration config) + public JwtTokenService(SymmetricSecurityKey signingKey, string issuer, string audience) { - _config = config; + _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) { - var jwtKey = _config["Jwt:Key"]; - if (string.IsNullOrWhiteSpace(jwtKey)) - { - throw new InvalidOperationException("JWT signing key is missing. Please set Jwt:Key via environment variables or configuration."); - } - - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); - var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var creds = new SigningCredentials(_signingKey, SecurityAlgorithms.HmacSha256); var claims = new[] { @@ -33,8 +30,8 @@ public string IssueToken(string username, string fullname, int userId) }; var token = new JwtSecurityToken( - issuer: _config["Jwt:Issuer"], - audience: _config["Jwt:Audience"], + issuer: _issuer, + audience: _audience, claims: claims, expires: DateTime.UtcNow.AddHours(8), signingCredentials: creds diff --git a/web/appsettings.json b/web/appsettings.json index e2891c3a..5fda5b28 100644 --- a/web/appsettings.json +++ b/web/appsettings.json @@ -56,5 +56,8 @@ }, "Cors": { "AllowedOrigins": [ "http://localhost:3000" ] + }, + "Auth": { + "DefaultCallbackPath": "/auth/callback" } } From 27972a760e2ab832928808910356f9797b016b48 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Wed, 25 Mar 2026 20:06:54 +0300 Subject: [PATCH 06/32] fix: sonar security and complexity issues, add CI test defaults --- web/Controllers/Api/AuthApiController.cs | 126 +++++++++++++--------- web/Program.cs | 76 +------------- web/ProgramConfiguration.cs | 128 +++++++++++++++++++++++ web/Services/JwtTokenService.cs | 2 + 4 files changed, 208 insertions(+), 124 deletions(-) create mode 100644 web/ProgramConfiguration.cs diff --git a/web/Controllers/Api/AuthApiController.cs b/web/Controllers/Api/AuthApiController.cs index 6900fbf4..55755a82 100644 --- a/web/Controllers/Api/AuthApiController.cs +++ b/web/Controllers/Api/AuthApiController.cs @@ -1,3 +1,4 @@ +#nullable enable using Atlas_Web.Models; using Atlas_Web.Services; using Microsoft.AspNetCore.Authorization; @@ -25,62 +26,87 @@ public AuthApiController(JwtTokenService jwt, Atlas_WebContext context, IConfigu [HttpGet("login")] public async Task Login([FromQuery] string? returnUrl = null) { - if (_config["Demo"] == "True") + if (_config["Demo"] != "True") { - var user = await _context.Users.FirstOrDefaultAsync(x => x.Username == "Default"); - if (user == null) - { - return NotFound("Demo user not found."); - } + return Unauthorized(new { error = "SAML login not configured for API flow." }); + } - 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."); - } - - string safeReturnUrl; - if (string.IsNullOrWhiteSpace(returnUrl)) - { - safeReturnUrl = (allowedOrigins.FirstOrDefault() ?? string.Empty).TrimEnd('/') + defaultCallbackPath; - } - else if (Uri.TryCreate(returnUrl, UriKind.Absolute, out var parsedReturnUrl)) - { - var isAllowed = false; - foreach (var origin in allowedOrigins) - { - if (Uri.TryCreate(origin, UriKind.Absolute, out var parsedOrigin) - && string.Equals(parsedOrigin.Scheme, parsedReturnUrl.Scheme, StringComparison.OrdinalIgnoreCase) - && string.Equals(parsedOrigin.Host, parsedReturnUrl.Host, StringComparison.OrdinalIgnoreCase) - && parsedOrigin.Port == parsedReturnUrl.Port) - { - isAllowed = true; - break; - } - } - safeReturnUrl = isAllowed ? returnUrl : (allowedOrigins.FirstOrDefault() ?? string.Empty).TrimEnd('/') + defaultCallbackPath; - } - else - { - safeReturnUrl = (allowedOrigins.FirstOrDefault() ?? string.Empty).TrimEnd('/') + defaultCallbackPath; - } - - if (string.IsNullOrWhiteSpace(safeReturnUrl)) + var user = await _context.Users.FirstOrDefaultAsync(x => x.Username == "Default"); + if (user == null) + { + return NotFound("Demo user not found."); + } + + var safeReturnUrlResult = GetSafeRedirectUrl(returnUrl); + if (safeReturnUrlResult is BadRequestObjectResult) + { + return safeReturnUrlResult; + } + + var safeReturnUrl = ((OkObjectResult)safeReturnUrlResult).Value as string ?? string.Empty; + var token = _jwt.IssueToken( + user.Username ?? "Default", + user.FullnameCalc ?? "Guest", + user.UserId + ); + + var redirectUrl = $"{safeReturnUrl}?token={token}"; + return Redirect(redirectUrl); + } + + 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 BadRequest("No allowed origins configured."); + return true; } - - var token = _jwt.IssueToken( - user.Username ?? "Default", - user.FullnameCalc ?? "Guest", - user.UserId - ); - return Redirect($"{safeReturnUrl}?token={token}"); } + return false; + } - return Unauthorized(new { error = "SAML login not configured for API flow." }); + 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); } [Authorize(AuthenticationSchemes = "Bearer")] diff --git a/web/Program.cs b/web/Program.cs index 0681f485..44a06acd 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -61,27 +61,7 @@ }); builder.Services.AddResponseCaching(); -builder.Services.AddCors(options => -{ - options.AddPolicy("NextJs", policy => - { - var origins = builder.Configuration - .GetSection("Cors:AllowedOrigins") - .Get(); - - if (origins == null || origins.Length == 0) - { - throw new InvalidOperationException( - "CORS allowed origins are not configured. Please set Cors:AllowedOrigins in configuration." - ); - } - - policy.WithOrigins(origins) - .AllowAnyHeader() - .AllowAnyMethod() - .AllowCredentials(); - }); -}); +ProgramConfiguration.ConfigureCors(builder); // for linq queries - conditionally register based on environment if (!builder.Environment.IsEnvironment("Test")) @@ -223,59 +203,7 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); -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)) -{ - throw new InvalidOperationException( - "JWT configuration is missing. Please set Jwt:Key, Jwt:Issuer, and Jwt:Audience via environment variables or configuration files." - ); -} - -var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey!)); -builder.Services.AddScoped(_ => new JwtTokenService(signingKey, jwtIssuer!, jwtAudience!)); - -if (builder.Configuration["Demo"] == "True") -{ -# pragma warning disable S1116 - builder - .Services.AddAuthentication(options => options.DefaultScheme = "Demo") - .AddScheme("Demo", options => { }) - .AddJwtBearer("Bearer", options => - { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - ValidIssuer = jwtIssuer, - ValidAudience = jwtAudience, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey!)), - }; - }); - ; -} -else -{ - 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 = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey!)), - }; - }); -} +ProgramConfiguration.ConfigureJwtAuthentication(builder); if (builder.Configuration.GetSection("Saml2").Exists()) { builder.Services.AddHttpClient(); diff --git a/web/ProgramConfiguration.cs b/web/ProgramConfiguration.cs new file mode 100644 index 00000000..e0c90d05 --- /dev/null +++ b/web/ProgramConfiguration.cs @@ -0,0 +1,128 @@ +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")) + { + jwtKey = "test-jwt-secret-key-for-ci-testing-minimum-32-characters"; + jwtIssuer = "atlas-test-issuer"; + jwtAudience = "atlas-test-audience"; + } + else + { + throw new InvalidOperationException( + "JWT configuration is missing. Please set Jwt:Key, Jwt:Issuer, and Jwt:Audience via environment variables or configuration files." + ); + } + } + + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); + 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 => { }) + .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/JwtTokenService.cs b/web/Services/JwtTokenService.cs index 134fb8a1..c291f123 100644 --- a/web/Services/JwtTokenService.cs +++ b/web/Services/JwtTokenService.cs @@ -20,7 +20,9 @@ public JwtTokenService(SymmetricSecurityKey signingKey, string issuer, string au 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[] { From 5b80dfad52a56be521f32e945d4a4d9a7d18faf7 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Wed, 25 Mar 2026 20:09:20 +0300 Subject: [PATCH 07/32] fix: disabled false flags --- web.Tests/IntegrationTests/Utilities/WebFactory.cs | 2 ++ web/Controllers/Api/AuthApiController.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/web.Tests/IntegrationTests/Utilities/WebFactory.cs b/web.Tests/IntegrationTests/Utilities/WebFactory.cs index 068a8727..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; diff --git a/web/Controllers/Api/AuthApiController.cs b/web/Controllers/Api/AuthApiController.cs index 55755a82..98c52dde 100644 --- a/web/Controllers/Api/AuthApiController.cs +++ b/web/Controllers/Api/AuthApiController.cs @@ -24,7 +24,9 @@ public AuthApiController(JwtTokenService jwt, Atlas_WebContext context, IConfigu [AllowAnonymous] [HttpGet("login")] +#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 { if (_config["Demo"] != "True") { From ff2577f0f7c968a36033bcf8f848103880c278a2 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Wed, 25 Mar 2026 20:14:32 +0300 Subject: [PATCH 08/32] fix: add using Atlas_Web; to Program.cs --- web/Controllers/Api/AuthApiController.cs | 2 ++ web/Program.cs | 1 + 2 files changed, 3 insertions(+) diff --git a/web/Controllers/Api/AuthApiController.cs b/web/Controllers/Api/AuthApiController.cs index 98c52dde..da3bf4ba 100644 --- a/web/Controllers/Api/AuthApiController.cs +++ b/web/Controllers/Api/AuthApiController.cs @@ -53,7 +53,9 @@ public async Task Login([FromQuery] string? returnUrl = null) ); var redirectUrl = $"{safeReturnUrl}?token={token}"; +#pragma warning disable S5146 // HTTP request redirections should not be open to forging attacks - False positive: URL is validated against CORS allowlist in GetSafeRedirectUrl return Redirect(redirectUrl); +#pragma warning restore S5146 } private IActionResult GetSafeRedirectUrl(string? returnUrl) diff --git a/web/Program.cs b/web/Program.cs index 44a06acd..cbd15c29 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -3,6 +3,7 @@ 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; From d3f10343ba196d31b3c0a7bac9d2d06406a67769 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Wed, 25 Mar 2026 21:14:50 +0300 Subject: [PATCH 09/32] Add JWT config to CI workflows and suppress Sonar false positives --- .github/workflows/lighthouse.yml | 9 +++++++++ .github/workflows/sonar.yml | 5 +++++ web/Controllers/Api/AuthApiController.cs | 4 ++-- web/ProgramConfiguration.cs | 4 ++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 011f636a..a61c8a7b 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -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/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/web/Controllers/Api/AuthApiController.cs b/web/Controllers/Api/AuthApiController.cs index da3bf4ba..e6e8c831 100644 --- a/web/Controllers/Api/AuthApiController.cs +++ b/web/Controllers/Api/AuthApiController.cs @@ -1,4 +1,5 @@ #nullable enable +using System.Diagnostics.CodeAnalysis; using Atlas_Web.Models; using Atlas_Web.Services; using Microsoft.AspNetCore.Authorization; @@ -24,6 +25,7 @@ public AuthApiController(JwtTokenService jwt, Atlas_WebContext context, IConfigu [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 @@ -53,9 +55,7 @@ public async Task Login([FromQuery] string? returnUrl = null) ); var redirectUrl = $"{safeReturnUrl}?token={token}"; -#pragma warning disable S5146 // HTTP request redirections should not be open to forging attacks - False positive: URL is validated against CORS allowlist in GetSafeRedirectUrl return Redirect(redirectUrl); -#pragma warning restore S5146 } private IActionResult GetSafeRedirectUrl(string? returnUrl) diff --git a/web/ProgramConfiguration.cs b/web/ProgramConfiguration.cs index e0c90d05..d4581042 100644 --- a/web/ProgramConfiguration.cs +++ b/web/ProgramConfiguration.cs @@ -51,9 +51,11 @@ public static void ConfigureJwtAuthentication(WebApplicationBuilder builder) { 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 { @@ -63,7 +65,9 @@ public static void ConfigureJwtAuthentication(WebApplicationBuilder builder) } } +#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") From 4f85cf67be32f0c3800e2194ca7826c7bdfd6a2c Mon Sep 17 00:00:00 2001 From: Semahegn Date: Wed, 1 Apr 2026 21:21:36 +0300 Subject: [PATCH 10/32] Add report API parity endpoints --- web/Contracts/Api/Reports/ReportDtos.cs | 271 ++++++++ web/Controllers/Api/ReportsApiController.cs | 156 ++++- web/Pages/Reports/Edit.cshtml.cs | 2 +- web/Program.cs | 2 + web/Services/ReportsApiService.Reads.cs | 726 ++++++++++++++++++++ web/Services/ReportsApiService.Writes.cs | 236 +++++++ web/Services/ReportsApiService.cs | 335 +++++++++ 7 files changed, 1701 insertions(+), 27 deletions(-) create mode 100644 web/Contracts/Api/Reports/ReportDtos.cs create mode 100644 web/Services/ReportsApiService.Reads.cs create mode 100644 web/Services/ReportsApiService.Writes.cs create mode 100644 web/Services/ReportsApiService.cs diff --git a/web/Contracts/Api/Reports/ReportDtos.cs b/web/Contracts/Api/Reports/ReportDtos.cs new file mode 100644 index 00000000..b6c9eb6a --- /dev/null +++ b/web/Contracts/Api/Reports/ReportDtos.cs @@ -0,0 +1,271 @@ +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 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 CanOpenInEditor { get; set; } + public bool CanViewGroups { 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 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 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 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/Controllers/Api/ReportsApiController.cs b/web/Controllers/Api/ReportsApiController.cs index 71af9c0c..e9dc37f1 100644 --- a/web/Controllers/Api/ReportsApiController.cs +++ b/web/Controllers/Api/ReportsApiController.cs @@ -1,7 +1,9 @@ -using Atlas_Web.Models; +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; -using Microsoft.EntityFrameworkCore; namespace Atlas_Web.Controllers.Api; @@ -10,35 +12,137 @@ namespace Atlas_Web.Controllers.Api; [Authorize(AuthenticationSchemes = "Bearer")] public class ReportsApiController : ControllerBase { - private readonly Atlas_WebContext _context; + private readonly IReportsApiService _reportsApiService; - public ReportsApiController(Atlas_WebContext context) + public ReportsApiController(IReportsApiService reportsApiService) { - _context = context; + _reportsApiService = reportsApiService; } [HttpGet] - public async Task GetReports([FromQuery] int page = 1, [FromQuery] int pageSize = 20) - { - var reports = await _context.ReportObjects - .Where(x => x.DefaultVisibilityYn == "Y") - .Include(x => x.ReportObjectType) - .OrderBy(x => x.Name) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(x => new + 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); + } + + [HttpPut("{id:int}")] + public async Task> UpdateReport( + int id, + [FromBody] UpdateReportDocumentRequestDto request, + CancellationToken cancellationToken = default + ) + { + if (!User.HasPermission("Edit Report Documentation")) + { + return Forbid(); + } + + var report = await _reportsApiService.UpdateReportAsync( + User, + id, + request, + cancellationToken + ); + if (report == null) + { + return NotFound(); + } + + return Ok(report); + } + + [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) { - id = x.ReportObjectId, - name = x.Name ?? x.DisplayTitle, - description = x.Description, - type = x.ReportObjectType != null ? x.ReportObjectType.ShortName : null, - url = x.ReportObjectUrl, - lastModified = x.LastModifiedDate, - }) - .ToListAsync(); - - var total = await _context.ReportObjects.CountAsync(x => x.DefaultVisibilityYn == "Y"); - - return Ok(new { reports, total, page, pageSize }); + 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/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 cbd15c29..0daea4eb 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -203,6 +203,8 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); +builder.Services.AddScoped(); +builder.Services.AddHttpContextAccessor(); ProgramConfiguration.ConfigureJwtAuthentication(builder); if (builder.Configuration.GetSection("Saml2").Exists()) diff --git a/web/Services/ReportsApiService.Reads.cs b/web/Services/ReportsApiService.Reads.cs new file mode 100644 index 00000000..771a57c4 --- /dev/null +++ b/web/Services/ReportsApiService.Reads.cs @@ -0,0 +1,726 @@ +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 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 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.DisplayName, + 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, + 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.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, + user.HasPermission("View Groups"), + user.HasPermission("Edit Report Purge Option"), + user.HasPermission("Edit Report Hidden Option") + ); + 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.CanOpenInEditor = user.HasPermission("Open In Editor"); + if (report.CanOpenInEditor) + { + 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(); + } + 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, + }; + + report.Parameters = Array.Empty(); + report.Queries = Array.Empty(); + report.ComponentQueries = Array.Empty(); + report.CanViewGroups = canViewGroups; + if (!canViewGroups) + { + report.Groups = Array.Empty(); + } + } +} diff --git a/web/Services/ReportsApiService.Writes.cs b/web/Services/ReportsApiService.Writes.cs new file mode 100644 index 00000000..f1351bf9 --- /dev/null +++ b/web/Services/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/ReportsApiService.cs b/web/Services/ReportsApiService.cs new file mode 100644 index 00000000..65541b99 --- /dev/null +++ b/web/Services/ReportsApiService.cs @@ -0,0 +1,335 @@ +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 SolrNet; +using SolrNet.Commands.Parameters; +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 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 ISolrReadOnlyOperations _solr; + private readonly ISolrReadOnlyOperations _solrLookup; + + public ReportsApiService( + Atlas_WebContext context, + IAuthorizationService authorizationService, + IHttpContextAccessor httpContextAccessor, + IConfiguration configuration, + ISolrReadOnlyOperations solr, + ISolrReadOnlyOperations solrLookup + ) + { + _context = context; + _authorizationService = authorizationService; + _httpContextAccessor = httpContextAccessor; + _configuration = configuration; + _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 UpdateReportAsync( + ClaimsPrincipal user, + int id, + UpdateReportDocumentRequestDto request, + CancellationToken cancellationToken + ) + { + var reportExists = await _context.ReportObjects.AnyAsync( + x => x.ReportObjectId == id, + cancellationToken + ); + if (!reportExists) + { + return null; + } + + 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); + + 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); + + 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", + _ => 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); + } +} From 6e52d32f6d6e4e1dca34e8476edf00195c4312d1 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Wed, 1 Apr 2026 23:54:01 +0300 Subject: [PATCH 11/32] add :3001 to allowed origins --- web/appsettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/appsettings.json b/web/appsettings.json index 5fda5b28..a2e38b1b 100644 --- a/web/appsettings.json +++ b/web/appsettings.json @@ -55,7 +55,7 @@ "Audience": "" }, "Cors": { - "AllowedOrigins": [ "http://localhost:3000" ] + "AllowedOrigins": [ "http://localhost:3000", "http://localhost:3001" ] }, "Auth": { "DefaultCallbackPath": "/auth/callback" From fef26b8fff55c5927b93fb4cc802403631f4f710 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Tue, 7 Apr 2026 17:13:07 +0300 Subject: [PATCH 12/32] feat(api): extend /me endpoint with roles, permissions and adminEnabled --- web/Controllers/Api/AuthApiController.cs | 109 +++++++++++++++++++---- 1 file changed, 94 insertions(+), 15 deletions(-) diff --git a/web/Controllers/Api/AuthApiController.cs b/web/Controllers/Api/AuthApiController.cs index e6e8c831..00f79112 100644 --- a/web/Controllers/Api/AuthApiController.cs +++ b/web/Controllers/Api/AuthApiController.cs @@ -1,10 +1,12 @@ #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; @@ -30,32 +32,49 @@ public AuthApiController(JwtTokenService jwt, Atlas_WebContext context, IConfigu public async Task Login([FromQuery] string? returnUrl = null) #pragma warning restore CS8632 { - if (_config["Demo"] != "True") + var user = await _context.Users.FirstOrDefaultAsync(x => x.Username == "Default"); + var safeReturnUrlResult = GetSafeRedirectUrl(returnUrl); + if (safeReturnUrlResult is BadRequestObjectResult) { - return Unauthorized(new { error = "SAML login not configured for API flow." }); + return safeReturnUrlResult; } - var user = await _context.Users.FirstOrDefaultAsync(x => x.Username == "Default"); - if (user == null) + var safeReturnUrl = ((OkObjectResult)safeReturnUrlResult).Value as string ?? string.Empty; + + if (_config["Demo"] == "True") { - return NotFound("Demo user not found."); + if (user == null) + { + return NotFound("Demo user not found."); + } + + var demoToken = _jwt.IssueToken( + user.Username ?? "Default", + user.FullnameCalc ?? "Guest", + user.UserId + ); + + return Redirect(BuildTokenRedirectUrl(safeReturnUrl, demoToken)); } - var safeReturnUrlResult = GetSafeRedirectUrl(returnUrl); - if (safeReturnUrlResult is BadRequestObjectResult) + if (User.Identity?.IsAuthenticated != true) { - return safeReturnUrlResult; + return Redirect(BuildSamlLoginUrl(safeReturnUrl)); + } + + var apiUser = await ResolveAuthenticatedUserAsync(User); + if (apiUser == null) + { + return Unauthorized(new { error = "Authenticated SAML user could not be resolved." }); } - var safeReturnUrl = ((OkObjectResult)safeReturnUrlResult).Value as string ?? string.Empty; var token = _jwt.IssueToken( - user.Username ?? "Default", - user.FullnameCalc ?? "Guest", - user.UserId + apiUser.Username ?? User.Identity?.Name ?? "Guest", + apiUser.FullnameCalc ?? User.FindFirstValue("Fullname") ?? apiUser.Username ?? "Guest", + apiUser.UserId ); - - var redirectUrl = $"{safeReturnUrl}?token={token}"; - return Redirect(redirectUrl); + + return Redirect(BuildTokenRedirectUrl(safeReturnUrl, token)); } private IActionResult GetSafeRedirectUrl(string? returnUrl) @@ -113,6 +132,63 @@ private IActionResult BuildDefaultRedirectUrl(string[] allowedOrigins, string de 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() @@ -122,6 +198,9 @@ public IActionResult Me() 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", }); } From b955b8b154ea003ca64ea6be645e6f6cd3486cdb Mon Sep 17 00:00:00 2001 From: Semahegn Date: Tue, 7 Apr 2026 17:26:28 +0300 Subject: [PATCH 13/32] fix(ci): bump lighthouse workflow to node 18 --- .github/workflows/lighthouse.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index a61c8a7b..5afdceb6 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: '18.x' - name: install node deps run: npm install - name: node build From 43aa1a7c1e4805903ade91548aafb7a6b0bdf298 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 09:05:51 +0000 Subject: [PATCH 14/32] chore(deps) Update all non-major dependencies (#687) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- web.Tests/web.Tests.csproj | 14 +++++++------- web/web.csproj | 22 +++++++++++----------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/web.Tests/web.Tests.csproj b/web.Tests/web.Tests.csproj index 135216a2..a5c33454 100644 --- a/web.Tests/web.Tests.csproj +++ b/web.Tests/web.Tests.csproj @@ -11,20 +11,20 @@ - - + + - + - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/web/web.csproj b/web/web.csproj index f958c73c..58fc5d50 100644 --- a/web/web.csproj +++ b/web/web.csproj @@ -28,9 +28,9 @@ true - + - + @@ -46,18 +46,18 @@ - + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -67,11 +67,11 @@ - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all From c5c11662fdd0e38331da637ef6d811413371cdc0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:36:03 +0000 Subject: [PATCH 15/32] chore(deps) Update dependency @rollup/plugin-babel to v7 (#688) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 155fdf2c..7159ca02 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", @@ -164,7 +164,7 @@ "unicorn/prefer-at": "warn" } }, - "version": "3.15.38", + "version": "3.15.39", "dependencies": { "sass": "^1.63.6" } From fd0f65564f0e4626a98ba1a8f66c0c32ec794c27 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Wed, 15 Apr 2026 15:49:01 +0300 Subject: [PATCH 16/32] feat(reports): add report api with feature flags and permission gates --- web/Contracts/Api/Reports/ReportDtos.cs | 33 +++++- web/Controllers/Api/ReportsApiController.cs | 68 ++++++++++++ .../{ => Reports}/ReportsApiService.Reads.cs | 58 ++++++++-- .../{ => Reports}/ReportsApiService.Writes.cs | 0 .../{ => Reports}/ReportsApiService.cs | 101 ++++++++++++++++++ 5 files changed, 250 insertions(+), 10 deletions(-) rename web/Services/{ => Reports}/ReportsApiService.Reads.cs (93%) rename web/Services/{ => Reports}/ReportsApiService.Writes.cs (100%) rename web/Services/{ => Reports}/ReportsApiService.cs (79%) diff --git a/web/Contracts/Api/Reports/ReportDtos.cs b/web/Contracts/Api/Reports/ReportDtos.cs index b6c9eb6a..d3621fd1 100644 --- a/web/Contracts/Api/Reports/ReportDtos.cs +++ b/web/Contracts/Api/Reports/ReportDtos.cs @@ -36,18 +36,21 @@ public sealed class ReportDetailDto 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 CanOpenInEditor { 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; } @@ -74,6 +77,15 @@ public sealed class ReportDetailDto 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; } @@ -225,6 +237,25 @@ public sealed class ReportMaintenanceStatusDto 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; } diff --git a/web/Controllers/Api/ReportsApiController.cs b/web/Controllers/Api/ReportsApiController.cs index e9dc37f1..476f3ed7 100644 --- a/web/Controllers/Api/ReportsApiController.cs +++ b/web/Controllers/Api/ReportsApiController.cs @@ -54,6 +54,74 @@ public async Task> GetReport( 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, diff --git a/web/Services/ReportsApiService.Reads.cs b/web/Services/Reports/ReportsApiService.Reads.cs similarity index 93% rename from web/Services/ReportsApiService.Reads.cs rename to web/Services/Reports/ReportsApiService.Reads.cs index 771a57c4..4cb66d4b 100644 --- a/web/Services/ReportsApiService.Reads.cs +++ b/web/Services/Reports/ReportsApiService.Reads.cs @@ -8,6 +8,31 @@ 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, @@ -85,6 +110,10 @@ 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); @@ -101,7 +130,7 @@ bool visibleOnly Id = x.ReportObjectId, Name = x.Name, DisplayTitle = x.DisplayTitle, - DisplayName = x.DisplayName, + DisplayName = x.DisplayTitle ?? x.Name, Description = x.Description, DetailedDescription = x.DetailedDescription, TypeName = x.ReportObjectType != null ? x.ReportObjectType.Name : null, @@ -112,6 +141,8 @@ bool visibleOnly EpicReportTemplateId = x.EpicReportTemplateId, ReportServerPath = x.ReportServerPath, Availability = x.Availability, + OrphanedReportObjectYn = x.OrphanedReportObjectYn, + RepositoryDescription = x.RepositoryDescription, VisibleInSearch = (x.OrphanedReportObjectYn ?? "N") == "N" && x.ReportObjectType != null @@ -352,6 +383,7 @@ bool visibleOnly return null; } + report.Features = features; report.Terms = await GetTermsAsync(id, cancellationToken); report.ComponentQueries = await GetComponentQueriesAsync(id, cancellationToken); report.Children = await GetChildrenAsync(id, cancellationToken); @@ -361,9 +393,9 @@ bool visibleOnly ApplyDetailVisibility( report, canEditDocumentation, - user.HasPermission("View Groups"), - user.HasPermission("Edit Report Purge Option"), - user.HasPermission("Edit Report Hidden Option") + canViewGroups, + canViewPurgeOption, + canViewHiddenOption ); ApplyReportActions(report, user); @@ -634,8 +666,10 @@ ClaimsPrincipal user report.CanRun ); report.RecordViewerUrl = actionReport.RecordViewerUrl(httpContext); - report.CanOpenInEditor = user.HasPermission("Open In Editor"); - if (report.CanOpenInEditor) + 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); @@ -689,6 +723,10 @@ bool canViewHiddenOption { report.Groups = Array.Empty(); } + if (report.Features?.TermsEnabled != true) + { + report.Terms = Array.Empty(); + } report.CanViewGroups = canViewGroups; return; } @@ -712,11 +750,13 @@ bool canViewHiddenOption Hidden = canViewHiddenOption ? report.Document.Hidden : null, MaintenanceLogs = report.Document.MaintenanceLogs, FragilityTags = report.Document.FragilityTags, + ServiceRequests = report.Document.ServiceRequests, }; - report.Parameters = Array.Empty(); - report.Queries = Array.Empty(); - report.ComponentQueries = Array.Empty(); + if (report.Features?.TermsEnabled != true) + { + report.Terms = Array.Empty(); + } report.CanViewGroups = canViewGroups; if (!canViewGroups) { diff --git a/web/Services/ReportsApiService.Writes.cs b/web/Services/Reports/ReportsApiService.Writes.cs similarity index 100% rename from web/Services/ReportsApiService.Writes.cs rename to web/Services/Reports/ReportsApiService.Writes.cs diff --git a/web/Services/ReportsApiService.cs b/web/Services/Reports/ReportsApiService.cs similarity index 79% rename from web/Services/ReportsApiService.cs rename to web/Services/Reports/ReportsApiService.cs index 65541b99..d66d2e23 100644 --- a/web/Services/ReportsApiService.cs +++ b/web/Services/Reports/ReportsApiService.cs @@ -25,6 +25,26 @@ Task GetReportAsync( 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, @@ -133,6 +153,87 @@ 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, From f8c5d4f7db8875aaa56540772e64bf074a2eb2cc Mon Sep 17 00:00:00 2001 From: Semahegn Date: Mon, 4 May 2026 21:50:49 +0300 Subject: [PATCH 17/32] feat: add SearchApiController with Solr-backed full-text search Exposes GET /api/search with query string (q), type handler (query/reports/terms/ collections/initiatives/users/groups), pagination, field-scoped search, advanced search (permission-gated), and arbitrary facet filter params. Returns results, facets, highlights, filter fields, and starred status per result type. --- web/Contracts/Api/Search/SearchDtos.cs | 66 ++++ web/Controllers/Api/SearchApiController.cs | 59 +++ web/Program.cs | 1 + web/Services/Search/SearchApiService.cs | 410 +++++++++++++++++++++ 4 files changed, 536 insertions(+) create mode 100644 web/Contracts/Api/Search/SearchDtos.cs create mode 100644 web/Controllers/Api/SearchApiController.cs create mode 100644 web/Services/Search/SearchApiService.cs 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/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/Program.cs b/web/Program.cs index 0daea4eb..65c49eb1 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -204,6 +204,7 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); ProgramConfiguration.ConfigureJwtAuthentication(builder); diff --git a/web/Services/Search/SearchApiService.cs b/web/Services/Search/SearchApiService.cs new file mode 100644 index 00000000..e077b80a --- /dev/null +++ b/web/Services/Search/SearchApiService.cs @@ -0,0 +1,410 @@ +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 const int ResultsPerPage = 20; + + 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() + ); + + var exactMatches = new List(); + var literals = Regex.Matches(searchString, @"("")(.+?)("")"); + + 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, @"("".+?"")", "") + .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 + ); + } +} From fb3b6ccfbb7feb7473c5425fe02261a1794a28ac Mon Sep 17 00:00:00 2001 From: Semahegn Date: Tue, 5 May 2026 00:35:15 +0300 Subject: [PATCH 18/32] fix: remove dead constant and add missing lookup types --- web/Services/Reports/ReportsApiService.cs | 3 +++ web/Services/Search/SearchApiService.cs | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/web/Services/Reports/ReportsApiService.cs b/web/Services/Reports/ReportsApiService.cs index d66d2e23..04cc9b1f 100644 --- a/web/Services/Reports/ReportsApiService.cs +++ b/web/Services/Reports/ReportsApiService.cs @@ -352,6 +352,9 @@ CancellationToken cancellationToken "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, }; diff --git a/web/Services/Search/SearchApiService.cs b/web/Services/Search/SearchApiService.cs index e077b80a..76ba0c04 100644 --- a/web/Services/Search/SearchApiService.cs +++ b/web/Services/Search/SearchApiService.cs @@ -27,7 +27,6 @@ CancellationToken cancellationToken public sealed class SearchApiService : ISearchApiService { private const int MaxPageSize = 100; - private const int ResultsPerPage = 20; private static readonly string[] FacetOrder = [ From 6868c0a1b05590005dde07673751b448d5bf64ca Mon Sep 17 00:00:00 2001 From: Semahegn Date: Tue, 5 May 2026 00:51:27 +0300 Subject: [PATCH 19/32] fix(sonar): regex --- web/Services/Search/SearchApiService.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web/Services/Search/SearchApiService.cs b/web/Services/Search/SearchApiService.cs index 76ba0c04..d76034e5 100644 --- a/web/Services/Search/SearchApiService.cs +++ b/web/Services/Search/SearchApiService.cs @@ -330,11 +330,13 @@ private static string BuildSearchQuery(string searchString, string field) searchString = Regex.Replace( searchString, @"\b(OR|AND|NOT)\b", - m => m.ToString().ToLower() + m => m.ToString().ToLower(), + RegexOptions.None, + TimeSpan.FromSeconds(1) ); var exactMatches = new List(); - var literals = Regex.Matches(searchString, @"("")(.+?)("")"); + var literals = Regex.Matches(searchString, @"("")(.+?)("")", RegexOptions.None, TimeSpan.FromSeconds(1)); foreach (Match literal in literals) { @@ -374,7 +376,7 @@ private static string BuildSearchQuery(string searchString, string field) } } - searchString = Regex.Replace(searchString, @"("".+?"")", "") + searchString = Regex.Replace(searchString, @"("".+?"")", "", RegexOptions.None, TimeSpan.FromSeconds(1)) .Replace("\"", "\\\"") .Trim(); From dfd81e7c505928a617d3865ef4f562a25050b3ff Mon Sep 17 00:00:00 2001 From: Semahegn Date: Tue, 5 May 2026 21:51:16 +0300 Subject: [PATCH 20/32] fi(ci): coverage by switching tests to dotnet collector --- .github/workflows/test.yaml | 32 ++++++++++++++++++++++++++++++-- package.json | 6 +++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index cba802ac..e94cb736 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -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: | @@ -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/package.json b/package.json index 7159ca02..86a7657e 100644 --- a/package.json +++ b/package.json @@ -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", From 7d9da0600aa4232bfff4263a8dde55340d804178 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Thu, 7 May 2026 02:21:18 +0300 Subject: [PATCH 21/32] feat(collections): add collections api --- .../Api/Collections/CollectionDtos.cs | 121 ++++ .../Api/CollectionsApiController.cs | 158 +++++ web/Program.cs | 1 + .../Collections/CollectionsApiService.cs | 548 ++++++++++++++++++ 4 files changed, 828 insertions(+) create mode 100644 web/Contracts/Api/Collections/CollectionDtos.cs create mode 100644 web/Controllers/Api/CollectionsApiController.cs create mode 100644 web/Services/Collections/CollectionsApiService.cs 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/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/Program.cs b/web/Program.cs index 65c49eb1..9b42b4d5 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -203,6 +203,7 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); 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); + } + } +} From 2fdb6d51260ae4c9622b5aff61bd4e988284eaf1 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Thu, 7 May 2026 14:46:13 +0300 Subject: [PATCH 22/32] feat(api): add interactions and profile API endpoints --- .../Api/Interactions/InteractionDtos.cs | 55 ++ web/Contracts/Api/Profile/ProfileDtos.cs | 58 ++ .../Api/InteractionsApiController.cs | 97 +++ web/Controllers/Api/ProfileApiController.cs | 186 +++++ web/Controllers/Api/ReportsApiController.cs | 27 +- web/Program.cs | 2 + .../Interactions/InteractionsApiService.cs | 481 ++++++++++++ web/Services/Profile/ProfileApiService.cs | 719 ++++++++++++++++++ web/Services/Reports/ReportsApiService.cs | 209 +++++ 9 files changed, 1824 insertions(+), 10 deletions(-) create mode 100644 web/Contracts/Api/Interactions/InteractionDtos.cs create mode 100644 web/Contracts/Api/Profile/ProfileDtos.cs create mode 100644 web/Controllers/Api/InteractionsApiController.cs create mode 100644 web/Controllers/Api/ProfileApiController.cs create mode 100644 web/Services/Interactions/InteractionsApiService.cs create mode 100644 web/Services/Profile/ProfileApiService.cs diff --git a/web/Contracts/Api/Interactions/InteractionDtos.cs b/web/Contracts/Api/Interactions/InteractionDtos.cs new file mode 100644 index 00000000..6a93d1fd --- /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/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..e7cd4f2f --- /dev/null +++ b/web/Controllers/Api/ProfileApiController.cs @@ -0,0 +1,186 @@ +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] int id, + [FromQuery] string type, + [FromQuery] double start_at = -31536000, + [FromQuery] double end_at = 0, + [FromQuery] List server = null, + [FromQuery] List database = null, + [FromQuery] List masterFile = null, + [FromQuery] List visible = null, + [FromQuery] List certification = null, + [FromQuery] List availability = null, + [FromQuery] List reportType = null, + CancellationToken cancellationToken = default + ) + { + return Ok( + await _profileApiService.GetChartAsync( + id, + type, + start_at, + end_at, + server, + database, + masterFile, + visible, + certification, + availability, + reportType, + cancellationToken + ) + ); + } + + [HttpGet("users")] + public async Task>> GetUsers( + [FromQuery] int id, + [FromQuery] string type, + [FromQuery] double start_at = -31536000, + [FromQuery] double end_at = 0, + [FromQuery] List server = null, + [FromQuery] List database = null, + [FromQuery] List masterFile = null, + [FromQuery] List visible = null, + [FromQuery] List certification = null, + [FromQuery] List availability = null, + [FromQuery] List reportType = null, + CancellationToken cancellationToken = default + ) + { + return Ok( + await _profileApiService.GetUsersAsync( + id, + type, + start_at, + end_at, + server, + database, + masterFile, + visible, + certification, + availability, + reportType, + cancellationToken + ) + ); + } + + [HttpGet("reports")] + public async Task>> GetReports( + [FromQuery] int id, + [FromQuery] string type, + [FromQuery] double start_at = -31536000, + [FromQuery] double end_at = 0, + [FromQuery] List server = null, + [FromQuery] List database = null, + [FromQuery] List masterFile = null, + [FromQuery] List visible = null, + [FromQuery] List certification = null, + [FromQuery] List availability = null, + [FromQuery] List reportType = null, + CancellationToken cancellationToken = default + ) + { + return Ok( + await _profileApiService.GetReportsAsync( + id, + type, + start_at, + end_at, + server, + database, + masterFile, + visible, + certification, + availability, + reportType, + cancellationToken + ) + ); + } + + [HttpGet("fails")] + public async Task>> GetFails( + [FromQuery] int id, + [FromQuery] string type, + [FromQuery] double start_at = -31536000, + [FromQuery] double end_at = 0, + [FromQuery] List server = null, + [FromQuery] List database = null, + [FromQuery] List masterFile = null, + [FromQuery] List visible = null, + [FromQuery] List certification = null, + [FromQuery] List availability = null, + [FromQuery] List reportType = null, + CancellationToken cancellationToken = default + ) + { + return Ok( + await _profileApiService.GetFailsAsync( + id, + type, + start_at, + end_at, + server, + database, + masterFile, + visible, + certification, + availability, + reportType, + 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 index 476f3ed7..a6105ea9 100644 --- a/web/Controllers/Api/ReportsApiController.cs +++ b/web/Controllers/Api/ReportsApiController.cs @@ -134,18 +134,25 @@ public async Task> UpdateReport( return Forbid(); } - var report = await _reportsApiService.UpdateReportAsync( - User, - id, - request, - cancellationToken - ); - if (report == null) + try { - return NotFound(); - } + var report = await _reportsApiService.UpdateReportAsync( + User, + id, + request, + cancellationToken + ); + if (report == null) + { + return NotFound(); + } - return Ok(report); + return Ok(report); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } } [HttpPost("{id:int}/images")] diff --git a/web/Program.cs b/web/Program.cs index 9b42b4d5..4f4d0349 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -204,6 +204,8 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); diff --git a/web/Services/Interactions/InteractionsApiService.cs b/web/Services/Interactions/InteractionsApiService.cs new file mode 100644 index 00000000..61a752c0 --- /dev/null +++ b/web/Services/Interactions/InteractionsApiService.cs @@ -0,0 +1,481 @@ +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, cancellationToken), + "collection" => await ToggleCollectionStarAsync(userId, request.Id, 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) + .Distinct() + .ToList(); + var groupIds = recipients + .Where(x => string.Equals(x.Type, "g", StringComparison.OrdinalIgnoreCase)) + .Select(x => x.UserId) + .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 + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true, + }; + 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 groupRecipient in groupUsers) + { + if (groupRecipient.User == null) + { + continue; + } + + await CreateShareAsync(sender, groupRecipient.User, 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 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/Profile/ProfileApiService.cs b/web/Services/Profile/ProfileApiService.cs new file mode 100644 index 00000000..07c849bc --- /dev/null +++ b/web/Services/Profile/ProfileApiService.cs @@ -0,0 +1,719 @@ +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( + int id, + string type, + double startAt, + double endAt, + List server, + List database, + List masterFile, + List visible, + List certification, + List availability, + List reportType, + CancellationToken cancellationToken + ); + Task> GetUsersAsync( + int id, + string type, + double startAt, + double endAt, + List server, + List database, + List masterFile, + List visible, + List certification, + List availability, + List reportType, + CancellationToken cancellationToken + ); + Task> GetReportsAsync( + int id, + string type, + double startAt, + double endAt, + List server, + List database, + List masterFile, + List visible, + List certification, + List availability, + List reportType, + CancellationToken cancellationToken + ); + Task> GetFailsAsync( + int id, + string type, + double startAt, + double endAt, + List server, + List database, + List masterFile, + List visible, + List certification, + List availability, + List reportType, + 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 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 readonly Atlas_WebContext _context; + private readonly IConfiguration _config; + + public ProfileApiService(Atlas_WebContext context, IConfiguration config) + { + _context = context; + _config = config; + } + + public async Task GetChartAsync( + int id, + string type, + double startAt, + double endAt, + List server, + List database, + List masterFile, + List visible, + List certification, + List availability, + List reportType, + CancellationToken cancellationToken + ) + { + var (subquery, subqueryGroup, dateFormat) = await BuildSubqueriesAsync( + id, + type, + startAt, + endAt, + server, + database, + masterFile, + visible, + certification, + availability, + reportType, + cancellationToken + ); + + 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 => (int)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 => (int)x.RunDurationSeconds, cancellationToken), 2) + : 0; + + return new ProfileChartResponseDto + { + Runs = totalRuns, + Users = distinctUsers, + RunTime = averageRunTime, + History = history, + }; + } + + public async Task> GetUsersAsync( + int id, + string type, + double startAt, + double endAt, + List server, + List database, + List masterFile, + List visible, + List certification, + List availability, + List reportType, + CancellationToken cancellationToken + ) + { + var (subquery, _, _) = await BuildSubqueriesAsync( + id, + type, + startAt, + endAt, + server, + database, + masterFile, + visible, + certification, + availability, + reportType, + cancellationToken + ); + + 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( + int id, + string type, + double startAt, + double endAt, + List server, + List database, + List masterFile, + List visible, + List certification, + List availability, + List reportType, + CancellationToken cancellationToken + ) + { + var (subquery, _, _) = await BuildSubqueriesAsync( + id, + type, + startAt, + endAt, + server, + database, + masterFile, + visible, + certification, + availability, + reportType, + cancellationToken + ); + + 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( + int id, + string type, + double startAt, + double endAt, + List server, + List database, + List masterFile, + List visible, + List certification, + List availability, + List reportType, + CancellationToken cancellationToken + ) + { + var (subquery, _, _) = await BuildSubqueriesAsync( + id, + type, + startAt, + endAt, + server, + database, + masterFile, + visible, + certification, + availability, + reportType, + cancellationToken + ); + + 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 = Regex.Replace( + Regex.Replace(grp.Key, @"^rs", "", RegexOptions.Multiline), + @"(?<=[a-z])([A-Z])", + " $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, "report", 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 + ) + { + return type switch + { + "report" when await _context.ReportObjects.AnyAsync(x => x.ReportObjectId == id, cancellationToken) => + await _context.Users.Where(x => x.StarredReports.Any(r => r.Reportid == id)) + .Select(x => new ProfileStarUserDto + { + Id = x.UserId, + FullName = x.FullnameCalc, + Email = x.Email, + }) + .AsNoTracking() + .ToListAsync(cancellationToken), + "term" when await _context.Terms.AnyAsync(x => x.TermId == id, cancellationToken) => + await _context.Users.Where(x => x.StarredTerms.Any(r => r.Termid == id)) + .Select(x => new ProfileStarUserDto + { + Id = x.UserId, + FullName = x.FullnameCalc, + Email = x.Email, + }) + .AsNoTracking() + .ToListAsync(cancellationToken), + "collection" when await _context.Collections.AnyAsync(x => x.CollectionId == id, cancellationToken) => + await _context.Users.Where(x => x.StarredCollections.Any(r => r.Collectionid == id)) + .Select(x => new ProfileStarUserDto + { + Id = x.UserId, + FullName = x.FullnameCalc, + Email = x.Email, + }) + .AsNoTracking() + .ToListAsync(cancellationToken), + _ => throw new InvalidOperationException( + "Wrong parameter value supplied. Type: " + type + " with Id: " + id + ), + }; + } + + public async Task> GetSubscriptionsAsync( + int id, + string type, + CancellationToken cancellationToken + ) + { + if ( + !string.Equals(type, "report", StringComparison.OrdinalIgnoreCase) + || !await _context.ReportObjects.AnyAsync(x => x.ReportObjectId == id, cancellationToken) + ) + { + throw new InvalidOperationException( + "Wrong parameter value supplied. Type: " + type + " with Id: " + 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< + Tuple< + IQueryable, + IQueryable>, + string + > + > BuildSubqueriesAsync( + int id, + string type, + double startAt, + double endAt, + List server, + List database, + List masterFile, + List visible, + List certification, + List availability, + List reportType, + CancellationToken cancellationToken + ) + { + server ??= new List(); + database ??= new List(); + masterFile ??= new List(); + visible ??= new List(); + certification ??= new List(); + availability ??= new List(); + reportType ??= new List(); + + var start = DateTime.Now.AddSeconds(startAt); + var end = DateTime.Now.AddSeconds(endAt); + + var runData = _context.ReportObjectRunDatas.AsQueryable(); + var reports = _context.ReportObjects.AsQueryable(); + + if (string.Equals(type, "report", StringComparison.OrdinalIgnoreCase)) + { + if (id == -1) + { + reports = ApplyReportFilters( + reports, + server, + database, + masterFile, + visible, + certification, + availability, + reportType + ); + } + else if (await _context.ReportObjects.AnyAsync(x => x.ReportObjectId == id, cancellationToken)) + { + reports = reports.Where(x => x.ReportObjectId == id); + } + else + { + throw InvalidType(type, id); + } + } + else if ( + string.Equals(type, "term", StringComparison.OrdinalIgnoreCase) + && await _context.Terms.AnyAsync(x => x.TermId == id, cancellationToken) + ) + { + reports = reports.Where(x => + _context.ReportObjectDocTerms.Where(t => t.TermId == id) + .Select(t => t.ReportObjectId) + .Contains(x.ReportObjectId) + ); + } + else if ( + string.Equals(type, "collection", StringComparison.OrdinalIgnoreCase) + && await _context.Collections.AnyAsync(x => x.CollectionId == id, cancellationToken) + ) + { + reports = reports.Where(x => + _context.CollectionReports.Where(c => c.CollectionId == id) + .Select(c => c.ReportId) + .Contains(x.ReportObjectId) + ); + } + else if ( + string.Equals(type, "user", StringComparison.OrdinalIgnoreCase) + && await _context.Users.AnyAsync(x => x.UserId == id, cancellationToken) + ) + { + runData = runData.Where(x => x.RunUserId == id); + reports = ApplyReportFilters( + reports, + server, + database, + masterFile, + visible, + certification, + availability, + reportType + ); + } + else if ( + string.Equals(type, "group", StringComparison.OrdinalIgnoreCase) + && await _context.UserGroups.AnyAsync(x => x.GroupId == id, cancellationToken) + ) + { + runData = runData.Where(x => x.RunUser.UserGroupsMemberships.Any(g => g.GroupId == id)); + reports = ApplyReportFilters( + reports, + server, + database, + masterFile, + visible, + certification, + availability, + reportType + ); + } + else + { + throw InvalidType(type, id); + } + + 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, + }; + + string dateFormat; + IQueryable> grouped; + + if (endAt - startAt < 172800) + { + dateFormat = "h tt"; + grouped = joined.Where(x => x.RunStartTime_Hour >= start && x.RunStartTime_Hour <= end) + .GroupBy(x => x.RunStartTime_Hour); + } + else if (endAt - startAt < 31536000) + { + dateFormat = endAt - startAt < 691200 ? "ddd M/d" : "MMM d"; + grouped = joined.Where(x => x.RunStartTime_Day >= start && x.RunStartTime_Day <= end) + .GroupBy(x => x.RunStartTime_Day); + } + else + { + dateFormat = "MMM yy"; + grouped = joined.Where(x => x.RunStartTime_Month >= start && x.RunStartTime_Month <= end) + .GroupBy(x => x.RunStartTime_Month); + } + + var flattened = grouped.SelectMany(x => x); + return Tuple.Create(flattened, grouped, dateFormat); + } + + private IQueryable ApplyReportFilters( + IQueryable reports, + List server, + List database, + List masterFile, + List visible, + List certification, + List availability, + List reportType + ) + { + if (server.Count > 0) + { + reports = reports.Where(x => server.Contains(x.SourceServer)); + } + + if (database.Count > 0) + { + reports = reports.Where(x => database.Contains(x.SourceDb)); + } + + if (masterFile.Count > 0) + { + reports = reports.Where(x => + masterFile.Contains(x.EpicMasterFile) + || (masterFile.Contains("None") && string.IsNullOrEmpty(x.EpicMasterFile)) + ); + } + + if (visible.Count > 0) + { + reports = reports.Where(x => + visible.Contains(x.DefaultVisibilityYn) + || (visible.Contains("Y") && string.IsNullOrEmpty(x.DefaultVisibilityYn)) + ); + } + + if (certification.Count > 0) + { + reports = reports.Where(x => + certification.Intersect(x.ReportTagLinks.Select(y => y.Tag.Name)).Any() + ); + } + + if (availability.Count > 0) + { + reports = reports.Where(x => + availability.Contains(x.Availability) + || (availability.Contains("Public") && string.IsNullOrEmpty(x.Availability)) + ); + } + + if (reportType.Count > 0) + { + reports = reports.Where(x => reportType.Contains((int)x.ReportObjectTypeId)); + } + + return reports; + } + + private bool IsUserProfileEnabled() + { + return _config["features:enable_user_profile"] == null + || _config["features:enable_user_profile"].ToLower() == "true"; + } + + private static InvalidOperationException InvalidType(string type, int id) + { + return new InvalidOperationException( + "Wrong parameter value supplied. Type: " + type + " with Id: " + id + ); + } +} diff --git a/web/Services/Reports/ReportsApiService.cs b/web/Services/Reports/ReportsApiService.cs index 04cc9b1f..5f983ea8 100644 --- a/web/Services/Reports/ReportsApiService.cs +++ b/web/Services/Reports/ReportsApiService.cs @@ -6,8 +6,10 @@ 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; @@ -82,6 +84,7 @@ public sealed partial class ReportsApiService : IReportsApiService 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; @@ -90,6 +93,7 @@ public ReportsApiService( IAuthorizationService authorizationService, IHttpContextAccessor httpContextAccessor, IConfiguration configuration, + IMemoryCache cache, ISolrReadOnlyOperations solr, ISolrReadOnlyOperations solrLookup ) @@ -98,6 +102,7 @@ ISolrReadOnlyOperations solrLookup _authorizationService = authorizationService; _httpContextAccessor = httpContextAccessor; _configuration = configuration; + _cache = cache; _solr = solr; _solrLookup = solrLookup; } @@ -241,6 +246,8 @@ public async Task UpdateReportAsync( CancellationToken cancellationToken ) { + await ValidateUpdateRequestAsync(id, request, cancellationToken); + var reportExists = await _context.ReportObjects.AnyAsync( x => x.ReportObjectId == id, cancellationToken @@ -250,6 +257,13 @@ CancellationToken cancellationToken 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 @@ -293,6 +307,12 @@ CancellationToken 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); } @@ -328,6 +348,7 @@ await _context.ReportObjectImagesDocs.Where(x => x.ReportObjectId == id) await _context.ReportObjectImagesDocs.AddAsync(image, cancellationToken); await _context.SaveChangesAsync(cancellationToken); + InvalidateReportCaches(id, Array.Empty(), Array.Empty()); return new ReportImageDto { @@ -436,4 +457,192 @@ CancellationToken cancellationToken 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}"); + } + } } From e92f6795a7e8443bb8e6359884c361d0cdb04d0e Mon Sep 17 00:00:00 2001 From: Semahegn Date: Thu, 7 May 2026 15:28:43 +0300 Subject: [PATCH 23/32] ref: profile API query handling and resolve Sonar issues in profile/interactions services --- .../Api/Interactions/InteractionDtos.cs | 4 +- .../Api/Profile/ProfileQueryRequestDto.cs | 39 ++ web/Controllers/Api/ProfileApiController.cs | 116 +--- .../Interactions/InteractionsApiService.cs | 25 +- web/Services/Profile/ProfileApiService.cs | 589 +++++++++--------- 5 files changed, 340 insertions(+), 433 deletions(-) create mode 100644 web/Contracts/Api/Profile/ProfileQueryRequestDto.cs diff --git a/web/Contracts/Api/Interactions/InteractionDtos.cs b/web/Contracts/Api/Interactions/InteractionDtos.cs index 6a93d1fd..42da668e 100644 --- a/web/Contracts/Api/Interactions/InteractionDtos.cs +++ b/web/Contracts/Api/Interactions/InteractionDtos.cs @@ -3,7 +3,7 @@ namespace Atlas_Web.Contracts.Api.Interactions; public sealed class ToggleStarRequestDto { public string Type { get; init; } - public int Id { get; init; } + public int? Id { get; init; } } public sealed class ToggleStarResponseDto @@ -28,7 +28,7 @@ public sealed class ShareMailRequestDto public sealed class ShareRecipientDto { - public int UserId { get; init; } + public int? UserId { get; init; } public string Type { 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/Controllers/Api/ProfileApiController.cs b/web/Controllers/Api/ProfileApiController.cs index e7cd4f2f..8bfadb6a 100644 --- a/web/Controllers/Api/ProfileApiController.cs +++ b/web/Controllers/Api/ProfileApiController.cs @@ -19,138 +19,38 @@ public ProfileApiController(IProfileApiService profileApiService) [HttpGet("chart")] public async Task> GetChart( - [FromQuery] int id, - [FromQuery] string type, - [FromQuery] double start_at = -31536000, - [FromQuery] double end_at = 0, - [FromQuery] List server = null, - [FromQuery] List database = null, - [FromQuery] List masterFile = null, - [FromQuery] List visible = null, - [FromQuery] List certification = null, - [FromQuery] List availability = null, - [FromQuery] List reportType = null, + [FromQuery] ProfileQueryRequestDto request, CancellationToken cancellationToken = default ) { - return Ok( - await _profileApiService.GetChartAsync( - id, - type, - start_at, - end_at, - server, - database, - masterFile, - visible, - certification, - availability, - reportType, - cancellationToken - ) - ); + return Ok(await _profileApiService.GetChartAsync(request, cancellationToken)); } [HttpGet("users")] public async Task>> GetUsers( - [FromQuery] int id, - [FromQuery] string type, - [FromQuery] double start_at = -31536000, - [FromQuery] double end_at = 0, - [FromQuery] List server = null, - [FromQuery] List database = null, - [FromQuery] List masterFile = null, - [FromQuery] List visible = null, - [FromQuery] List certification = null, - [FromQuery] List availability = null, - [FromQuery] List reportType = null, + [FromQuery] ProfileQueryRequestDto request, CancellationToken cancellationToken = default ) { - return Ok( - await _profileApiService.GetUsersAsync( - id, - type, - start_at, - end_at, - server, - database, - masterFile, - visible, - certification, - availability, - reportType, - cancellationToken - ) - ); + return Ok(await _profileApiService.GetUsersAsync(request, cancellationToken)); } [HttpGet("reports")] public async Task>> GetReports( - [FromQuery] int id, - [FromQuery] string type, - [FromQuery] double start_at = -31536000, - [FromQuery] double end_at = 0, - [FromQuery] List server = null, - [FromQuery] List database = null, - [FromQuery] List masterFile = null, - [FromQuery] List visible = null, - [FromQuery] List certification = null, - [FromQuery] List availability = null, - [FromQuery] List reportType = null, + [FromQuery] ProfileQueryRequestDto request, CancellationToken cancellationToken = default ) { - return Ok( - await _profileApiService.GetReportsAsync( - id, - type, - start_at, - end_at, - server, - database, - masterFile, - visible, - certification, - availability, - reportType, - cancellationToken - ) - ); + return Ok(await _profileApiService.GetReportsAsync(request, cancellationToken)); } [HttpGet("fails")] public async Task>> GetFails( - [FromQuery] int id, - [FromQuery] string type, - [FromQuery] double start_at = -31536000, - [FromQuery] double end_at = 0, - [FromQuery] List server = null, - [FromQuery] List database = null, - [FromQuery] List masterFile = null, - [FromQuery] List visible = null, - [FromQuery] List certification = null, - [FromQuery] List availability = null, - [FromQuery] List reportType = null, + [FromQuery] ProfileQueryRequestDto request, CancellationToken cancellationToken = default ) { - return Ok( - await _profileApiService.GetFailsAsync( - id, - type, - start_at, - end_at, - server, - database, - masterFile, - visible, - certification, - availability, - reportType, - cancellationToken - ) - ); + return Ok(await _profileApiService.GetFailsAsync(request, cancellationToken)); } [HttpGet("run-list")] diff --git a/web/Services/Interactions/InteractionsApiService.cs b/web/Services/Interactions/InteractionsApiService.cs index 61a752c0..6236a739 100644 --- a/web/Services/Interactions/InteractionsApiService.cs +++ b/web/Services/Interactions/InteractionsApiService.cs @@ -81,8 +81,12 @@ CancellationToken cancellationToken return type switch { - "report" => await ToggleReportStarAsync(userId, request.Id, cancellationToken), - "collection" => await ToggleCollectionStarAsync(userId, request.Id, cancellationToken), + "report" => await ToggleReportStarAsync(userId, request.Id!.Value, cancellationToken), + "collection" => await ToggleCollectionStarAsync( + userId, + request.Id!.Value, + cancellationToken + ), _ => throw new InvalidOperationException("Unsupported star target type."), }; } @@ -106,12 +110,12 @@ CancellationToken cancellationToken var userIds = recipients .Where(x => !string.Equals(x.Type, "g", StringComparison.OrdinalIgnoreCase)) - .Select(x => x.UserId) + .Select(x => x.UserId!.Value) .Distinct() .ToList(); var groupIds = recipients .Where(x => string.Equals(x.Type, "g", StringComparison.OrdinalIgnoreCase)) - .Select(x => x.UserId) + .Select(x => x.UserId!.Value) .Distinct() .ToList(); @@ -194,10 +198,7 @@ CancellationToken cancellationToken throw new InvalidOperationException("Feedback target is required."); } - using var handler = new HttpClientHandler - { - ServerCertificateCustomValidationCallback = (_, _, _, _) => true, - }; + using var handler = new HttpClientHandler(); using var client = new HttpClient(handler) { DefaultRequestVersion = new Version(1, 1), @@ -375,14 +376,14 @@ CancellationToken cancellationToken shareCount++; } - foreach (var groupRecipient in groupUsers) + foreach (var recipient in groupUsers.Select(groupRecipient => groupRecipient.User)) { - if (groupRecipient.User == null) + if (recipient == null) { continue; } - await CreateShareAsync(sender, groupRecipient.User, request, cancellationToken); + await CreateShareAsync(sender, recipient, request, cancellationToken); shareCount++; } @@ -440,7 +441,7 @@ await _emailer.SendAsync( ); } - private object BuildRequester(ClaimsPrincipal user) + private static object BuildRequester(ClaimsPrincipal user) { var email = user.GetUserEmail(); var name = user.GetUserName(); diff --git a/web/Services/Profile/ProfileApiService.cs b/web/Services/Profile/ProfileApiService.cs index 07c849bc..ec4d1e04 100644 --- a/web/Services/Profile/ProfileApiService.cs +++ b/web/Services/Profile/ProfileApiService.cs @@ -8,59 +8,19 @@ namespace Atlas_Web.Services; public interface IProfileApiService { Task GetChartAsync( - int id, - string type, - double startAt, - double endAt, - List server, - List database, - List masterFile, - List visible, - List certification, - List availability, - List reportType, + ProfileQueryRequestDto request, CancellationToken cancellationToken ); Task> GetUsersAsync( - int id, - string type, - double startAt, - double endAt, - List server, - List database, - List masterFile, - List visible, - List certification, - List availability, - List reportType, + ProfileQueryRequestDto request, CancellationToken cancellationToken ); Task> GetReportsAsync( - int id, - string type, - double startAt, - double endAt, - List server, - List database, - List masterFile, - List visible, - List certification, - List availability, - List reportType, + ProfileQueryRequestDto request, CancellationToken cancellationToken ); Task> GetFailsAsync( - int id, - string type, - double startAt, - double endAt, - List server, - List database, - List masterFile, - List visible, - List certification, - List availability, - List reportType, + ProfileQueryRequestDto request, CancellationToken cancellationToken ); Task> GetRunListAsync( @@ -83,6 +43,15 @@ 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; } @@ -99,6 +68,28 @@ private sealed class ProfileRunRow 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 ReportType { 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; @@ -109,34 +100,15 @@ public ProfileApiService(Atlas_WebContext context, IConfiguration config) } public async Task GetChartAsync( - int id, - string type, - double startAt, - double endAt, - List server, - List database, - List masterFile, - List visible, - List certification, - List availability, - List reportType, + ProfileQueryRequestDto request, CancellationToken cancellationToken ) { - var (subquery, subqueryGroup, dateFormat) = await BuildSubqueriesAsync( - id, - type, - startAt, - endAt, - server, - database, - masterFile, - visible, - certification, - availability, - reportType, - 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 @@ -146,7 +118,7 @@ orderby grp.Key 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 => (int)x.RunDurationSeconds), 1), + RunTime = Math.Round(grp.Average(x => x.RunDurationSeconds), 1), } ) .AsNoTracking() @@ -155,7 +127,7 @@ orderby grp.Key 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 => (int)x.RunDurationSeconds, cancellationToken), 2) + ? Math.Round(await subquery.AverageAsync(x => x.RunDurationSeconds, cancellationToken), 2) : 0; return new ProfileChartResponseDto @@ -168,34 +140,11 @@ orderby grp.Key } public async Task> GetUsersAsync( - int id, - string type, - double startAt, - double endAt, - List server, - List database, - List masterFile, - List visible, - List certification, - List availability, - List reportType, + ProfileQueryRequestDto request, CancellationToken cancellationToken ) { - var (subquery, _, _) = await BuildSubqueriesAsync( - id, - type, - startAt, - endAt, - server, - database, - masterFile, - visible, - certification, - availability, - reportType, - cancellationToken - ); + var subquery = (await BuildSubqueriesAsync(ToQueryOptions(request), cancellationToken)).Flattened; var total = await subquery.SumAsync(x => x.Runs, cancellationToken); return await ( @@ -220,34 +169,11 @@ from a in subquery } public async Task> GetReportsAsync( - int id, - string type, - double startAt, - double endAt, - List server, - List database, - List masterFile, - List visible, - List certification, - List availability, - List reportType, + ProfileQueryRequestDto request, CancellationToken cancellationToken ) { - var (subquery, _, _) = await BuildSubqueriesAsync( - id, - type, - startAt, - endAt, - server, - database, - masterFile, - visible, - certification, - availability, - reportType, - cancellationToken - ); + var subquery = (await BuildSubqueriesAsync(ToQueryOptions(request), cancellationToken)).Flattened; var total = await subquery.SumAsync(x => x.Runs, cancellationToken); return await ( @@ -276,34 +202,11 @@ from a in subquery } public async Task> GetFailsAsync( - int id, - string type, - double startAt, - double endAt, - List server, - List database, - List masterFile, - List visible, - List certification, - List availability, - List reportType, + ProfileQueryRequestDto request, CancellationToken cancellationToken ) { - var (subquery, _, _) = await BuildSubqueriesAsync( - id, - type, - startAt, - endAt, - server, - database, - masterFile, - visible, - certification, - availability, - reportType, - cancellationToken - ); + var subquery = (await BuildSubqueriesAsync(ToQueryOptions(request), cancellationToken)).Flattened; var total = await subquery.SumAsync(x => x.Runs, cancellationToken); return await ( @@ -312,9 +215,8 @@ from a in subquery group a by a.RunStatus into grp select new ProfileBarItemDto { - Key = Regex.Replace( - Regex.Replace(grp.Key, @"^rs", "", RegexOptions.Multiline), - @"(?<=[a-z])([A-Z])", + Key = SplitCamelCaseRegex.Replace( + RsPrefixRegex.Replace(grp.Key, string.Empty), " $1" ), Count = grp.Sum(x => x.Runs), @@ -339,7 +241,7 @@ CancellationToken cancellationToken reportType ??= new List(); var runData = _context.ReportObjectRunDatas.AsQueryable(); - if (string.Equals(type, "report", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(type, ReportType, StringComparison.OrdinalIgnoreCase)) { return await ( from b in runData @@ -453,7 +355,7 @@ CancellationToken cancellationToken ) { if ( - !string.Equals(type, "report", StringComparison.OrdinalIgnoreCase) + !string.Equals(type, ReportType, StringComparison.OrdinalIgnoreCase) || !await _context.ReportObjects.AnyAsync(x => x.ReportObjectId == id, cancellationToken) ) { @@ -479,125 +381,23 @@ CancellationToken cancellationToken .ToListAsync(cancellationToken); } - private async Task< - Tuple< - IQueryable, - IQueryable>, - string - > - > BuildSubqueriesAsync( - int id, - string type, - double startAt, - double endAt, - List server, - List database, - List masterFile, - List visible, - List certification, - List availability, - List reportType, + private async Task BuildSubqueriesAsync( + ProfileQueryOptions query, CancellationToken cancellationToken ) { - server ??= new List(); - database ??= new List(); - masterFile ??= new List(); - visible ??= new List(); - certification ??= new List(); - availability ??= new List(); - reportType ??= new List(); - - var start = DateTime.Now.AddSeconds(startAt); - var end = DateTime.Now.AddSeconds(endAt); + var start = DateTime.Now.AddSeconds(query.StartAt); + var end = DateTime.Now.AddSeconds(query.EndAt); var runData = _context.ReportObjectRunDatas.AsQueryable(); var reports = _context.ReportObjects.AsQueryable(); - if (string.Equals(type, "report", StringComparison.OrdinalIgnoreCase)) - { - if (id == -1) - { - reports = ApplyReportFilters( - reports, - server, - database, - masterFile, - visible, - certification, - availability, - reportType - ); - } - else if (await _context.ReportObjects.AnyAsync(x => x.ReportObjectId == id, cancellationToken)) - { - reports = reports.Where(x => x.ReportObjectId == id); - } - else - { - throw InvalidType(type, id); - } - } - else if ( - string.Equals(type, "term", StringComparison.OrdinalIgnoreCase) - && await _context.Terms.AnyAsync(x => x.TermId == id, cancellationToken) - ) - { - reports = reports.Where(x => - _context.ReportObjectDocTerms.Where(t => t.TermId == id) - .Select(t => t.ReportObjectId) - .Contains(x.ReportObjectId) - ); - } - else if ( - string.Equals(type, "collection", StringComparison.OrdinalIgnoreCase) - && await _context.Collections.AnyAsync(x => x.CollectionId == id, cancellationToken) - ) - { - reports = reports.Where(x => - _context.CollectionReports.Where(c => c.CollectionId == id) - .Select(c => c.ReportId) - .Contains(x.ReportObjectId) - ); - } - else if ( - string.Equals(type, "user", StringComparison.OrdinalIgnoreCase) - && await _context.Users.AnyAsync(x => x.UserId == id, cancellationToken) - ) - { - runData = runData.Where(x => x.RunUserId == id); - reports = ApplyReportFilters( - reports, - server, - database, - masterFile, - visible, - certification, - availability, - reportType - ); - } - else if ( - string.Equals(type, "group", StringComparison.OrdinalIgnoreCase) - && await _context.UserGroups.AnyAsync(x => x.GroupId == id, cancellationToken) - ) - { - runData = runData.Where(x => x.RunUser.UserGroupsMemberships.Any(g => g.GroupId == id)); - reports = ApplyReportFilters( - reports, - server, - database, - masterFile, - visible, - certification, - availability, - reportType - ); - } - else - { - throw InvalidType(type, id); - } + (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 @@ -618,87 +418,66 @@ join r in reports on b.ReportObjectId equals r.ReportObjectId DisplayTitle = r.DisplayTitle, }; - string dateFormat; - IQueryable> grouped; - - if (endAt - startAt < 172800) - { - dateFormat = "h tt"; - grouped = joined.Where(x => x.RunStartTime_Hour >= start && x.RunStartTime_Hour <= end) - .GroupBy(x => x.RunStartTime_Hour); - } - else if (endAt - startAt < 31536000) - { - dateFormat = endAt - startAt < 691200 ? "ddd M/d" : "MMM d"; - grouped = joined.Where(x => x.RunStartTime_Day >= start && x.RunStartTime_Day <= end) - .GroupBy(x => x.RunStartTime_Day); - } - else - { - dateFormat = "MMM yy"; - grouped = joined.Where(x => x.RunStartTime_Month >= start && x.RunStartTime_Month <= end) - .GroupBy(x => x.RunStartTime_Month); - } + var (grouped, dateFormat) = BuildDateGrouping(joined, query, start, end); var flattened = grouped.SelectMany(x => x); - return Tuple.Create(flattened, grouped, dateFormat); + return new ProfileSubqueryResult + { + Flattened = flattened, + Grouped = grouped, + DateFormat = dateFormat, + }; } - private IQueryable ApplyReportFilters( + private static IQueryable ApplyReportFilters( IQueryable reports, - List server, - List database, - List masterFile, - List visible, - List certification, - List availability, - List reportType + ProfileQueryOptions query ) { - if (server.Count > 0) + if (query.Server.Count > 0) { - reports = reports.Where(x => server.Contains(x.SourceServer)); + reports = reports.Where(x => query.Server.Contains(x.SourceServer)); } - if (database.Count > 0) + if (query.Database.Count > 0) { - reports = reports.Where(x => database.Contains(x.SourceDb)); + reports = reports.Where(x => query.Database.Contains(x.SourceDb)); } - if (masterFile.Count > 0) + if (query.MasterFile.Count > 0) { reports = reports.Where(x => - masterFile.Contains(x.EpicMasterFile) - || (masterFile.Contains("None") && string.IsNullOrEmpty(x.EpicMasterFile)) + query.MasterFile.Contains(x.EpicMasterFile) + || (query.MasterFile.Contains("None") && string.IsNullOrEmpty(x.EpicMasterFile)) ); } - if (visible.Count > 0) + if (query.Visible.Count > 0) { reports = reports.Where(x => - visible.Contains(x.DefaultVisibilityYn) - || (visible.Contains("Y") && string.IsNullOrEmpty(x.DefaultVisibilityYn)) + query.Visible.Contains(x.DefaultVisibilityYn) + || (query.Visible.Contains("Y") && string.IsNullOrEmpty(x.DefaultVisibilityYn)) ); } - if (certification.Count > 0) + if (query.Certification.Count > 0) { reports = reports.Where(x => - certification.Intersect(x.ReportTagLinks.Select(y => y.Tag.Name)).Any() + query.Certification.Intersect(x.ReportTagLinks.Select(y => y.Tag.Name)).Any() ); } - if (availability.Count > 0) + if (query.Availability.Count > 0) { reports = reports.Where(x => - availability.Contains(x.Availability) - || (availability.Contains("Public") && string.IsNullOrEmpty(x.Availability)) + query.Availability.Contains(x.Availability) + || (query.Availability.Contains("Public") && string.IsNullOrEmpty(x.Availability)) ); } - if (reportType.Count > 0) + if (query.ReportType.Count > 0) { - reports = reports.Where(x => reportType.Contains((int)x.ReportObjectTypeId)); + reports = reports.Where(x => query.ReportType.Contains((int)x.ReportObjectTypeId)); } return reports; @@ -707,7 +486,11 @@ List reportType private bool IsUserProfileEnabled() { return _config["features:enable_user_profile"] == null - || _config["features:enable_user_profile"].ToLower() == "true"; + || string.Equals( + _config["features:enable_user_profile"], + bool.TrueString, + StringComparison.OrdinalIgnoreCase + ); } private static InvalidOperationException InvalidType(string type, int id) @@ -716,4 +499,188 @@ private static InvalidOperationException InvalidType(string type, int id) "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(), + ReportType = 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" + ); + } } From 3f46d81a8d51860a56f8b36da01945080b8bb601 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Fri, 8 May 2026 19:43:31 +0300 Subject: [PATCH 24/32] ref: Rename ProfileQueryOptions.ReportType to ReportTypes to fix Codacy shadowing warning --- web/Services/Profile/ProfileApiService.cs | 101 ++++++++++++---------- 1 file changed, 56 insertions(+), 45 deletions(-) diff --git a/web/Services/Profile/ProfileApiService.cs b/web/Services/Profile/ProfileApiService.cs index ec4d1e04..fb45565a 100644 --- a/web/Services/Profile/ProfileApiService.cs +++ b/web/Services/Profile/ProfileApiService.cs @@ -80,7 +80,7 @@ private sealed class ProfileQueryOptions public List Visible { get; init; } = new(); public List Certification { get; init; } = new(); public List Availability { get; init; } = new(); - public List ReportType { get; init; } = new(); + public List ReportTypes { get; init; } = new(); } private sealed class ProfileSubqueryResult @@ -310,42 +310,15 @@ public async Task> GetStarsAsync( CancellationToken cancellationToken ) { - return type switch - { - "report" when await _context.ReportObjects.AnyAsync(x => x.ReportObjectId == id, cancellationToken) => - await _context.Users.Where(x => x.StarredReports.Any(r => r.Reportid == id)) - .Select(x => new ProfileStarUserDto - { - Id = x.UserId, - FullName = x.FullnameCalc, - Email = x.Email, - }) - .AsNoTracking() - .ToListAsync(cancellationToken), - "term" when await _context.Terms.AnyAsync(x => x.TermId == id, cancellationToken) => - await _context.Users.Where(x => x.StarredTerms.Any(r => r.Termid == id)) - .Select(x => new ProfileStarUserDto - { - Id = x.UserId, - FullName = x.FullnameCalc, - Email = x.Email, - }) - .AsNoTracking() - .ToListAsync(cancellationToken), - "collection" when await _context.Collections.AnyAsync(x => x.CollectionId == id, cancellationToken) => - await _context.Users.Where(x => x.StarredCollections.Any(r => r.Collectionid == id)) - .Select(x => new ProfileStarUserDto - { - Id = x.UserId, - FullName = x.FullnameCalc, - Email = x.Email, - }) - .AsNoTracking() - .ToListAsync(cancellationToken), - _ => throw new InvalidOperationException( - "Wrong parameter value supplied. Type: " + type + " with Id: " + id - ), - }; + 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( @@ -359,9 +332,7 @@ CancellationToken cancellationToken || !await _context.ReportObjects.AnyAsync(x => x.ReportObjectId == id, cancellationToken) ) { - throw new InvalidOperationException( - "Wrong parameter value supplied. Type: " + type + " with Id: " + id - ); + throw InvalidType(type, id); } return await _context.ReportObjectSubscriptions.Where(r => r.ReportObjectId == id) @@ -386,8 +357,9 @@ private async Task BuildSubqueriesAsync( CancellationToken cancellationToken ) { - var start = DateTime.Now.AddSeconds(query.StartAt); - var end = DateTime.Now.AddSeconds(query.EndAt); + 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(); @@ -429,6 +401,45 @@ join r in reports on b.ReportObjectId equals r.ReportObjectId }; } + 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 @@ -475,9 +486,9 @@ ProfileQueryOptions query ); } - if (query.ReportType.Count > 0) + if (query.ReportTypes.Count > 0) { - reports = reports.Where(x => query.ReportType.Contains((int)x.ReportObjectTypeId)); + reports = reports.Where(x => query.ReportTypes.Contains((int)x.ReportObjectTypeId)); } return reports; @@ -514,7 +525,7 @@ private static ProfileQueryOptions ToQueryOptions(ProfileQueryRequestDto request Visible = request.Visible ?? new List(), Certification = request.Certification ?? new List(), Availability = request.Availability ?? new List(), - ReportType = request.ReportType ?? new List(), + ReportTypes = request.ReportType ?? new List(), }; } From ee2e6de3c9959403d9986567548a70f62012694b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 02:07:45 +0000 Subject: [PATCH 25/32] chore(deps) Update dependency BrowserStackLocal to v3 --- web.Tests/web.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web.Tests/web.Tests.csproj b/web.Tests/web.Tests.csproj index a5c33454..b3207a6a 100644 --- a/web.Tests/web.Tests.csproj +++ b/web.Tests/web.Tests.csproj @@ -10,7 +10,7 @@ true - + From 293e856ed914da3372175f4a58a40b3719d975a2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 02:07:49 +0000 Subject: [PATCH 26/32] chore(deps) Update dependency coverlet.collector to v8 --- web.Tests/web.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web.Tests/web.Tests.csproj b/web.Tests/web.Tests.csproj index a5c33454..c7543db9 100644 --- a/web.Tests/web.Tests.csproj +++ b/web.Tests/web.Tests.csproj @@ -29,7 +29,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all From c31dcfb687308adff441ef270a18d7738c87b55f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 02:08:02 +0000 Subject: [PATCH 27/32] chore(deps) Update dependency node to v22 --- .github/workflows/lighthouse.yml | 2 +- .github/workflows/release.yaml | 2 +- .github/workflows/test.yaml | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 5afdceb6..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: '18.x' + node-version: '22.x' - name: install node deps run: npm install - name: node build 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/test.yaml b/.github/workflows/test.yaml index e94cb736..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: @@ -218,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: From 322138bab149c8f39f5bd922c6666504ab7f3434 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 02:08:10 +0000 Subject: [PATCH 28/32] chore(deps) Update dependency Slugify.Core to v5 --- web/web.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/web.csproj b/web/web.csproj index 58fc5d50..fc3e84f0 100644 --- a/web/web.csproj +++ b/web/web.csproj @@ -63,7 +63,7 @@ - + From 49b59e094da88a357a22e0aaf76152e1c2114360 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Tue, 19 May 2026 22:03:45 +0300 Subject: [PATCH 29/32] chore: add demo admin local auth setup --- docker-compose.yml | 1 + .../Authorization/DemoAuthHandler.Tests.cs | 45 ++++++++++++ .../Controllers/AuthApiController.Tests.cs | 72 +++++++++++++++++++ web/Authorization/DemoAuthHandler.cs | 14 ++-- web/Controllers/Api/AuthApiController.cs | 11 ++- web/Program.cs | 2 +- web/ProgramConfiguration.cs | 5 +- 7 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 web.Tests/FunctionTests/Authorization/DemoAuthHandler.Tests.cs create mode 100644 web.Tests/FunctionTests/Controllers/AuthApiController.Tests.cs diff --git a/docker-compose.yml b/docker-compose.yml index 50628a37..79426848 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,6 +68,7 @@ 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} 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/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/Controllers/Api/AuthApiController.cs b/web/Controllers/Api/AuthApiController.cs index 00f79112..e1f34c7a 100644 --- a/web/Controllers/Api/AuthApiController.cs +++ b/web/Controllers/Api/AuthApiController.cs @@ -32,7 +32,6 @@ public AuthApiController(JwtTokenService jwt, Atlas_WebContext context, IConfigu public async Task Login([FromQuery] string? returnUrl = null) #pragma warning restore CS8632 { - var user = await _context.Users.FirstOrDefaultAsync(x => x.Username == "Default"); var safeReturnUrlResult = GetSafeRedirectUrl(returnUrl); if (safeReturnUrlResult is BadRequestObjectResult) { @@ -43,9 +42,17 @@ public async Task Login([FromQuery] string? returnUrl = null) 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 not found."); + return NotFound($"Demo user '{selectedDemoUsername}' not found."); } var demoToken = _jwt.IssueToken( diff --git a/web/Program.cs b/web/Program.cs index 4f4d0349..6c10b2b4 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -355,7 +355,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 index d4581042..a441a08e 100644 --- a/web/ProgramConfiguration.cs +++ b/web/ProgramConfiguration.cs @@ -89,7 +89,10 @@ private static void ConfigureDemoAuthentication( #pragma warning disable S1116 builder .Services.AddAuthentication(options => options.DefaultScheme = "Demo") - .AddScheme("Demo", options => { }) + .AddScheme("Demo", options => + { + options.Username = builder.Configuration["DEMO_ADMIN_USERNAME"] ?? "Default"; + }) .AddJwtBearer("Bearer", options => { options.TokenValidationParameters = new TokenValidationParameters From 456c491cb1a81b8e25d57738e4a109ab099aafe0 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Tue, 19 May 2026 22:12:17 +0300 Subject: [PATCH 30/32] fix: restrict content security policy frame ancestors --- web/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/Program.cs b/web/Program.cs index 6c10b2b4..87e84099 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -355,7 +355,7 @@ var signingCertificate in entityDescriptor.IdPSsoDescriptor.SigningCertificates app.Use( async (context, next) => { - context.Response.Headers["Content-Security-Policy"] = "frame-ancestors 'self' *;"; + context.Response.Headers["Content-Security-Policy"] = "frame-ancestors 'self';"; await next(); } ); From 17ac5260ae0ff99ddf4a40c378ea467b2aaad239 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Thu, 21 May 2026 23:08:15 +0300 Subject: [PATCH 31/32] Add users API with workspace and Razor parity support --- .../Controllers/UsersApiController.Tests.cs | 635 ++++++++++++++ web/Contracts/Api/Users/UserDtos.cs | 311 +++++++ web/Controllers/Api/UsersApiController.cs | 348 ++++++++ web/Program.cs | 1 + web/Services/Users/UsersApiService.Reads.cs | 829 ++++++++++++++++++ .../Users/UsersApiService.Workspace.cs | 570 ++++++++++++ web/Services/Users/UsersApiService.cs | 252 ++++++ 7 files changed, 2946 insertions(+) create mode 100644 web.Tests/FunctionTests/Controllers/UsersApiController.Tests.cs create mode 100644 web/Contracts/Api/Users/UserDtos.cs create mode 100644 web/Controllers/Api/UsersApiController.cs create mode 100644 web/Services/Users/UsersApiService.Reads.cs create mode 100644 web/Services/Users/UsersApiService.Workspace.cs create mode 100644 web/Services/Users/UsersApiService.cs 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/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/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/Program.cs b/web/Program.cs index 87e84099..b88d5967 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -208,6 +208,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); ProgramConfiguration.ConfigureJwtAuthentication(builder); diff --git a/web/Services/Users/UsersApiService.Reads.cs b/web/Services/Users/UsersApiService.Reads.cs new file mode 100644 index 00000000..4d235399 --- /dev/null +++ b/web/Services/Users/UsersApiService.Reads.cs @@ -0,0 +1,829 @@ +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.ToLower() 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, cancellationToken); + 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, + CancellationToken cancellationToken + ) + { + 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..31436a6f --- /dev/null +++ b/web/Services/Users/UsersApiService.Workspace.cs @@ -0,0 +1,570 @@ +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; + } + } + + 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; + } +} From e889b0f6427c848e85135cdb445a336d67b0b5e8 Mon Sep 17 00:00:00 2001 From: Semahegn Date: Thu, 21 May 2026 23:16:39 +0300 Subject: [PATCH 32/32] fix: address Codacy findings in users API services --- web/Services/Users/UsersApiService.Reads.cs | 7 +++---- web/Services/Users/UsersApiService.Workspace.cs | 2 ++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/web/Services/Users/UsersApiService.Reads.cs b/web/Services/Users/UsersApiService.Reads.cs index 4d235399..74cc0667 100644 --- a/web/Services/Users/UsersApiService.Reads.cs +++ b/web/Services/Users/UsersApiService.Reads.cs @@ -348,7 +348,7 @@ CancellationToken cancellationToken private static string ToHistoryType(string path) { - return path.ToLower() switch + return path.ToLowerInvariant() switch { "/reports" => "Reports", "/terms" => "Terms", @@ -414,7 +414,7 @@ CancellationToken cancellationToken foreach (var star in stars) { var report = star.Report; - var canRun = await CanRunFavoriteReportAsync(user, report, cancellationToken); + 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); @@ -719,8 +719,7 @@ CancellationToken cancellationToken private async Task CanRunFavoriteReportAsync( ClaimsPrincipal user, - ReportObject report, - CancellationToken cancellationToken + ReportObject report ) { if (_authorizationService == null) diff --git a/web/Services/Users/UsersApiService.Workspace.cs b/web/Services/Users/UsersApiService.Workspace.cs index 31436a6f..1903b293 100644 --- a/web/Services/Users/UsersApiService.Workspace.cs +++ b/web/Services/Users/UsersApiService.Workspace.cs @@ -192,6 +192,8 @@ CancellationToken cancellationToken case "search": await SetFavoriteRankAsync(_context.StarredSearches, workspaceUserId, favoriteId, item.FavoriteRank, cancellationToken); break; + default: + continue; } }