diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 9a8c5c1..325a52a 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -4,6 +4,10 @@ on: branches: [ main ] push: branches: [ main ] + paths: + - "src/**" + - "tests/**" + - ".github/**" jobs: test-and-build: diff --git a/Directory.Build.props b/Directory.Build.props index e5753e4..ca0a69b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -15,7 +15,7 @@ - net9.0 + net10.0 enable enable true diff --git a/Directory.Packages.props b/Directory.Packages.props index 1435e8a..338da01 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,17 +5,17 @@ - + - + - + @@ -76,13 +76,13 @@ - - - + + + - + diff --git a/src/Riber.Api/Common/Api/AppExtension.cs b/src/Riber.Api/Common/Api/AppExtension.cs deleted file mode 100644 index 6bac05d..0000000 --- a/src/Riber.Api/Common/Api/AppExtension.cs +++ /dev/null @@ -1,61 +0,0 @@ -using HealthChecks.UI.Client; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Riber.Api.Middlewares; -using Scalar.AspNetCore; - -namespace Riber.Api.Common.Api; - -public static class AppExtension -{ - public static void UsePipeline(this WebApplication app) - { - app.UseConfigurations(); - app.UseSecurity(); - app.UseMiddlewares(); - app.UseHealthChecks(); - app.MapControllers(); - } - - private static void UseSecurity(this WebApplication app) - { - app.UseAuthentication(); - app.UseAuthorization(); - } - - private static void UseMiddlewares(this WebApplication app) - { - app.UseMiddleware(); - } - - private static void UseConfigurations(this WebApplication app) - { - app.UseExceptionHandler(); - app.Use(async (context, next) => - { - context.Response.Headers.Append("X-Content-Type-Options", "nosniff"); - context.Response.Headers.Append("Referrer-Policy", "no-referrer"); - context.Response.Headers.Append("X-XSS-Protection", "1; mode=block"); - context.Response.Headers.Append("X-Frame-Options", "DENY"); - await next(); - }); - - if (app.Environment.IsDevelopment()) - { - app.MapOpenApi(); - app.MapScalarApiReference(); - } - - if (app.Environment.IsProduction()) - app.UseHttpsRedirection(); - - app.UseRequestTimeouts(); - } - - private static void UseHealthChecks(this WebApplication app) - { - app.MapHealthChecks("/", new HealthCheckOptions - { - ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse - }); - } -} \ No newline at end of file diff --git a/src/Riber.Api/Common/Api/BuilderExtension.cs b/src/Riber.Api/Common/Api/BuilderExtension.cs deleted file mode 100644 index 9bd4048..0000000 --- a/src/Riber.Api/Common/Api/BuilderExtension.cs +++ /dev/null @@ -1,215 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Asp.Versioning; -using Microsoft.AspNetCore.Authorization; -using Riber.Application; -using Riber.Infrastructure; -using Microsoft.AspNetCore.Http.Timeouts; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.IdentityModel.Tokens; -using Microsoft.OpenApi.Models; -using Riber.Api.Authorizations.Permissions; -using Riber.Api.Middlewares; -using Riber.Infrastructure.Persistence; -using Riber.Infrastructure.Persistence.Identity; -using Riber.Infrastructure.Settings; - -namespace Riber.Api.Common.Api; - -public static class BuilderExtension -{ - public static void AddPipeline(this WebApplicationBuilder builder) - { - builder.AddJsonConfiguration(); - builder.AddDocumentationApi(); - builder.AddDependencyInjection(); - builder.AddConfigurations(); - builder.AddMiddleware(); - builder.AddSecurity(); - } - - private static void AddDependencyInjection(this WebApplicationBuilder builder) - { - builder.Services.AddApplication(); - builder.Services.AddInfrastructure(builder.Configuration, builder.Logging); - } - - private static void AddMiddleware(this WebApplicationBuilder builder) - { - builder.Services.AddScoped(); - } - - private static void AddConfigurations(this WebApplicationBuilder builder) - { - builder.WebHost.ConfigureKestrel(options => - { - options.AddServerHeader = false; - options.ConfigureEndpointDefaults(endpoint - => endpoint.Protocols = HttpProtocols.Http1AndHttp2AndHttp3); - }); - - builder.Configuration.AddEnvironmentVariables(); - builder.Services.AddExceptionHandler(); - builder.Services.AddProblemDetails(); - builder.Services.AddMemoryCache(); - - builder.Services - .AddOptions() - .Bind(builder.Configuration.GetSection(nameof(AccessTokenSettings))) // <- Atribuí os valores - .ValidateDataAnnotations() // ⇽ Pega as regras de validação - .ValidateOnStart(); // ← Faz a validação, se não tiver válido, cancela a execução do programa - - builder.Services - .AddOptions() - .Bind(builder.Configuration.GetSection(nameof(RefreshTokenSettings))) - .ValidateDataAnnotations() - .ValidateOnStart(); - - builder.Services - .Configure(options - => options.SuppressModelStateInvalidFilter = true) - .Configure(options - => options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles); - - // Para uso das políticas, consulte: docs/REQUEST-TIMEOUT.md - builder.Services.AddRequestTimeouts(options => - { - options.DefaultPolicy = new RequestTimeoutPolicy { Timeout = TimeSpan.FromMinutes(1) }; - options.AddPolicy("fast", TimeSpan.FromSeconds(15)); - options.AddPolicy("standard", TimeSpan.FromSeconds(30)); - options.AddPolicy("slow", TimeSpan.FromMinutes(1)); - options.AddPolicy("upload", TimeSpan.FromMinutes(5)); - }); - - builder.Services.AddApiVersioning(options => - { - // Define a versão padrão da API (1.0) - options.DefaultApiVersion = new ApiVersion(1, 0); - // Se o cliente não especificar a versão, assume a versão padrão - options.AssumeDefaultVersionWhenUnspecified = true; - // Define COMO o cliente pode especificar a versão - options.ApiVersionReader = ApiVersionReader.Combine( - new UrlSegmentApiVersionReader(), // via URL: /api/v1/company - new QueryStringApiVersionReader("version"), // via query: ?version=1.0 - new HeaderApiVersionReader("X-Version") // via header: X-Version: 1.0 - ); - }).AddApiExplorer(setup => - { - setup.GroupNameFormat = "'v'VVV"; - setup.SubstituteApiVersionInUrl = true; - }); - } - - private static void AddSecurity(this WebApplicationBuilder builder) - { - var accessToken = - builder.Configuration.GetSection(nameof(AccessTokenSettings)).Get() - ?? throw new InvalidOperationException($"{nameof(AccessTokenSettings)} configuration not found"); - - var refreshToken = - builder.Configuration.GetSection(nameof(RefreshTokenSettings)).Get() - ?? throw new InvalidOperationException($"{nameof(RefreshTokenSettings)} configuration not found"); - - builder.Services.AddIdentity() - .AddEntityFrameworkStores() - .AddDefaultTokenProviders(); - - builder.Services - .AddAuthentication(options => - { - options.DefaultAuthenticateScheme = nameof(AccessTokenSettings); - options.DefaultChallengeScheme = nameof(AccessTokenSettings); - }) - .AddJwtBearer(nameof(AccessTokenSettings), options - => options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(accessToken.SecretKey)), - ValidateIssuer = true, - ValidIssuer = accessToken.Issuer, - ValidateAudience = true, - ValidAudience = accessToken.Audience, - ValidateLifetime = true, // ← Valida expiração - ClockSkew = TimeSpan.Zero, // ← Remove tolerância de tempo - RequireExpirationTime = true // ← Exige claim 'exp' - } - ) - .AddJwtBearer(nameof(RefreshTokenSettings), options - => options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(refreshToken.SecretKey)), - ValidateIssuer = true, - ValidIssuer = refreshToken.Issuer, - ValidateAudience = true, - ValidAudience = refreshToken.Audience, - ValidateLifetime = true, - ClockSkew = TimeSpan.Zero, - RequireExpirationTime = true - }); - - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddAuthorization(); - } - - private static void AddDocumentationApi(this WebApplicationBuilder builder) - { - builder.Services.AddOpenApi(options - => options.AddDocumentTransformer((document, _, _) => - { - document.Info = new() - { - Title = "Riber Documentation API", Version = "v1", Description = "API do Riber" - }; - - document.Components ??= new(); - document.Components.SecuritySchemes = new Dictionary - { - ["Bearer"] = new() - { - Type = SecuritySchemeType.Http, - Scheme = "bearer", - BearerFormat = "JWT", - Description = "Insert your JWT Token here" - } - }; - - document.SecurityRequirements = - [ - new OpenApiSecurityRequirement - { - [new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } }] = - Array.Empty() - } - ]; - - return Task.CompletedTask; - })); - } - - private static void AddJsonConfiguration(this WebApplicationBuilder builder) - { - builder.Services.ConfigureHttpJsonOptions(options => - { - options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - options.SerializerOptions.PropertyNameCaseInsensitive = true; - options.SerializerOptions.ReadCommentHandling = JsonCommentHandling.Skip; - options.SerializerOptions.AllowTrailingCommas = true; - options.SerializerOptions.WriteIndented = true; - options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.Never; - }); - - builder.Services - .AddControllers() - .AddJsonOptions(options => - { - options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; - options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.Never; - }); - } -} \ No newline at end of file diff --git a/src/Riber.Api/Common/AppExtension.cs b/src/Riber.Api/Common/AppExtension.cs new file mode 100644 index 0000000..24b1f8d --- /dev/null +++ b/src/Riber.Api/Common/AppExtension.cs @@ -0,0 +1,64 @@ +using HealthChecks.UI.Client; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Riber.Api.Middlewares; +using Scalar.AspNetCore; + +namespace Riber.Api.Common; + +internal static class AppExtension +{ + extension(WebApplication app) + { + public void UsePipeline() + { + app.UseConfigurations(); + app.UseSecurity(); + app.UseMiddlewares(); + app.UseHealthChecks(); + app.MapControllers(); + } + + private void UseSecurity() + { + app.UseAuthentication(); + app.UseAuthorization(); + } + + private void UseMiddlewares() + { + app.UseMiddleware(); + } + + private void UseConfigurations() + { + app.UseExceptionHandler(); + app.Use(async (context, next) => + { + context.Response.Headers.Append("X-Content-Type-Options", "nosniff"); + context.Response.Headers.Append("Referrer-Policy", "no-referrer"); + context.Response.Headers.Append("X-XSS-Protection", "1; mode=block"); + context.Response.Headers.Append("X-Frame-Options", "DENY"); + await next(); + }); + + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + app.MapScalarApiReference(); + } + + if (app.Environment.IsProduction()) + app.UseHttpsRedirection(); + + app.UseRequestTimeouts(); + } + + private void UseHealthChecks() + { + app.MapHealthChecks("/", new HealthCheckOptions + { + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse + }); + } + } +} \ No newline at end of file diff --git a/src/Riber.Api/Common/BuilderExtension.cs b/src/Riber.Api/Common/BuilderExtension.cs new file mode 100644 index 0000000..31fda96 --- /dev/null +++ b/src/Riber.Api/Common/BuilderExtension.cs @@ -0,0 +1,189 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.Timeouts; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.IdentityModel.Tokens; +using Riber.Api.Authorizations.Permissions; +using Riber.Api.Common.Config; +using Riber.Api.Common.Transformers; +using Riber.Api.Middlewares; +using Riber.Application; +using Riber.Infrastructure; +using Riber.Infrastructure.Persistence; +using Riber.Infrastructure.Persistence.Identity; +using Riber.Infrastructure.Settings; + +namespace Riber.Api.Common; + +internal static class BuilderExtension +{ + extension(WebApplicationBuilder builder) + { + public void AddPipeline() + { + builder.AddJsonConfiguration(); + builder.AddDocumentationApi(); + builder.AddDependencyInjection(); + builder.AddConfigurations(); + builder.AddMiddleware(); + builder.AddSecurity(); + } + + private void AddDependencyInjection() + { + builder.Services.AddApplication(); + builder.Services.AddInfrastructure(builder.Configuration, builder.Logging); + } + + private void AddMiddleware() + { + builder.Services.AddScoped(); + } + + private void AddConfigurations() + { + builder.WebHost.ConfigureKestrel(options => + { + options.AddServerHeader = false; + options.ConfigureEndpointDefaults(endpoint + => endpoint.Protocols = HttpProtocols.Http1AndHttp2AndHttp3); + }); + + builder.Configuration.AddEnvironmentVariables(); + builder.Services.AddExceptionHandler(); + builder.Services.AddProblemDetails(); + builder.Services.AddMemoryCache(); + + builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection(nameof(AccessTokenSettings))) // <- Atribuí os valores + .ValidateDataAnnotations() // ⇽ Pega as regras de validação + .ValidateOnStart(); // ← Faz a validação, se não tiver válido, cancela a execução do programa + + builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection(nameof(RefreshTokenSettings))) + .ValidateDataAnnotations() + .ValidateOnStart(); + + builder.Services + .Configure(options + => options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles); + + // Para uso das políticas, consulte: docs/REQUEST-TIMEOUT.md + builder.Services.AddRequestTimeouts(options => + { + options.DefaultPolicy = new RequestTimeoutPolicy { Timeout = TimeSpan.FromMinutes(1) }; + options.AddPolicy("fast", TimeSpan.FromSeconds(15)); + options.AddPolicy("standard", TimeSpan.FromSeconds(30)); + options.AddPolicy("slow", TimeSpan.FromMinutes(1)); + options.AddPolicy("upload", TimeSpan.FromMinutes(5)); + }); + + builder.Services.AddApiVersioning(options => + { + // Define a versão padrão da API (1.0) + options.DefaultApiVersion = new ApiVersion(1, 0); + // Se o cliente não especificar a versão, assume a versão padrão + options.AssumeDefaultVersionWhenUnspecified = true; + // Define COMO o cliente pode especificar a versão + options.ApiVersionReader = ApiVersionReader.Combine( + new UrlSegmentApiVersionReader(), // via URL: /api/v1/company + new QueryStringApiVersionReader("version"), // via query: ?version=1.0 + new HeaderApiVersionReader("X-Version") // via header: X-Version: 1.0 + ); + }).AddApiExplorer(setup => + { + setup.GroupNameFormat = "'v'VVV"; + setup.SubstituteApiVersionInUrl = true; + }); + } + + private void AddSecurity() + { + var accessToken = + builder.Configuration.GetSection(nameof(AccessTokenSettings)).Get() + ?? throw new InvalidOperationException($"{nameof(AccessTokenSettings)} configuration not found"); + + var refreshToken = + builder.Configuration.GetSection(nameof(RefreshTokenSettings)).Get() + ?? throw new InvalidOperationException($"{nameof(RefreshTokenSettings)} configuration not found"); + + builder.Services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + builder.Services + .AddAuthentication(options => + { + options.DefaultAuthenticateScheme = nameof(AccessTokenSettings); + options.DefaultChallengeScheme = nameof(AccessTokenSettings); + }) + .AddJwtBearer(nameof(AccessTokenSettings), options + => options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(accessToken.SecretKey)), + ValidateIssuer = true, + ValidIssuer = accessToken.Issuer, + ValidateAudience = true, + ValidAudience = accessToken.Audience, + ValidateLifetime = true, // ← Valida expiração + ClockSkew = TimeSpan.Zero, // ← Remove tolerância de tempo + RequireExpirationTime = true // ← Exige claim 'exp' + } + ) + .AddJwtBearer(nameof(RefreshTokenSettings), options + => options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(refreshToken.SecretKey)), + ValidateIssuer = true, + ValidIssuer = refreshToken.Issuer, + ValidateAudience = true, + ValidAudience = refreshToken.Audience, + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero, + RequireExpirationTime = true + }); + + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddAuthorization(); + } + + private void AddDocumentationApi() + { + builder.Services.AddOpenApi(options + => options.AddDocumentTransformer()); + } + + private void AddJsonConfiguration() + { + builder.Services.ConfigureHttpJsonOptions(options => + { + options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.SerializerOptions.PropertyNameCaseInsensitive = true; + options.SerializerOptions.ReadCommentHandling = JsonCommentHandling.Skip; + options.SerializerOptions.AllowTrailingCommas = true; + options.SerializerOptions.WriteIndented = true; + options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.Never; + }); + + builder.Services + .AddControllers() + .ConfigureInvalidModelStateResponse() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; + options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.Never; + }); + } + } +} \ No newline at end of file diff --git a/src/Riber.Api/Common/Config/ApiBehavior.cs b/src/Riber.Api/Common/Config/ApiBehavior.cs new file mode 100644 index 0000000..a082b3d --- /dev/null +++ b/src/Riber.Api/Common/Config/ApiBehavior.cs @@ -0,0 +1,39 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; +using Riber.Application.Common; +using EmptyResult = Microsoft.AspNetCore.Mvc.EmptyResult; + +namespace Riber.Api.Common.Config; + +internal static class ApiBehavior +{ + extension(IMvcBuilder builder) + { + public IMvcBuilder ConfigureInvalidModelStateResponse() + { + return builder.ConfigureApiBehaviorOptions(options => + options.InvalidModelStateResponseFactory = CreateInvalidModelStateResponse); + } + } + + private static IActionResult CreateInvalidModelStateResponse(ActionContext context) + { + var errors = context.ModelState + .Where(e => e.Value?.Errors.Count > 0) + .ToDictionary( + kvp => kvp.Key.Replace("$.", "").ToLower(), + kvp => kvp.Value!.Errors + .Select(e => e.ErrorMessage) + .ToArray() + ); + + errors.Remove("command", out var command); + var result = Result.Failure( + command?[0] ?? "Dados de entrada inválidos.", + HttpStatusCode.BadRequest, + errors + ); + + return new BadRequestObjectResult(result); + } +} \ No newline at end of file diff --git a/src/Riber.Api/Common/Api/GlobalExceptionHandler.cs b/src/Riber.Api/Common/Config/GlobalExceptionHandler.cs similarity index 75% rename from src/Riber.Api/Common/Api/GlobalExceptionHandler.cs rename to src/Riber.Api/Common/Config/GlobalExceptionHandler.cs index 5dfa7fe..862c0c1 100644 --- a/src/Riber.Api/Common/Api/GlobalExceptionHandler.cs +++ b/src/Riber.Api/Common/Config/GlobalExceptionHandler.cs @@ -1,17 +1,16 @@ -using Microsoft.AspNetCore.Diagnostics; +using System.Net; +using Microsoft.AspNetCore.Diagnostics; using Riber.Api.Extensions; -using Riber.Application.Exceptions; using Riber.Domain.Exceptions; -using System.Net; using Layer = Riber.Application.Exceptions; -namespace Riber.Api.Common.Api; +namespace Riber.Api.Common.Config; /// /// Trata exceções globais na aplicação registrando erros e formatando /// uma resposta JSON padronizada para o cliente. /// -public sealed class GlobalExceptionHandler( +internal sealed class GlobalExceptionHandler( ILogger logger) : IExceptionHandler { @@ -31,9 +30,7 @@ public async ValueTask TryHandleAsync( private static (HttpStatusCode, string, Dictionary?) MapExceptionToError(Exception exception) => exception switch { - RequestTimeoutException timeoutEx => (timeoutEx.Code, timeoutEx.Message, null), - ValidationException validationEx => (HttpStatusCode.BadRequest, "Dados Inválidos.", validationEx.Details), - Layer.ApplicationException applicationEx => (applicationEx.Code, applicationEx.Message, null), + Layer.ApplicationException applicationEx => (applicationEx.Code, applicationEx.Message, applicationEx.Details), DomainException domainEx => (HttpStatusCode.UnprocessableContent, domainEx.Message, null), _ => (HttpStatusCode.InternalServerError, "Erro inesperado no servidor.", null) }; diff --git a/src/Riber.Api/Common/Logs/.gitkeep b/src/Riber.Api/Common/Logs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Riber.Api/Common/Transformers/BearerSecuritySchemeTransformer.cs b/src/Riber.Api/Common/Transformers/BearerSecuritySchemeTransformer.cs new file mode 100644 index 0000000..60ac568 --- /dev/null +++ b/src/Riber.Api/Common/Transformers/BearerSecuritySchemeTransformer.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Riber.Api.Common.Transformers; + +internal class BearerSecuritySchemeTransformer + : IOpenApiDocumentTransformer +{ + public Task TransformAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + CancellationToken cancellationToken) + { + document.Info = new() { Title = "Riber Documentation API", Version = "v1", Description = "API do Riber" }; + + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes = new Dictionary + { + ["Bearer"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "Insert your JWT Token here", + Name = "Authorization" + } + }; + document.Security = + [ + new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference("Bearer", document)] = [] + } + ]; + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Riber.Api/Extensions/HttpContextExtension.cs b/src/Riber.Api/Extensions/HttpContextExtension.cs index 49cf05d..6e004c1 100644 --- a/src/Riber.Api/Extensions/HttpContextExtension.cs +++ b/src/Riber.Api/Extensions/HttpContextExtension.cs @@ -20,7 +20,7 @@ public static async Task WriteErrorResponse( Dictionary? details = null, CancellationToken cancellationToken = default) { - var response = Result.Failure(message, code, details); + var response = Result.Failure(message, code, details); var jsonResponse = JsonSerializer.Serialize(response, JsonOptions); context.Response.StatusCode = (int)code; diff --git a/src/Riber.Api/Program.cs b/src/Riber.Api/Program.cs index d5484c1..fc51327 100644 --- a/src/Riber.Api/Program.cs +++ b/src/Riber.Api/Program.cs @@ -1,5 +1,5 @@ using Microsoft.EntityFrameworkCore; -using Riber.Api.Common.Api; +using Riber.Api.Common; using Riber.Infrastructure.Persistence; var builder = WebApplication.CreateBuilder(args); @@ -16,8 +16,11 @@ await app.RunAsync(); -/// -/// Ponto de entrada da aplicação Riber API. -/// Esta classe é utilizada como referência de tipo para testes de integração. -/// -public partial class Program { protected Program() { }} \ No newline at end of file +namespace Riber.Api +{ + /// + /// Ponto de entrada da aplicação Riber API. + /// Esta classe é utilizada como referência de tipo para testes de integração. + /// + public partial class Program { protected Program() { }} +} \ No newline at end of file diff --git a/src/Riber.Api/Properties/launchSettings.json b/src/Riber.Api/Properties/launchSettings.json index ccc929c..4310607 100644 --- a/src/Riber.Api/Properties/launchSettings.json +++ b/src/Riber.Api/Properties/launchSettings.json @@ -4,8 +4,9 @@ "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": false, + "launchBrowser": true, "applicationUrl": "http://localhost:5266", + "launchUrl": "scalar", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -13,7 +14,8 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": false, + "launchBrowser": true, + "launchUrl": "scalar", "applicationUrl": "https://localhost:7162;http://localhost:5266", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Riber.Api/Riber.Api.csproj b/src/Riber.Api/Riber.Api.csproj index 46f9971..f214135 100644 --- a/src/Riber.Api/Riber.Api.csproj +++ b/src/Riber.Api/Riber.Api.csproj @@ -2,17 +2,21 @@ e2e3dfd3-be66-444b-9252-3a3db2ea0e9c + + + + - - - - - + + + + + - - + + diff --git a/src/Riber.Application/Abstractions/Dispatchers/IDeleteImageFromStorageDispatcher.cs b/src/Riber.Application/Abstractions/Dispatchers/IDeleteImageFromStorageDispatcher.cs deleted file mode 100644 index b378b91..0000000 --- a/src/Riber.Application/Abstractions/Dispatchers/IDeleteImageFromStorageDispatcher.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Riber.Application.Abstractions.Dispatchers; - -/// -/// Dispatcher responsável por agendar a exclusão de imagens do storage em background. -/// -public interface IDeleteImageFromStorageDispatcher -{ - /// - /// Agenda a remoção de uma imagem do armazenamento em nuvem de forma assíncrona. - /// A operação é executada em background através de um job agendado. - /// - /// A chave única da imagem a ser removida do storage (ex: "12345.jpg") - /// Token para cancelamento da operação de agendamento - /// Uma task que completa quando o job de remoção foi agendado com sucesso - Task SendAsync(string imageKey, CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/src/Riber.Application/Behaviors/LoggingBehavior.cs b/src/Riber.Application/Behaviors/LoggingBehavior.cs index 690bbce..216259a 100644 --- a/src/Riber.Application/Behaviors/LoggingBehavior.cs +++ b/src/Riber.Application/Behaviors/LoggingBehavior.cs @@ -38,13 +38,12 @@ public async ValueTask Handle( return result; } - catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { stopwatch.Stop(); activity?.SetStatus(ActivityStatusCode.Error, "Request cancelled"); logger.LogWarning( - ex, "{RequestName} cancelled after {ElapsedSeconds:F2}s", messageName, stopwatch.Elapsed.TotalSeconds @@ -65,12 +64,6 @@ public async ValueTask Handle( if (!ex.Data.Contains("ElapsedMs")) ex.Data["ElapsedMs"] = stopwatch.ElapsedMilliseconds; - logger.LogError( - ex, - "{RequestName} failed after {ElapsedMs}ms", - messageName, - stopwatch.ElapsedMilliseconds - ); throw; } } diff --git a/src/Riber.Application/Exceptions/ApplicationException.cs b/src/Riber.Application/Exceptions/ApplicationException.cs index 1a8e85f..8837750 100644 --- a/src/Riber.Application/Exceptions/ApplicationException.cs +++ b/src/Riber.Application/Exceptions/ApplicationException.cs @@ -2,7 +2,12 @@ namespace Riber.Application.Exceptions; -public abstract class ApplicationException(string message, HttpStatusCode code) : Exception(message) +public abstract class ApplicationException( + string message, + HttpStatusCode code, + Dictionary? details = null) + : Exception(message) { + public Dictionary? Details => details; public HttpStatusCode Code => code; } \ No newline at end of file diff --git a/src/Riber.Application/Exceptions/ValidationException.cs b/src/Riber.Application/Exceptions/ValidationException.cs index 2e8a2b1..b6511c0 100644 --- a/src/Riber.Application/Exceptions/ValidationException.cs +++ b/src/Riber.Application/Exceptions/ValidationException.cs @@ -1,6 +1,6 @@ -namespace Riber.Application.Exceptions; +using System.Net; -public sealed class ValidationException(Dictionary details) : Exception -{ - public Dictionary Details => details; -} \ No newline at end of file +namespace Riber.Application.Exceptions; + +public sealed class ValidationException(Dictionary details) + : ApplicationException("Dados Inválidos.", HttpStatusCode.BadRequest, details); \ No newline at end of file diff --git a/src/Riber.Application/Riber.Application.csproj b/src/Riber.Application/Riber.Application.csproj index a6f565f..a69bf5b 100644 --- a/src/Riber.Application/Riber.Application.csproj +++ b/src/Riber.Application/Riber.Application.csproj @@ -1,7 +1,6 @@  - diff --git a/src/Riber.Domain/Abstractions/Error.cs b/src/Riber.Domain/Abstractions/Error.cs index c3d1f05..b4cbece 100644 --- a/src/Riber.Domain/Abstractions/Error.cs +++ b/src/Riber.Domain/Abstractions/Error.cs @@ -12,6 +12,9 @@ public sealed class Error public string Type { get; init; } = string.Empty; public string Message { get; init; } = string.Empty; + + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] public Dictionary? Details { get; init; } #endregion diff --git a/src/Riber.Infrastructure/DependencyInjection.cs b/src/Riber.Infrastructure/DependencyInjection.cs index 3a7ea73..40a34cd 100644 --- a/src/Riber.Infrastructure/DependencyInjection.cs +++ b/src/Riber.Infrastructure/DependencyInjection.cs @@ -69,30 +69,16 @@ public static void AddInfrastructure(this IServiceCollection services, IConfigur private static void AddLogging(this ILoggingBuilder logging) { - const string output = - "[{Timestamp:dd/MM/yyyy HH:mm:ss}] {Level:u3} | {SourceContext} | {Message:lj}{NewLine}{Exception}"; + const string logStructure = "{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level:u3}] {Message:lj}{NewLine}{Exception}"; Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() - .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) - .MinimumLevel.Override("System", LogEventLevel.Warning) - .Enrich.FromLogContext() - .WriteTo.Console(outputTemplate: output) - .WriteTo.Logger(lc => lc - .Filter.ByExcluding(evt => evt.Level >= LogEventLevel.Error) - .WriteTo.File( - path: "Common/Logs/app-.log", - outputTemplate: output, - rollingInterval: RollingInterval.Day, - restrictedToMinimumLevel: LogEventLevel.Information, - retainedFileCountLimit: 30)) - .WriteTo.File( - path: "Common/Logs/errors-.log", - outputTemplate: output, - rollingInterval: RollingInterval.Day, - restrictedToMinimumLevel: LogEventLevel.Error, - retainedFileCountLimit: 90) + .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) + .WriteTo.Console(outputTemplate: logStructure) .CreateLogger(); + logging.ClearProviders(); logging.AddSerilog(); } diff --git a/src/Riber.Infrastructure/Persistence/Factories/AppDbContextFactory.cs b/src/Riber.Infrastructure/Persistence/Factories/AppDbContextFactory.cs index 74e0933..1b45565 100644 --- a/src/Riber.Infrastructure/Persistence/Factories/AppDbContextFactory.cs +++ b/src/Riber.Infrastructure/Persistence/Factories/AppDbContextFactory.cs @@ -7,16 +7,15 @@ namespace Riber.Infrastructure.Persistence.Factories; public sealed class AppDbContextFactory : IDesignTimeDbContextFactory { + private const string AppSettingsPath = "../../../../Riber.Api/appsettings.json"; + public AppDbContext CreateDbContext(string[] args) { var configuration = new ConfigurationBuilder() - .SetBasePath(Path.Combine(Directory.GetCurrentDirectory(), "../Riber.Api")) - .AddJsonFile("appsettings.json", optional: false) - .AddEnvironmentVariables() + .AddJsonFile(Path.Combine(AppContext.BaseDirectory, AppSettingsPath), optional: false) .Build(); - var optionsBuilder = new DbContextOptionsBuilder(); - optionsBuilder + var optionsBuilder = new DbContextOptionsBuilder() .UseNpgsql( configuration.GetConnectionString("DefaultConnection"), b => @@ -26,7 +25,7 @@ public AppDbContext CreateDbContext(string[] args) .MigrationsHistoryTable("__EFMigrationsHistory"); }) .AddInterceptors(new CaseInsensitiveInterceptor(), new AuditInterceptor()) - .EnableSensitiveDataLogging() + .EnableDetailedErrors() .EnableServiceProviderCaching(); return new AppDbContext(optionsBuilder.Options); diff --git a/src/Riber.Infrastructure/Persistence/Migrations/20251104000902_AddProductEmbeddingsTable.Designer.cs b/src/Riber.Infrastructure/Persistence/Migrations/20251104000902_AddProductEmbeddingsTable.Designer.cs index 49faa21..11092d7 100644 --- a/src/Riber.Infrastructure/Persistence/Migrations/20251104000902_AddProductEmbeddingsTable.Designer.cs +++ b/src/Riber.Infrastructure/Persistence/Migrations/20251104000902_AddProductEmbeddingsTable.Designer.cs @@ -1395,7 +1395,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasName("pk_product_embeddings_id"); b.HasIndex("Embeddings") - .HasDatabaseName("ix_product_embeddings_vector") + .HasDatabaseName("ixembeddings_vector") .HasAnnotation("Npgsql:StorageParameter:ef_construction", 64) .HasAnnotation("Npgsql:StorageParameter:m", 16); diff --git a/src/Riber.Infrastructure/Persistence/Migrations/20251108175933_AddChatAndAssistantEntitiesTable.Designer.cs b/src/Riber.Infrastructure/Persistence/Migrations/20251108175933_AddChatAndAssistantEntitiesTable.Designer.cs index 45f178b..ceb470b 100644 --- a/src/Riber.Infrastructure/Persistence/Migrations/20251108175933_AddChatAndAssistantEntitiesTable.Designer.cs +++ b/src/Riber.Infrastructure/Persistence/Migrations/20251108175933_AddChatAndAssistantEntitiesTable.Designer.cs @@ -1517,7 +1517,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasName("pk_product_embeddings_id"); b.HasIndex("Embeddings") - .HasDatabaseName("ix_product_embeddings_vector") + .HasDatabaseName("ix_embeddings_vector") .HasAnnotation("Npgsql:StorageParameter:ef_construction", 64) .HasAnnotation("Npgsql:StorageParameter:m", 16); diff --git a/src/Riber.Infrastructure/Persistence/Migrations/20251109044954_AddProductEmbeddingsCompanyRelation.Designer.cs b/src/Riber.Infrastructure/Persistence/Migrations/20251109044954_AddProductEmbeddingsCompanyRelation.Designer.cs index bdbf6f4..91ca98c 100644 --- a/src/Riber.Infrastructure/Persistence/Migrations/20251109044954_AddProductEmbeddingsCompanyRelation.Designer.cs +++ b/src/Riber.Infrastructure/Persistence/Migrations/20251109044954_AddProductEmbeddingsCompanyRelation.Designer.cs @@ -1523,7 +1523,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("CompanyId"); b.HasIndex("Embeddings") - .HasDatabaseName("ix_product_embeddings_vector") + .HasDatabaseName("ix_embeddings_vector") .HasAnnotation("Npgsql:StorageParameter:ef_construction", 64) .HasAnnotation("Npgsql:StorageParameter:m", 16); diff --git a/src/Riber.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/Riber.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index ea35b39..1de03b3 100644 --- a/src/Riber.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Riber.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -1548,7 +1548,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b1.HasKey("ImageId"); - b1.ToTable("image"); + b1.ToTable("image", (string)null); b1.WithOwner() .HasForeignKey("ImageId"); @@ -1597,7 +1597,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b1.HasKey("ProductId"); - b1.ToTable("product"); + b1.ToTable("product", (string)null); b1.WithOwner() .HasForeignKey("ProductId"); @@ -1676,83 +1676,83 @@ protected override void BuildModel(ModelBuilder modelBuilder) b1.HasIndex(new[] { "Value" }, "uq_company_email") .IsUnique(); - b1.ToTable("company"); + b1.ToTable("company", (string)null); b1.WithOwner() .HasForeignKey("CompanyId"); }); - b.OwnsOne("Riber.Domain.ValueObjects.TaxId.TaxId", "TaxId", b1 => + b.OwnsOne("Riber.Domain.ValueObjects.CompanyName.CompanyName", "Name", b1 => { b1.Property("CompanyId") .HasColumnType("uuid"); - b1.Property("Type") + b1.Property("Corporate") .IsRequired() + .HasMaxLength(150) .HasColumnType("text") - .HasColumnName("tax_id_type"); + .HasColumnName("corporate_name"); - b1.Property("Value") + b1.Property("Fantasy") .IsRequired() - .HasMaxLength(14) + .HasMaxLength(100) .HasColumnType("text") - .HasColumnName("tax_id_value"); + .HasColumnName("fantasy_name"); b1.HasKey("CompanyId"); - b1.HasIndex(new[] { "Value" }, "uq_company_tax_id") - .IsUnique(); + b1.HasIndex(new[] { "Corporate" }, "uq_company_corporate_name"); - b1.ToTable("company"); + b1.ToTable("company", (string)null); b1.WithOwner() .HasForeignKey("CompanyId"); }); - b.OwnsOne("Riber.Domain.ValueObjects.CompanyName.CompanyName", "Name", b1 => + b.OwnsOne("Riber.Domain.ValueObjects.Phone.Phone", "Phone", b1 => { b1.Property("CompanyId") .HasColumnType("uuid"); - b1.Property("Corporate") - .IsRequired() - .HasMaxLength(150) - .HasColumnType("text") - .HasColumnName("corporate_name"); - - b1.Property("Fantasy") + b1.Property("Value") .IsRequired() - .HasMaxLength(100) + .HasMaxLength(15) .HasColumnType("text") - .HasColumnName("fantasy_name"); + .HasColumnName("phone"); b1.HasKey("CompanyId"); - b1.HasIndex(new[] { "Corporate" }, "uq_company_corporate_name"); + b1.HasIndex(new[] { "Value" }, "uq_company_phone") + .IsUnique(); - b1.ToTable("company"); + b1.ToTable("company", (string)null); b1.WithOwner() .HasForeignKey("CompanyId"); }); - b.OwnsOne("Riber.Domain.ValueObjects.Phone.Phone", "Phone", b1 => + b.OwnsOne("Riber.Domain.ValueObjects.TaxId.TaxId", "TaxId", b1 => { b1.Property("CompanyId") .HasColumnType("uuid"); + b1.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tax_id_type"); + b1.Property("Value") .IsRequired() - .HasMaxLength(15) + .HasMaxLength(14) .HasColumnType("text") - .HasColumnName("phone"); + .HasColumnName("tax_id_value"); b1.HasKey("CompanyId"); - b1.HasIndex(new[] { "Value" }, "uq_company_phone") + b1.HasIndex(new[] { "Value" }, "uq_company_tax_id") .IsUnique(); - b1.ToTable("company"); + b1.ToTable("company", (string)null); b1.WithOwner() .HasForeignKey("CompanyId"); @@ -1803,7 +1803,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b1.HasIndex(new[] { "Value" }, "uq_order_order_token") .IsUnique(); - b1.ToTable("order"); + b1.ToTable("order", (string)null); b1.WithOwner() .HasForeignKey("OrderId"); @@ -1833,29 +1833,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasConstraintName("fk_order_item_product_id"); - b.OwnsOne("Riber.Domain.ValueObjects.Money.Money", "UnitPrice", b1 => - { - b1.Property("OrderItemId") - .HasColumnType("uuid"); - - b1.Property("Currency") - .IsRequired() - .HasMaxLength(3) - .HasColumnType("text") - .HasColumnName("unit_price_currency"); - - b1.Property("Value") - .HasColumnType("numeric") - .HasColumnName("unit_price"); - - b1.HasKey("OrderItemId"); - - b1.ToTable("order_item"); - - b1.WithOwner() - .HasForeignKey("OrderItemId"); - }); - b.OwnsOne("Riber.Domain.ValueObjects.Discount.Discount", "ItemDiscount", b1 => { b1.Property("OrderItemId") @@ -1880,7 +1857,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b1.HasKey("OrderItemId"); - b1.ToTable("order_item"); + b1.ToTable("order_item", (string)null); b1.WithOwner() .HasForeignKey("OrderItemId"); @@ -1897,7 +1874,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) b1.HasKey("OrderItemId"); - b1.ToTable("order_item"); + b1.ToTable("order_item", (string)null); + + b1.WithOwner() + .HasForeignKey("OrderItemId"); + }); + + b.OwnsOne("Riber.Domain.ValueObjects.Money.Money", "UnitPrice", b1 => + { + b1.Property("OrderItemId") + .HasColumnType("uuid"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("text") + .HasColumnName("unit_price_currency"); + + b1.Property("Value") + .HasColumnType("numeric") + .HasColumnName("unit_price"); + + b1.HasKey("OrderItemId"); + + b1.ToTable("order_item", (string)null); b1.WithOwner() .HasForeignKey("OrderItemId"); @@ -1934,7 +1934,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b1.HasIndex(new[] { "Value" }, "uq_invitations_email") .IsUnique(); - b1.ToTable("invitation"); + b1.ToTable("invitation", (string)null); b1.WithOwner() .HasForeignKey("InvitationId"); @@ -1956,7 +1956,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b1.HasIndex(new[] { "Value" }, "uq_invitation_invite_token") .IsUnique(); - b1.ToTable("invitation"); + b1.ToTable("invitation", (string)null); b1.WithOwner() .HasForeignKey("InvitationId"); @@ -1977,47 +1977,47 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.SetNull) .HasConstraintName("fk_user_company_id"); - b.OwnsOne("Riber.Domain.ValueObjects.TaxId.TaxId", "TaxId", b1 => + b.OwnsOne("Riber.Domain.ValueObjects.FullName.FullName", "FullName", b1 => { b1.Property("UserId") .HasColumnType("uuid"); - b1.Property("Type") - .IsRequired() - .HasColumnType("text") - .HasColumnName("tax_id_type"); - b1.Property("Value") .IsRequired() - .HasMaxLength(14) + .HasMaxLength(255) .HasColumnType("text") - .HasColumnName("tax_id_value"); + .HasColumnName("full_name"); b1.HasKey("UserId"); - b1.HasIndex(new[] { "Value" }, "uq_user_tax_id") - .IsUnique(); - - b1.ToTable("user"); + b1.ToTable("user", (string)null); b1.WithOwner() .HasForeignKey("UserId"); }); - b.OwnsOne("Riber.Domain.ValueObjects.FullName.FullName", "FullName", b1 => + b.OwnsOne("Riber.Domain.ValueObjects.TaxId.TaxId", "TaxId", b1 => { b1.Property("UserId") .HasColumnType("uuid"); + b1.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tax_id_type"); + b1.Property("Value") .IsRequired() - .HasMaxLength(255) + .HasMaxLength(14) .HasColumnType("text") - .HasColumnName("full_name"); + .HasColumnName("tax_id_value"); b1.HasKey("UserId"); - b1.ToTable("user"); + b1.HasIndex(new[] { "Value" }, "uq_user_tax_id") + .IsUnique(); + + b1.ToTable("user", (string)null); b1.WithOwner() .HasForeignKey("UserId"); diff --git a/src/Riber.Infrastructure/Persistence/Seeders/ApplicationRoleSeeder.cs b/src/Riber.Infrastructure/Persistence/Seeders/ApplicationRoleSeeder.cs index 8b9fcf8..b8d11dc 100644 --- a/src/Riber.Infrastructure/Persistence/Seeders/ApplicationRoleSeeder.cs +++ b/src/Riber.Infrastructure/Persistence/Seeders/ApplicationRoleSeeder.cs @@ -13,25 +13,29 @@ public static void ApplyRoleSeeder(this ModelBuilder builder) { Id = new Guid("72bf32a9-69e8-4a57-936b-c6b23c47216d"), Name = "Admin", - NormalizedName = "ADMIN" + NormalizedName = "ADMIN", + ConcurrencyStamp = null }, new() { Id = new Guid("2a74bf8e-0be3-46cc-9310-fdd5f80bd878"), Name = "Manager", - NormalizedName = "MANAGER" + NormalizedName = "MANAGER", + ConcurrencyStamp = null }, new() { Id = new Guid("5b20150c-817c-4020-bb91-59d29f732a32"), Name = "Employee", - NormalizedName = "EMPLOYEE" + NormalizedName = "EMPLOYEE", + ConcurrencyStamp = null }, new() { Id = new Guid("f9bb36fe-9ac3-4cad-9a37-b90eab601cf5"), Name = "Viewer", - NormalizedName = "VIEWER" + NormalizedName = "VIEWER", + ConcurrencyStamp = null } ];