diff --git a/MeAjudaAi.sln b/MeAjudaAi.sln index 77e73c1cc..d855166a9 100644 --- a/MeAjudaAi.sln +++ b/MeAjudaAi.sln @@ -145,16 +145,24 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{4726175B EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeAjudaAi.Modules.SearchProviders.Tests", "src\Modules\SearchProviders\Tests\MeAjudaAi.Modules.SearchProviders.Tests.csproj", "{C7F6B6F4-4F9C-C844-500C-87E3802A6C4B}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Catalogs", "Catalogs", "{8B551008-B254-EBAF-1B6D-AB7C420234EA}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ServiceCatalogs", "ServiceCatalogs", "{8B551008-B254-EBAF-1B6D-AB7C420234EA}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Domain", "Domain", "{B346CC0B-427A-E442-6F5D-8AAE1AB081D6}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeAjudaAi.Modules.ServiceCatalogs.Domain", "src\Modules\ServiceCatalogs\Domain\MeAjudaAi.Modules.ServiceCatalogs.Domain.csproj", "{DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Application", "Application", "{44577491-2FC0-4F52-AF5C-2BC9B323CDB7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeAjudaAi.Modules.ServiceCatalogs.Application", "src\Modules\ServiceCatalogs\Application\MeAjudaAi.Modules.ServiceCatalogs.Application.csproj", "{A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{8D23D6D3-2B2E-7F09-866F-FA51CC0FC081}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeAjudaAi.Modules.ServiceCatalogs.Infrastructure", "src\Modules\ServiceCatalogs\Infrastructure\MeAjudaAi.Modules.ServiceCatalogs.Infrastructure.csproj", "{3B6D6C13-1E04-47B9-B44E-36D25DF913C7}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "API", "API", "{30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeAjudaAi.Modules.ServiceCatalogs.API", "src\Modules\ServiceCatalogs\API\MeAjudaAi.Modules.ServiceCatalogs.API.csproj", "{B1C2D3E4-F5A6-7B8C-9D0E-1F2A3B4C5D6E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -573,18 +581,18 @@ Global {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}.Release|x64.Build.0 = Release|Any CPU {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}.Release|x86.ActiveCfg = Release|Any CPU {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}.Release|x86.Build.0 = Release|Any CPU - {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Debug|x64.ActiveCfg = Debug|Any CPU - {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Debug|x64.Build.0 = Debug|Any CPU - {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Debug|x86.ActiveCfg = Debug|Any CPU - {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Debug|x86.Build.0 = Debug|Any CPU - {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Release|Any CPU.Build.0 = Release|Any CPU - {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Release|x64.ActiveCfg = Release|Any CPU - {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Release|x64.Build.0 = Release|Any CPU - {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Release|x86.ActiveCfg = Release|Any CPU - {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Release|x86.Build.0 = Release|Any CPU + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Debug|x64.ActiveCfg = Debug|Any CPU + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Debug|x64.Build.0 = Debug|Any CPU + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Debug|x86.ActiveCfg = Debug|Any CPU + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Debug|x86.Build.0 = Debug|Any CPU + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Release|Any CPU.Build.0 = Release|Any CPU + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Release|x64.ActiveCfg = Release|Any CPU + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Release|x64.Build.0 = Release|Any CPU + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Release|x86.ActiveCfg = Release|Any CPU + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Release|x86.Build.0 = Release|Any CPU {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Debug|Any CPU.Build.0 = Debug|Any CPU {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -597,6 +605,18 @@ Global {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Release|x64.Build.0 = Release|Any CPU {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Release|x86.ActiveCfg = Release|Any CPU {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Release|x86.Build.0 = Release|Any CPU + {B1C2D3E4-F5A6-7B8C-9D0E-1F2A3B4C5D6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1C2D3E4-F5A6-7B8C-9D0E-1F2A3B4C5D6E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1C2D3E4-F5A6-7B8C-9D0E-1F2A3B4C5D6E}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1C2D3E4-F5A6-7B8C-9D0E-1F2A3B4C5D6E}.Debug|x64.Build.0 = Debug|Any CPU + {B1C2D3E4-F5A6-7B8C-9D0E-1F2A3B4C5D6E}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1C2D3E4-F5A6-7B8C-9D0E-1F2A3B4C5D6E}.Debug|x86.Build.0 = Debug|Any CPU + {B1C2D3E4-F5A6-7B8C-9D0E-1F2A3B4C5D6E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1C2D3E4-F5A6-7B8C-9D0E-1F2A3B4C5D6E}.Release|Any CPU.Build.0 = Release|Any CPU + {B1C2D3E4-F5A6-7B8C-9D0E-1F2A3B4C5D6E}.Release|x64.ActiveCfg = Release|Any CPU + {B1C2D3E4-F5A6-7B8C-9D0E-1F2A3B4C5D6E}.Release|x64.Build.0 = Release|Any CPU + {B1C2D3E4-F5A6-7B8C-9D0E-1F2A3B4C5D6E}.Release|x86.ActiveCfg = Release|Any CPU + {B1C2D3E4-F5A6-7B8C-9D0E-1F2A3B4C5D6E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -671,8 +691,12 @@ Global {8B551008-B254-EBAF-1B6D-AB7C420234EA} = {D55DFAF4-45A1-4C45-AA54-8CE46F0AFB1F} {B346CC0B-427A-E442-6F5D-8AAE1AB081D6} = {8B551008-B254-EBAF-1B6D-AB7C420234EA} {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A} = {B346CC0B-427A-E442-6F5D-8AAE1AB081D6} + {44577491-2FC0-4F52-AF5C-2BC9B323CDB7} = {8B551008-B254-EBAF-1B6D-AB7C420234EA} + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D} = {44577491-2FC0-4F52-AF5C-2BC9B323CDB7} {8D23D6D3-2B2E-7F09-866F-FA51CC0FC081} = {8B551008-B254-EBAF-1B6D-AB7C420234EA} {3B6D6C13-1E04-47B9-B44E-36D25DF913C7} = {8D23D6D3-2B2E-7F09-866F-FA51CC0FC081} + {30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8} = {8B551008-B254-EBAF-1B6D-AB7C420234EA} + {B1C2D3E4-F5A6-7B8C-9D0E-1F2A3B4C5D6E} = {30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {391B5342-8EC5-4DF0-BCDA-6D73F87E8751} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/Service/ActivateServiceEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/Service/ActivateServiceEndpoint.cs new file mode 100644 index 000000000..99d9c3fea --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/Service/ActivateServiceEndpoint.cs @@ -0,0 +1,30 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; +using MeAjudaAi.Shared.Authorization; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.Service; + +public class ActivateServiceEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("/{id:guid}/activate", ActivateAsync) + .WithName("ActivateService") + .WithSummary("Ativar serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task ActivateAsync( + Guid id, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new ActivateServiceCommand(id); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/Service/ChangeServiceCategoryEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/Service/ChangeServiceCategoryEndpoint.cs new file mode 100644 index 000000000..63d926fb2 --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/Service/ChangeServiceCategoryEndpoint.cs @@ -0,0 +1,33 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs.Requests.Service; +using MeAjudaAi.Shared.Authorization; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.Service; + +public class ChangeServiceCategoryEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("/{id:guid}/change-category", ChangeAsync) + .WithName("ChangeServiceCategory") + .WithSummary("Alterar categoria do serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task ChangeAsync( + Guid id, + [FromBody] ChangeServiceCategoryRequest request, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new ChangeServiceCategoryCommand(id, request.NewCategoryId); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/Service/CreateServiceEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/Service/CreateServiceEndpoint.cs new file mode 100644 index 000000000..4b24f9899 --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/Service/CreateServiceEndpoint.cs @@ -0,0 +1,38 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs.Requests.Service; +using MeAjudaAi.Shared.Authorization; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Contracts; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.Service; + +public class CreateServiceEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("/", CreateAsync) + .WithName("CreateService") + .WithSummary("Criar serviço") + .Produces>(StatusCodes.Status201Created) + .RequireAdmin(); + + private static async Task CreateAsync( + [FromBody] CreateServiceRequest request, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new CreateServiceCommand(request.CategoryId, request.Name, request.Description, request.DisplayOrder); + var result = await commandDispatcher.SendAsync>(command, cancellationToken); + + if (!result.IsSuccess) + return Handle(result); + + return Handle(result, "GetServiceById", new { id = result.Value!.Id }); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/Service/DeactivateServiceEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/Service/DeactivateServiceEndpoint.cs new file mode 100644 index 000000000..fc856cc42 --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/Service/DeactivateServiceEndpoint.cs @@ -0,0 +1,30 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; +using MeAjudaAi.Shared.Authorization; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.Service; + +public class DeactivateServiceEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("/{id:guid}/deactivate", DeactivateAsync) + .WithName("DeactivateService") + .WithSummary("Desativar serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task DeactivateAsync( + Guid id, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new DeactivateServiceCommand(id); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/Service/DeleteServiceEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/Service/DeleteServiceEndpoint.cs new file mode 100644 index 000000000..cd85b0574 --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/Service/DeleteServiceEndpoint.cs @@ -0,0 +1,30 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; +using MeAjudaAi.Shared.Authorization; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.Service; + +public class DeleteServiceEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapDelete("/{id:guid}", DeleteAsync) + .WithName("DeleteService") + .WithSummary("Deletar serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task DeleteAsync( + Guid id, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new DeleteServiceCommand(id); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/Service/GetAllServicesEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/Service/GetAllServicesEndpoint.cs new file mode 100644 index 000000000..8ca4541e9 --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/Service/GetAllServicesEndpoint.cs @@ -0,0 +1,31 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.Service; +using MeAjudaAi.Shared.Contracts; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.Service; + +public class GetAllServicesEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapGet("/", GetAllAsync) + .WithName("GetAllServices") + .WithSummary("Listar todos os serviços") + .Produces>>(StatusCodes.Status200OK); + + private static async Task GetAllAsync( + [AsParameters] GetAllServicesQuery query, + IQueryDispatcher queryDispatcher, + CancellationToken cancellationToken) + { + var result = await queryDispatcher.QueryAsync>>( + query, cancellationToken); + return Handle(result); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/Service/GetServiceByIdEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/Service/GetServiceByIdEndpoint.cs new file mode 100644 index 000000000..c968ea8c2 --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/Service/GetServiceByIdEndpoint.cs @@ -0,0 +1,37 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.Service; +using MeAjudaAi.Shared.Contracts; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.Service; + +public class GetServiceByIdEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapGet("/{id:guid}", GetByIdAsync) + .WithName("GetServiceById") + .WithSummary("Buscar serviço por ID") + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + + private static async Task GetByIdAsync( + Guid id, + IQueryDispatcher queryDispatcher, + CancellationToken cancellationToken) + { + var query = new GetServiceByIdQuery(id); + var result = await queryDispatcher.QueryAsync>(query, cancellationToken); + + if (result.IsSuccess && result.Value is null) + { + return Results.NotFound(); + } + + return Handle(result); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/Service/GetServicesByCategoryEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/Service/GetServicesByCategoryEndpoint.cs new file mode 100644 index 000000000..a2aa91fb9 --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/Service/GetServicesByCategoryEndpoint.cs @@ -0,0 +1,32 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.Service; +using MeAjudaAi.Shared.Contracts; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.Service; + +public class GetServicesByCategoryEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapGet("/category/{categoryId:guid}", GetByCategoryAsync) + .WithName("GetServicesByCategory") + .WithSummary("Listar serviços por categoria") + .Produces>>(StatusCodes.Status200OK); + + private static async Task GetByCategoryAsync( + Guid categoryId, + [AsParameters] GetServicesByCategoryQuery query, + IQueryDispatcher queryDispatcher, + CancellationToken cancellationToken) + { + var queryWithCategory = query with { CategoryId = categoryId }; + var result = await queryDispatcher.QueryAsync>>(queryWithCategory, cancellationToken); + return Handle(result); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/Service/UpdateServiceEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/Service/UpdateServiceEndpoint.cs new file mode 100644 index 000000000..f03034ca1 --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/Service/UpdateServiceEndpoint.cs @@ -0,0 +1,33 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs.Requests.Service; +using MeAjudaAi.Shared.Authorization; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.Service; + +public class UpdateServiceEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPut("/{id:guid}", UpdateAsync) + .WithName("UpdateService") + .WithSummary("Atualizar serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task UpdateAsync( + Guid id, + [FromBody] UpdateServiceRequest request, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new UpdateServiceCommand(id, request.Name, request.Description, request.DisplayOrder); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/Service/ValidateServicesEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/Service/ValidateServicesEndpoint.cs new file mode 100644 index 000000000..671419d99 --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/Service/ValidateServicesEndpoint.cs @@ -0,0 +1,44 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs.Requests.Service; +using MeAjudaAi.Modules.ServiceCatalogs.Application.ModuleApi; +using MeAjudaAi.Shared.Authorization; +using MeAjudaAi.Shared.Contracts; +// TODO Phase 2: Uncomment when shared contracts are added +// using MeAjudaAi.Shared.Contracts.Modules.ServiceCatalogs; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.Service; + +public class ValidateServicesEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("validate", ValidateAsync) + .WithName("ValidateServices") + .WithSummary("Validar múltiplos serviços") + .Produces>(StatusCodes.Status200OK) + .RequireAdmin(); + + private static async Task ValidateAsync( + [FromBody] ValidateServicesRequest request, + [FromServices] ServiceCatalogsModuleApi moduleApi, + CancellationToken cancellationToken) + { + var result = await moduleApi.ValidateServicesAsync(request.ServiceIds, cancellationToken); + + if (!result.IsSuccess) + return Handle(result); + + // Mapear do DTO do módulo para o DTO da API + var response = new ValidateServicesResponse( + result.Value!.AllValid, + result.Value.InvalidServiceIds, + result.Value.InactiveServiceIds + ); + + return Handle(Result.Success(response)); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCatalogsModuleEndpoints.cs b/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCatalogsModuleEndpoints.cs new file mode 100644 index 000000000..5995842df --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCatalogsModuleEndpoints.cs @@ -0,0 +1,44 @@ +using MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.Service; +using MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.ServiceCategory; +using MeAjudaAi.Shared.Endpoints; +using Microsoft.AspNetCore.Builder; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints; + +/// +/// Classe responsável pelo mapeamento de todos os endpoints do módulo ServiceCatalogs. +/// +public static class ServiceCatalogsModuleEndpoints +{ + /// + /// Mapeia todos os endpoints do módulo ServiceCatalogs. + /// + /// Aplicação web para configuração das rotas + public static void MapServiceCatalogsEndpoints(this WebApplication app) + { + // Service Categories endpoints + var categoriesEndpoints = BaseEndpoint.CreateVersionedGroup(app, "catalogs/categories", "ServiceCategories"); + + categoriesEndpoints.MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint(); + + // Services endpoints + var servicesEndpoints = BaseEndpoint.CreateVersionedGroup(app, "catalogs/services", "Services"); + + servicesEndpoints.MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint(); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/ActivateServiceCategoryEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/ActivateServiceCategoryEndpoint.cs new file mode 100644 index 000000000..34b677509 --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/ActivateServiceCategoryEndpoint.cs @@ -0,0 +1,30 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Shared.Authorization; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.ServiceCategory; + +public class ActivateServiceCategoryEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("/{id:guid}/activate", ActivateAsync) + .WithName("ActivateServiceCategory") + .WithSummary("Ativar categoria de serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task ActivateAsync( + Guid id, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new ActivateServiceCategoryCommand(id); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/CreateServiceCategoryEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/CreateServiceCategoryEndpoint.cs new file mode 100644 index 000000000..0d2340b81 --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/CreateServiceCategoryEndpoint.cs @@ -0,0 +1,43 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Shared.Authorization; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Contracts; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.ServiceCategory; + +public record CreateServiceCategoryRequest(string Name, string? Description, int DisplayOrder); + +public class CreateServiceCategoryEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("/", CreateAsync) + .WithName("CreateServiceCategory") + .WithSummary("Criar categoria de serviço") + .Produces>(StatusCodes.Status201Created) + .RequireAdmin(); + + private static async Task CreateAsync( + [FromBody] CreateServiceCategoryRequest request, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new CreateServiceCategoryCommand(request.Name, request.Description, request.DisplayOrder); + var result = await commandDispatcher.SendAsync>( + command, cancellationToken); + + if (!result.IsSuccess) + return Handle(result); + + if (result.Value is null) + return Results.BadRequest("Unexpected null value in successful result."); + + return Handle(result, "GetServiceCategoryById", new { id = result.Value.Id }); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/DeactivateServiceCategoryEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/DeactivateServiceCategoryEndpoint.cs new file mode 100644 index 000000000..fa9076139 --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/DeactivateServiceCategoryEndpoint.cs @@ -0,0 +1,30 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Shared.Authorization; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.ServiceCategory; + +public class DeactivateServiceCategoryEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("/{id:guid}/deactivate", DeactivateAsync) + .WithName("DeactivateServiceCategory") + .WithSummary("Desativar categoria de serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task DeactivateAsync( + Guid id, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new DeactivateServiceCategoryCommand(id); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/DeleteServiceCategoryEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/DeleteServiceCategoryEndpoint.cs new file mode 100644 index 000000000..d628d536a --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/DeleteServiceCategoryEndpoint.cs @@ -0,0 +1,30 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Shared.Authorization; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.ServiceCategory; + +public class DeleteServiceCategoryEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapDelete("/{id:guid}", DeleteAsync) + .WithName("DeleteServiceCategory") + .WithSummary("Deletar categoria de serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task DeleteAsync( + Guid id, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new DeleteServiceCategoryCommand(id); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/GetAllServiceCategoriesEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/GetAllServiceCategoriesEndpoint.cs new file mode 100644 index 000000000..cab7e1514 --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/GetAllServiceCategoriesEndpoint.cs @@ -0,0 +1,35 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.ServiceCategory; +using MeAjudaAi.Shared.Contracts; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.ServiceCategory; + +public record GetAllCategoriesQuery(bool ActiveOnly = false); + +public class GetAllServiceCategoriesEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapGet("/", GetAllAsync) + .WithName("GetAllServiceCategories") + .WithSummary("Listar todas as categorias") + .Produces>>(StatusCodes.Status200OK); + + private static async Task GetAllAsync( + [AsParameters] GetAllCategoriesQuery query, + IQueryDispatcher queryDispatcher, + CancellationToken cancellationToken) + { + var qry = new GetAllServiceCategoriesQuery(query.ActiveOnly); + var result = await queryDispatcher.QueryAsync>>( + qry, cancellationToken); + + return Handle(result); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/GetServiceCategoryByIdEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/GetServiceCategoryByIdEndpoint.cs new file mode 100644 index 000000000..170099419 --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/GetServiceCategoryByIdEndpoint.cs @@ -0,0 +1,36 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.ServiceCategory; +using MeAjudaAi.Shared.Contracts; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.ServiceCategory; + +public class GetServiceCategoryByIdEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapGet("/{id:guid}", GetByIdAsync) + .WithName("GetServiceCategoryById") + .WithSummary("Buscar categoria por ID") + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + + private static async Task GetByIdAsync( + Guid id, + IQueryDispatcher queryDispatcher, + CancellationToken cancellationToken) + { + var query = new GetServiceCategoryByIdQuery(id); + var result = await queryDispatcher.QueryAsync>( + query, cancellationToken); + + if (result.IsSuccess && result.Value == null) + return Results.NotFound(); + + return Handle(result); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/UpdateServiceCategoryEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/UpdateServiceCategoryEndpoint.cs new file mode 100644 index 000000000..da46150e0 --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Endpoints/ServiceCategory/UpdateServiceCategoryEndpoint.cs @@ -0,0 +1,34 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Shared.Authorization; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints.ServiceCategory; + +public record UpdateServiceCategoryRequest(string Name, string? Description, int DisplayOrder); + +public class UpdateServiceCategoryEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPut("/{id:guid}", UpdateAsync) + .WithName("UpdateServiceCategory") + .WithSummary("Atualizar categoria de serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task UpdateAsync( + Guid id, + [FromBody] UpdateServiceCategoryRequest request, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new UpdateServiceCategoryCommand(id, request.Name, request.Description, request.DisplayOrder); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} diff --git a/src/Modules/ServiceCatalogs/API/Extensions.cs b/src/Modules/ServiceCatalogs/API/Extensions.cs new file mode 100644 index 000000000..c547be924 --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/Extensions.cs @@ -0,0 +1,94 @@ +using MeAjudaAi.Modules.ServiceCatalogs.API.Endpoints; +using MeAjudaAi.Modules.ServiceCatalogs.Application; +using MeAjudaAi.Modules.ServiceCatalogs.Infrastructure; +using Microsoft.AspNetCore.Builder; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.ServiceCatalogs.API; + +public static class Extensions +{ + /// + /// Adiciona os serviços do módulo ServiceCatalogs. + /// + public static IServiceCollection AddServiceCatalogsModule( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddApplication(); + services.AddServiceCatalogsInfrastructure(configuration); + + return services; + } + + /// + /// Configura os endpoints do módulo ServiceCatalogs. + /// + public static WebApplication UseServiceCatalogsModule(this WebApplication app) + { + // Garantir que as migrações estão aplicadas + EnsureDatabaseMigrations(app); + + app.MapServiceCatalogsEndpoints(); + + return app; + } + + private static void EnsureDatabaseMigrations(WebApplication app) + { + if (app?.Services == null) return; + + try + { + using var scope = app.Services.CreateScope(); + var logger = scope.ServiceProvider.GetService>(); + var context = scope.ServiceProvider.GetService(); + + if (context == null) + { + logger?.LogWarning("ServiceCatalogsDbContext not found in DI container. Skipping migrations."); + return; + } + + // Em ambiente de teste, pular migrações automáticas + if (app.Environment.IsEnvironment("Test") || app.Environment.IsEnvironment("Testing")) + { + logger?.LogInformation("Skipping ServiceCatalogs migrations in test environment: {Environment}", app.Environment.EnvironmentName); + return; + } + + context.Database.Migrate(); + } + catch (Exception ex) + { + using var scope = app.Services.CreateScope(); + var logger = scope.ServiceProvider.GetService>(); + + // Only fallback to EnsureCreated in Development + if (app.Environment.IsDevelopment()) + { + logger?.LogWarning(ex, "Falha ao aplicar migrações do módulo ServiceCatalogs. Usando EnsureCreated como fallback em Development."); + try + { + var context = scope.ServiceProvider.GetService(); + context?.Database.EnsureCreated(); + } + catch (Exception fallbackEx) + { + logger?.LogError(fallbackEx, "Falha crítica ao inicializar o banco do módulo ServiceCatalogs."); + throw; // Fail fast even in Development if EnsureCreated fails + } + } + else + { + // Fail fast in non-development environments + logger?.LogError(ex, "Falha crítica ao aplicar migrações do módulo ServiceCatalogs em ambiente de produção."); + throw; + } + } + } +} diff --git a/src/Modules/ServiceCatalogs/API/MeAjudaAi.Modules.ServiceCatalogs.API.csproj b/src/Modules/ServiceCatalogs/API/MeAjudaAi.Modules.ServiceCatalogs.API.csproj new file mode 100644 index 000000000..4d8aa8ecc --- /dev/null +++ b/src/Modules/ServiceCatalogs/API/MeAjudaAi.Modules.ServiceCatalogs.API.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + true + + NU1701;NU1507 + true + true + latest + + + + + + + + + + + + + + + + diff --git a/src/Modules/ServiceCatalogs/Application/Commands/Service/ActivateServiceCommand.cs b/src/Modules/ServiceCatalogs/Application/Commands/Service/ActivateServiceCommand.cs new file mode 100644 index 000000000..387f7dcfc --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Commands/Service/ActivateServiceCommand.cs @@ -0,0 +1,9 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; + +/// +/// Command to activate a service, making it available for use. +/// +public sealed record ActivateServiceCommand(Guid Id) : Command; diff --git a/src/Modules/ServiceCatalogs/Application/Commands/Service/ChangeServiceCategoryCommand.cs b/src/Modules/ServiceCatalogs/Application/Commands/Service/ChangeServiceCategoryCommand.cs new file mode 100644 index 000000000..829816ed5 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Commands/Service/ChangeServiceCategoryCommand.cs @@ -0,0 +1,12 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; + +/// +/// Command to move a service to a different category. +/// +public sealed record ChangeServiceCategoryCommand( + Guid ServiceId, + Guid NewCategoryId +) : Command; diff --git a/src/Modules/ServiceCatalogs/Application/Commands/Service/CreateServiceCommand.cs b/src/Modules/ServiceCatalogs/Application/Commands/Service/CreateServiceCommand.cs new file mode 100644 index 000000000..c6c33878c --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Commands/Service/CreateServiceCommand.cs @@ -0,0 +1,15 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; + +/// +/// Command to create a new service in a specific category. +/// +public sealed record CreateServiceCommand( + Guid CategoryId, + string Name, + string? Description, + int DisplayOrder = 0 +) : Command>; diff --git a/src/Modules/ServiceCatalogs/Application/Commands/Service/DeactivateServiceCommand.cs b/src/Modules/ServiceCatalogs/Application/Commands/Service/DeactivateServiceCommand.cs new file mode 100644 index 000000000..c0ba8501e --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Commands/Service/DeactivateServiceCommand.cs @@ -0,0 +1,9 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; + +/// +/// Command to deactivate a service, removing it from active use. +/// +public sealed record DeactivateServiceCommand(Guid Id) : Command; diff --git a/src/Modules/ServiceCatalogs/Application/Commands/Service/DeleteServiceCommand.cs b/src/Modules/ServiceCatalogs/Application/Commands/Service/DeleteServiceCommand.cs new file mode 100644 index 000000000..664991a50 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Commands/Service/DeleteServiceCommand.cs @@ -0,0 +1,11 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; + +/// +/// Command to delete a service from the catalog. +/// Note: Future enhancement required - implement soft-delete pattern (IsActive = false) to preserve +/// audit history and prevent deletion when providers reference this service. See handler TODO. +/// +public sealed record DeleteServiceCommand(Guid Id) : Command; diff --git a/src/Modules/ServiceCatalogs/Application/Commands/Service/UpdateServiceCommand.cs b/src/Modules/ServiceCatalogs/Application/Commands/Service/UpdateServiceCommand.cs new file mode 100644 index 000000000..9b77ef7b0 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Commands/Service/UpdateServiceCommand.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; + +/// +/// Command to update an existing service's details. +/// Validation limits must match ValidationConstants.CatalogLimits. +/// Note: Guid.Empty validation is handled by the command handler to provide domain-specific error messages. +/// +public sealed record UpdateServiceCommand( + Guid Id, + [Required] + [MaxLength(150)] + string Name, + [MaxLength(1000)] + string? Description, + [Range(0, int.MaxValue)] + int DisplayOrder +) : Command; diff --git a/src/Modules/ServiceCatalogs/Application/Commands/ServiceCategory/ActivateServiceCategoryCommand.cs b/src/Modules/ServiceCatalogs/Application/Commands/ServiceCategory/ActivateServiceCategoryCommand.cs new file mode 100644 index 000000000..3775c01a9 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Commands/ServiceCategory/ActivateServiceCategoryCommand.cs @@ -0,0 +1,9 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.ServiceCategory; + +/// +/// Command to activate a service category. +/// +public sealed record ActivateServiceCategoryCommand(Guid Id) : Command; diff --git a/src/Modules/ServiceCatalogs/Application/Commands/ServiceCategory/CreateServiceCategoryCommand.cs b/src/Modules/ServiceCatalogs/Application/Commands/ServiceCategory/CreateServiceCategoryCommand.cs new file mode 100644 index 000000000..ac7ad4db0 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Commands/ServiceCategory/CreateServiceCategoryCommand.cs @@ -0,0 +1,11 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.ServiceCategory; + +public sealed record CreateServiceCategoryCommand( + string Name, + string? Description, + int DisplayOrder = 0 +) : Command>; diff --git a/src/Modules/ServiceCatalogs/Application/Commands/ServiceCategory/DeactivateServiceCategoryCommand.cs b/src/Modules/ServiceCatalogs/Application/Commands/ServiceCategory/DeactivateServiceCategoryCommand.cs new file mode 100644 index 000000000..398adcada --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Commands/ServiceCategory/DeactivateServiceCategoryCommand.cs @@ -0,0 +1,6 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.ServiceCategory; + +public sealed record DeactivateServiceCategoryCommand(Guid Id) : Command; diff --git a/src/Modules/ServiceCatalogs/Application/Commands/ServiceCategory/DeleteServiceCategoryCommand.cs b/src/Modules/ServiceCatalogs/Application/Commands/ServiceCategory/DeleteServiceCategoryCommand.cs new file mode 100644 index 000000000..5c001f3e7 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Commands/ServiceCategory/DeleteServiceCategoryCommand.cs @@ -0,0 +1,6 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.ServiceCategory; + +public sealed record DeleteServiceCategoryCommand(Guid Id) : Command; diff --git a/src/Modules/ServiceCatalogs/Application/Commands/ServiceCategory/UpdateServiceCategoryCommand.cs b/src/Modules/ServiceCatalogs/Application/Commands/ServiceCategory/UpdateServiceCategoryCommand.cs new file mode 100644 index 000000000..660c01c0d --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Commands/ServiceCategory/UpdateServiceCategoryCommand.cs @@ -0,0 +1,17 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.ServiceCategory; + +/// +/// Command to update an existing service category's information. +/// Note: This command requires all fields for updates (full-update pattern). +/// Future enhancement: Consider supporting partial updates where clients only send changed fields +/// using nullable fields or optional wrapper types if API requirements evolve. +/// +public sealed record UpdateServiceCategoryCommand( + Guid Id, + string Name, + string? Description, + int DisplayOrder = 0 +) : Command; diff --git a/src/Modules/ServiceCatalogs/Application/DTOs/Requests/Service/ChangeServiceCategoryRequest.cs b/src/Modules/ServiceCatalogs/Application/DTOs/Requests/Service/ChangeServiceCategoryRequest.cs new file mode 100644 index 000000000..58815affe --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/DTOs/Requests/Service/ChangeServiceCategoryRequest.cs @@ -0,0 +1,8 @@ +using MeAjudaAi.Shared.Contracts; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs.Requests.Service; + +public sealed record ChangeServiceCategoryRequest : Request +{ + public Guid NewCategoryId { get; init; } +} diff --git a/src/Modules/ServiceCatalogs/Application/DTOs/Requests/Service/CreateServiceRequest.cs b/src/Modules/ServiceCatalogs/Application/DTOs/Requests/Service/CreateServiceRequest.cs new file mode 100644 index 000000000..a498b40b3 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/DTOs/Requests/Service/CreateServiceRequest.cs @@ -0,0 +1,11 @@ +using MeAjudaAi.Shared.Contracts; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs.Requests.Service; + +public sealed record CreateServiceRequest : Request +{ + public Guid CategoryId { get; init; } + public string Name { get; init; } = string.Empty; + public string? Description { get; init; } + public int DisplayOrder { get; init; } = 0; +} diff --git a/src/Modules/ServiceCatalogs/Application/DTOs/Requests/Service/UpdateServiceRequest.cs b/src/Modules/ServiceCatalogs/Application/DTOs/Requests/Service/UpdateServiceRequest.cs new file mode 100644 index 000000000..acfbd7445 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/DTOs/Requests/Service/UpdateServiceRequest.cs @@ -0,0 +1,13 @@ +using MeAjudaAi.Shared.Contracts; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs.Requests.Service; + +/// +/// Request to update an existing service's information. +/// +public sealed record UpdateServiceRequest : Request +{ + public string Name { get; init; } = string.Empty; + public string? Description { get; init; } + public int DisplayOrder { get; init; } +} diff --git a/src/Modules/ServiceCatalogs/Application/DTOs/Requests/Service/ValidateServicesRequest.cs b/src/Modules/ServiceCatalogs/Application/DTOs/Requests/Service/ValidateServicesRequest.cs new file mode 100644 index 000000000..3e239c6ad --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/DTOs/Requests/Service/ValidateServicesRequest.cs @@ -0,0 +1,8 @@ +using MeAjudaAi.Shared.Contracts; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs.Requests.Service; + +public sealed record ValidateServicesRequest : Request +{ + public IReadOnlyCollection ServiceIds { get; init; } = Array.Empty(); +} diff --git a/src/Modules/ServiceCatalogs/Application/DTOs/Requests/Service/ValidateServicesResponse.cs b/src/Modules/ServiceCatalogs/Application/DTOs/Requests/Service/ValidateServicesResponse.cs new file mode 100644 index 000000000..1433c17d6 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/DTOs/Requests/Service/ValidateServicesResponse.cs @@ -0,0 +1,15 @@ +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs.Requests.Service; + +public sealed record ValidateServicesResponse +{ + public bool AllValid { get; init; } + public IReadOnlyCollection InvalidServiceIds { get; init; } + public IReadOnlyCollection InactiveServiceIds { get; init; } + + public ValidateServicesResponse(bool allValid, IReadOnlyCollection? invalidServiceIds, IReadOnlyCollection? inactiveServiceIds) + { + AllValid = allValid; + InvalidServiceIds = invalidServiceIds ?? Array.Empty(); + InactiveServiceIds = inactiveServiceIds ?? Array.Empty(); + } +} diff --git a/src/Modules/ServiceCatalogs/Application/DTOs/Requests/ServiceCategory/UpdateServiceCategoryRequest.cs b/src/Modules/ServiceCatalogs/Application/DTOs/Requests/ServiceCategory/UpdateServiceCategoryRequest.cs new file mode 100644 index 000000000..0e01f144e --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/DTOs/Requests/ServiceCategory/UpdateServiceCategoryRequest.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using MeAjudaAi.Shared.Contracts; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs.Requests.ServiceCategory; + +public sealed record UpdateServiceCategoryRequest : Request +{ + [Required] + [MaxLength(100)] + public string Name { get; init; } = string.Empty; + + [MaxLength(500)] + public string? Description { get; init; } + + [Range(0, int.MaxValue)] + public int DisplayOrder { get; init; } +} diff --git a/src/Modules/ServiceCatalogs/Application/DTOs/ServiceCategoryDto.cs b/src/Modules/ServiceCatalogs/Application/DTOs/ServiceCategoryDto.cs new file mode 100644 index 000000000..85ebb7b2f --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/DTOs/ServiceCategoryDto.cs @@ -0,0 +1,14 @@ +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; + +/// +/// DTO para informações de categoria de serviço. +/// +public sealed record ServiceCategoryDto( + Guid Id, + string Name, + string? Description, + bool IsActive, + int DisplayOrder, + DateTime CreatedAt, + DateTime? UpdatedAt +); diff --git a/src/Modules/ServiceCatalogs/Application/DTOs/ServiceCategoryWithCountDto.cs b/src/Modules/ServiceCatalogs/Application/DTOs/ServiceCategoryWithCountDto.cs new file mode 100644 index 000000000..832d5e45c --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/DTOs/ServiceCategoryWithCountDto.cs @@ -0,0 +1,14 @@ +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; + +/// +/// DTO para categoria com a contagem de seus serviços. +/// +public sealed record ServiceCategoryWithCountDto( + Guid Id, + string Name, + string? Description, + bool IsActive, + int DisplayOrder, + int ActiveServicesCount, + int TotalServicesCount +); diff --git a/src/Modules/ServiceCatalogs/Application/DTOs/ServiceDto.cs b/src/Modules/ServiceCatalogs/Application/DTOs/ServiceDto.cs new file mode 100644 index 000000000..fdd274eaf --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/DTOs/ServiceDto.cs @@ -0,0 +1,16 @@ +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; + +/// +/// DTO para informações de serviço. +/// +public sealed record ServiceDto( + Guid Id, + Guid CategoryId, + string CategoryName, + string Name, + string? Description, + bool IsActive, + int DisplayOrder, + DateTime CreatedAt, + DateTime? UpdatedAt +); diff --git a/src/Modules/ServiceCatalogs/Application/DTOs/ServiceListDto.cs b/src/Modules/ServiceCatalogs/Application/DTOs/ServiceListDto.cs new file mode 100644 index 000000000..2daf727f2 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/DTOs/ServiceListDto.cs @@ -0,0 +1,12 @@ +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; + +/// +/// DTO simplificado para serviço sem detalhes de categoria (para listas). +/// +public sealed record ServiceListDto( + Guid Id, + Guid CategoryId, + string Name, + string? Description, + bool IsActive +); diff --git a/src/Modules/ServiceCatalogs/Application/Extensions.cs b/src/Modules/ServiceCatalogs/Application/Extensions.cs new file mode 100644 index 000000000..5ea0abe68 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Extensions.cs @@ -0,0 +1,22 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.ModuleApi; +// TODO Phase 2: Uncomment when shared contracts are added +// using MeAjudaAi.Shared.Contracts.Modules.ServiceCatalogs; +using Microsoft.Extensions.DependencyInjection; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application; + +public static class Extensions +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + // Note: Handlers are automatically registered through reflection in Infrastructure layer + // via AddApplicationHandlers() which scans the Application assembly + + // Module API - register concrete type for DI (interface registration in Phase 2) + // TODO Phase 2: Uncomment interface registration when IServiceCatalogsModuleApi is added + // services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/ActivateServiceCommandHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/ActivateServiceCommandHandler.cs new file mode 100644 index 000000000..2e9cdf9a6 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/ActivateServiceCommandHandler.cs @@ -0,0 +1,30 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Commands.Service; + +public sealed class ActivateServiceCommandHandler( + IServiceRepository serviceRepository) + : ICommandHandler +{ + public async Task HandleAsync(ActivateServiceCommand request, CancellationToken cancellationToken = default) + { + if (request.Id == Guid.Empty) + return Result.Failure("Service ID cannot be empty."); + + var serviceId = ServiceId.From(request.Id); + var service = await serviceRepository.GetByIdAsync(serviceId, cancellationToken); + + if (service is null) + return Result.Failure($"Service with ID '{request.Id}' not found."); + + service.Activate(); + + await serviceRepository.UpdateAsync(service, cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/ChangeServiceCategoryCommandHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/ChangeServiceCategoryCommandHandler.cs new file mode 100644 index 000000000..05da757da --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/ChangeServiceCategoryCommandHandler.cs @@ -0,0 +1,62 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Exceptions; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Commands.Service; + +public sealed class ChangeServiceCategoryCommandHandler( + IServiceRepository serviceRepository, + IServiceCategoryRepository categoryRepository) + : ICommandHandler +{ + public async Task HandleAsync(ChangeServiceCategoryCommand request, CancellationToken cancellationToken = default) + { + try + { + if (request.ServiceId == Guid.Empty) + return Result.Failure("Service ID cannot be empty."); + + if (request.NewCategoryId == Guid.Empty) + return Result.Failure("New category ID cannot be empty."); + + var serviceId = ServiceId.From(request.ServiceId); + var service = await serviceRepository.GetByIdAsync(serviceId, cancellationToken); + + if (service is null) + return Result.Failure($"Service with ID '{request.ServiceId}' not found."); + + var newCategoryId = ServiceCategoryId.From(request.NewCategoryId); + var newCategory = await categoryRepository.GetByIdAsync(newCategoryId, cancellationToken); + + if (newCategory is null) + return Result.Failure($"Category with ID '{request.NewCategoryId}' not found."); + + if (!newCategory.IsActive) + return Result.Failure("Cannot move service to inactive category."); + + // Garantir que o nome ainda é único na categoria de destino + if (await serviceRepository.ExistsWithNameAsync( + service.Name, + service.Id, + newCategoryId, + cancellationToken)) + { + return Result.Failure( + $"A service with name '{service.Name}' already exists in the target category."); + } + + service.ChangeCategory(newCategoryId); + + await serviceRepository.UpdateAsync(service, cancellationToken); + + return Result.Success(); + } + catch (CatalogDomainException ex) + { + return Result.Failure(ex.Message); + } + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/CreateServiceCommandHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/CreateServiceCommandHandler.cs new file mode 100644 index 000000000..71a7ef09d --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/CreateServiceCommandHandler.cs @@ -0,0 +1,69 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Entities; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Exceptions; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Commands.Service; + +public sealed class CreateServiceCommandHandler( + IServiceRepository serviceRepository, + IServiceCategoryRepository categoryRepository) + : ICommandHandler> +{ + public async Task> HandleAsync(CreateServiceCommand request, CancellationToken cancellationToken = default) + { + try + { + if (request.CategoryId == Guid.Empty) + return Result.Failure("Category ID cannot be empty."); + + var categoryId = ServiceCategoryId.From(request.CategoryId); + + // Verificar se a categoria existe e está ativa + var category = await categoryRepository.GetByIdAsync(categoryId, cancellationToken); + if (category is null) + return Result.Failure($"Category with ID '{request.CategoryId}' not found."); + + if (!category.IsActive) + return Result.Failure("Cannot create service in inactive category."); + + var normalizedName = request.Name?.Trim(); + + if (string.IsNullOrWhiteSpace(normalizedName)) + return Result.Failure("Service name is required."); + + // Verificar se já existe serviço com o mesmo nome na categoria + if (await serviceRepository.ExistsWithNameAsync(normalizedName, null, categoryId, cancellationToken)) + return Result.Failure($"A service with name '{normalizedName}' already exists in this category."); + + // Validar DisplayOrder + if (request.DisplayOrder < 0) + return Result.Failure("Display order cannot be negative."); + + var service = Domain.Entities.Service.Create(categoryId, normalizedName, request.Description, request.DisplayOrder); + + await serviceRepository.AddAsync(service, cancellationToken); + + var dto = new ServiceDto( + service.Id.Value, + service.CategoryId.Value, + category.Name, + service.Name, + service.Description, + service.IsActive, + service.DisplayOrder, + service.CreatedAt, + service.UpdatedAt + ); + return Result.Success(dto); + } + catch (CatalogDomainException ex) + { + return Result.Failure(ex.Message); + } + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/DeactivateServiceCommandHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/DeactivateServiceCommandHandler.cs new file mode 100644 index 000000000..387fb82cb --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/DeactivateServiceCommandHandler.cs @@ -0,0 +1,30 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Commands.Service; + +public sealed class DeactivateServiceCommandHandler( + IServiceRepository serviceRepository) + : ICommandHandler +{ + public async Task HandleAsync(DeactivateServiceCommand request, CancellationToken cancellationToken = default) + { + if (request.Id == Guid.Empty) + return Result.Failure("Service ID cannot be empty."); + + var serviceId = ServiceId.From(request.Id); + var service = await serviceRepository.GetByIdAsync(serviceId, cancellationToken); + + if (service is null) + return Result.Failure($"Service with ID '{request.Id}' not found."); + + service.Deactivate(); + + await serviceRepository.UpdateAsync(service, cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/DeleteServiceCommandHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/DeleteServiceCommandHandler.cs new file mode 100644 index 000000000..fb7e91991 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/DeleteServiceCommandHandler.cs @@ -0,0 +1,35 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Commands.Service; + +public sealed class DeleteServiceCommandHandler( + IServiceRepository serviceRepository) + : ICommandHandler +{ + public async Task HandleAsync(DeleteServiceCommand request, CancellationToken cancellationToken = default) + { + if (request.Id == Guid.Empty) + return Result.Failure("Service ID cannot be empty."); + + var serviceId = ServiceId.From(request.Id); + var service = await serviceRepository.GetByIdAsync(serviceId, cancellationToken); + + if (service is null) + return Result.Failure($"Service with ID '{request.Id}' not found."); + + // TODO: Verificar se algum provedor oferece este serviço antes de deletar + // Isso requer integração com o módulo Providers via IProvidersModuleApi + // Considerar implementar: + // 1. Chamar IProvidersModuleApi.HasProvidersOfferingServiceAsync(serviceId) + // 2. Retornar falha se existirem provedores: "Cannot delete service: X providers offer this service" + // 3. Ou implementar padrão de soft-delete para preservar dados históricos + + await serviceRepository.DeleteAsync(serviceId, cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/UpdateServiceCommandHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/UpdateServiceCommandHandler.cs new file mode 100644 index 000000000..8d04c4ec3 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/Service/UpdateServiceCommandHandler.cs @@ -0,0 +1,47 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.Service; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Exceptions; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Commands.Service; + +public sealed class UpdateServiceCommandHandler( + IServiceRepository serviceRepository) + : ICommandHandler +{ + public async Task HandleAsync(UpdateServiceCommand request, CancellationToken cancellationToken = default) + { + try + { + if (request.Id == Guid.Empty) + return Result.Failure("Service ID cannot be empty."); + + var serviceId = ServiceId.From(request.Id); + var service = await serviceRepository.GetByIdAsync(serviceId, cancellationToken); + + if (service is null) + return Result.Failure($"Service with ID '{request.Id}' not found."); + + var normalizedName = request.Name?.Trim(); + + if (string.IsNullOrWhiteSpace(normalizedName)) + return Result.Failure("Service name cannot be empty."); + + // Verificar se já existe serviço com o mesmo nome na categoria (excluindo o serviço atual) + if (await serviceRepository.ExistsWithNameAsync(normalizedName, serviceId, service.CategoryId, cancellationToken)) + return Result.Failure($"A service with name '{normalizedName}' already exists in this category."); + + service.Update(normalizedName, request.Description, request.DisplayOrder); + + await serviceRepository.UpdateAsync(service, cancellationToken); + + return Result.Success(); + } + catch (CatalogDomainException ex) + { + return Result.Failure(ex.Message); + } + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Commands/ServiceCategory/ActivateServiceCategoryCommandHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/ServiceCategory/ActivateServiceCategoryCommandHandler.cs new file mode 100644 index 000000000..93ee722d4 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/ServiceCategory/ActivateServiceCategoryCommandHandler.cs @@ -0,0 +1,30 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Commands.ServiceCategory; + +public sealed class ActivateServiceCategoryCommandHandler( + IServiceCategoryRepository categoryRepository) + : ICommandHandler +{ + public async Task HandleAsync(ActivateServiceCategoryCommand request, CancellationToken cancellationToken = default) + { + if (request.Id == Guid.Empty) + return Result.Failure("Category ID cannot be empty."); + + var categoryId = ServiceCategoryId.From(request.Id); + var category = await categoryRepository.GetByIdAsync(categoryId, cancellationToken); + + if (category is null) + return Result.Failure($"Category with ID '{request.Id}' not found."); + + category.Activate(); + + await categoryRepository.UpdateAsync(category, cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Commands/ServiceCategory/CreateServiceCategoryCommandHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/ServiceCategory/CreateServiceCategoryCommandHandler.cs new file mode 100644 index 000000000..ec3306d5d --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/ServiceCategory/CreateServiceCategoryCommandHandler.cs @@ -0,0 +1,49 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Exceptions; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; +using ServiceCategoryEntity = MeAjudaAi.Modules.ServiceCatalogs.Domain.Entities.ServiceCategory; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Commands.ServiceCategory; + +public sealed class CreateServiceCategoryCommandHandler( + IServiceCategoryRepository categoryRepository) + : ICommandHandler> +{ + public async Task> HandleAsync(CreateServiceCategoryCommand request, CancellationToken cancellationToken = default) + { + try + { + var normalizedName = request.Name?.Trim(); + + if (string.IsNullOrWhiteSpace(normalizedName)) + return Result.Failure("Category name is required."); + + // Verificar se já existe categoria com o mesmo nome + if (await categoryRepository.ExistsWithNameAsync(normalizedName, null, cancellationToken)) + return Result.Failure($"A category with name '{normalizedName}' already exists."); + + var category = ServiceCategoryEntity.Create(normalizedName, request.Description, request.DisplayOrder); + + await categoryRepository.AddAsync(category, cancellationToken); + + var dto = new ServiceCategoryDto( + category.Id.Value, + category.Name, + category.Description, + category.IsActive, + category.DisplayOrder, + category.CreatedAt, + category.UpdatedAt + ); + + return Result.Success(dto); + } + catch (CatalogDomainException ex) + { + return Result.Failure(ex.Message); + } + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Commands/ServiceCategory/DeactivateServiceCategoryCommandHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/ServiceCategory/DeactivateServiceCategoryCommandHandler.cs new file mode 100644 index 000000000..71fef834d --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/ServiceCategory/DeactivateServiceCategoryCommandHandler.cs @@ -0,0 +1,30 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Commands.ServiceCategory; + +public sealed class DeactivateServiceCategoryCommandHandler( + IServiceCategoryRepository categoryRepository) + : ICommandHandler +{ + public async Task HandleAsync(DeactivateServiceCategoryCommand request, CancellationToken cancellationToken = default) + { + if (request.Id == Guid.Empty) + return Result.Failure("Category ID cannot be empty."); + + var categoryId = ServiceCategoryId.From(request.Id); + var category = await categoryRepository.GetByIdAsync(categoryId, cancellationToken); + + if (category is null) + return Result.Failure($"Category with ID '{request.Id}' not found."); + + category.Deactivate(); + + await categoryRepository.UpdateAsync(category, cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Commands/ServiceCategory/DeleteServiceCategoryCommandHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/ServiceCategory/DeleteServiceCategoryCommandHandler.cs new file mode 100644 index 000000000..7277308b9 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/ServiceCategory/DeleteServiceCategoryCommandHandler.cs @@ -0,0 +1,34 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Commands.ServiceCategory; + +public sealed class DeleteServiceCategoryCommandHandler( + IServiceCategoryRepository categoryRepository, + IServiceRepository serviceRepository) + : ICommandHandler +{ + public async Task HandleAsync(DeleteServiceCategoryCommand request, CancellationToken cancellationToken = default) + { + if (request.Id == Guid.Empty) + return Result.Failure("Category ID cannot be empty."); + + var categoryId = ServiceCategoryId.From(request.Id); + var category = await categoryRepository.GetByIdAsync(categoryId, cancellationToken); + + if (category is null) + return Result.Failure($"Category with ID '{request.Id}' not found."); + + // Verificar se a categoria possui serviços + var serviceCount = await serviceRepository.CountByCategoryAsync(categoryId, activeOnly: false, cancellationToken); + if (serviceCount > 0) + return Result.Failure($"Cannot delete category with {serviceCount} service(s). Remove or reassign services first."); + + await categoryRepository.DeleteAsync(categoryId, cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Commands/ServiceCategory/UpdateServiceCategoryCommandHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/ServiceCategory/UpdateServiceCategoryCommandHandler.cs new file mode 100644 index 000000000..afd8901a2 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Commands/ServiceCategory/UpdateServiceCategoryCommandHandler.cs @@ -0,0 +1,47 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Exceptions; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Commands.ServiceCategory; + +public sealed class UpdateServiceCategoryCommandHandler( + IServiceCategoryRepository categoryRepository) + : ICommandHandler +{ + public async Task HandleAsync(UpdateServiceCategoryCommand request, CancellationToken cancellationToken = default) + { + try + { + if (request.Id == Guid.Empty) + return Result.Failure("Category ID cannot be empty."); + + var categoryId = ServiceCategoryId.From(request.Id); + var category = await categoryRepository.GetByIdAsync(categoryId, cancellationToken); + + if (category is null) + return Result.Failure($"Category with ID '{request.Id}' not found."); + + var normalizedName = request.Name?.Trim(); + + if (string.IsNullOrWhiteSpace(normalizedName)) + return Result.Failure("Category name cannot be empty."); + + // Verificar se já existe categoria com o mesmo nome (excluindo a categoria atual) + if (await categoryRepository.ExistsWithNameAsync(normalizedName, categoryId, cancellationToken)) + return Result.Failure($"A category with name '{normalizedName}' already exists."); + + category.Update(normalizedName, request.Description, request.DisplayOrder); + + await categoryRepository.UpdateAsync(category, cancellationToken); + + return Result.Success(); + } + catch (CatalogDomainException ex) + { + return Result.Failure(ex.Message); + } + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Queries/Service/GetAllServicesQueryHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Queries/Service/GetAllServicesQueryHandler.cs new file mode 100644 index 000000000..301b5af91 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Queries/Service/GetAllServicesQueryHandler.cs @@ -0,0 +1,23 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Mappings; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.Service; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Queries.Service; + +public sealed class GetAllServicesQueryHandler(IServiceRepository repository) + : IQueryHandler>> +{ + public async Task>> HandleAsync( + GetAllServicesQuery request, + CancellationToken cancellationToken = default) + { + var services = await repository.GetAllAsync(request.ActiveOnly, cancellationToken); + + var dtos = services.Select(s => s.ToListDto()).ToList(); + + return Result>.Success(dtos); + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Queries/Service/GetServiceByIdQueryHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Queries/Service/GetServiceByIdQueryHandler.cs new file mode 100644 index 000000000..5e28c87be --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Queries/Service/GetServiceByIdQueryHandler.cs @@ -0,0 +1,33 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Mappings; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.Service; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Queries.Service; + +public sealed class GetServiceByIdQueryHandler(IServiceRepository repository) + : IQueryHandler> +{ + public async Task> HandleAsync( + GetServiceByIdQuery request, + CancellationToken cancellationToken = default) + { + // Treat Guid.Empty as validation error for consistency with command handlers + if (request.Id == Guid.Empty) + return Result.Failure("Service ID cannot be empty."); + + var serviceId = ServiceId.From(request.Id); + var service = await repository.GetByIdAsync(serviceId, cancellationToken); + + if (service is null) + return Result.Success(null); + + // Nota: A propriedade de navegação Category deve ser carregada pelo repositório + var dto = service.ToDto(); + + return Result.Success(dto); + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Queries/Service/GetServicesByCategoryQueryHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Queries/Service/GetServicesByCategoryQueryHandler.cs new file mode 100644 index 000000000..8a49c40b4 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Queries/Service/GetServicesByCategoryQueryHandler.cs @@ -0,0 +1,28 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Mappings; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.Service; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Queries.Service; + +public sealed class GetServicesByCategoryQueryHandler(IServiceRepository repository) + : IQueryHandler>> +{ + public async Task>> HandleAsync( + GetServicesByCategoryQuery request, + CancellationToken cancellationToken = default) + { + if (request.CategoryId == Guid.Empty) + return Result>.Success(Array.Empty()); + + var categoryId = ServiceCategoryId.From(request.CategoryId); + var services = await repository.GetByCategoryAsync(categoryId, request.ActiveOnly, cancellationToken); + + var dtos = services.Select(s => s.ToListDto()).ToList(); + + return Result>.Success(dtos); + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Queries/ServiceCategory/GetAllServiceCategoriesQueryHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Queries/ServiceCategory/GetAllServiceCategoriesQueryHandler.cs new file mode 100644 index 000000000..0ae287589 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Queries/ServiceCategory/GetAllServiceCategoriesQueryHandler.cs @@ -0,0 +1,23 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Mappings; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.ServiceCategory; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Queries.ServiceCategory; + +public sealed class GetAllServiceCategoriesQueryHandler(IServiceCategoryRepository repository) + : IQueryHandler>> +{ + public async Task>> HandleAsync( + GetAllServiceCategoriesQuery request, + CancellationToken cancellationToken = default) + { + var categories = await repository.GetAllAsync(request.ActiveOnly, cancellationToken); + + var dtos = categories.Select(c => c.ToDto()).ToList(); + + return Result>.Success(dtos); + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Queries/ServiceCategory/GetServiceCategoriesWithCountQueryHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Queries/ServiceCategory/GetServiceCategoriesWithCountQueryHandler.cs new file mode 100644 index 000000000..36e32012d --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Queries/ServiceCategory/GetServiceCategoriesWithCountQueryHandler.cs @@ -0,0 +1,50 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.ServiceCategory; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Queries.ServiceCategory; + +public sealed class GetServiceCategoriesWithCountQueryHandler( + IServiceCategoryRepository categoryRepository, + IServiceRepository serviceRepository) + : IQueryHandler>> +{ + public async Task>> HandleAsync( + GetServiceCategoriesWithCountQuery request, + CancellationToken cancellationToken = default) + { + var categories = await categoryRepository.GetAllAsync(request.ActiveOnly, cancellationToken); + + var dtos = new List(); + + // NOTA: Isso executa 2 * N consultas de contagem (uma para total, uma para ativo por categoria). + // Para catálogos pequenos a médios isso é aceitável. Se isso se tornar um gargalo de performance + // com muitas categorias, considere otimizar com uma consulta em lote ou agrupamento no repositório. + foreach (var category in categories) + { + var totalCount = await serviceRepository.CountByCategoryAsync( + category.Id, + activeOnly: false, + cancellationToken); + + var activeCount = await serviceRepository.CountByCategoryAsync( + category.Id, + activeOnly: true, + cancellationToken); + + dtos.Add(new ServiceCategoryWithCountDto( + category.Id.Value, + category.Name, + category.Description, + category.IsActive, + category.DisplayOrder, + activeCount, + totalCount + )); + } + + return Result>.Success(dtos); + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Handlers/Queries/ServiceCategory/GetServiceCategoryByIdQueryHandler.cs b/src/Modules/ServiceCatalogs/Application/Handlers/Queries/ServiceCategory/GetServiceCategoryByIdQueryHandler.cs new file mode 100644 index 000000000..0b15b4ffd --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Handlers/Queries/ServiceCategory/GetServiceCategoryByIdQueryHandler.cs @@ -0,0 +1,31 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Mappings; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.ServiceCategory; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Queries.ServiceCategory; + +public sealed class GetServiceCategoryByIdQueryHandler(IServiceCategoryRepository repository) + : IQueryHandler> +{ + public async Task> HandleAsync( + GetServiceCategoryByIdQuery request, + CancellationToken cancellationToken = default) + { + if (request.Id == Guid.Empty) + return Result.Failure("Service Category ID cannot be empty."); + + var categoryId = ServiceCategoryId.From(request.Id); + var category = await repository.GetByIdAsync(categoryId, cancellationToken); + + if (category is null) + return Result.Success(null); + + var dto = category.ToDto(); + + return Result.Success(dto); + } +} diff --git a/src/Modules/ServiceCatalogs/Application/Mappings/DtoMappingExtensions.cs b/src/Modules/ServiceCatalogs/Application/Mappings/DtoMappingExtensions.cs new file mode 100644 index 000000000..3f87c9781 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Mappings/DtoMappingExtensions.cs @@ -0,0 +1,55 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Entities; +using MeAjudaAi.Shared.Constants; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Mappings; + +/// +/// Extension methods for mapping domain entities to DTOs. +/// Centralizes mapping logic to avoid duplication across handlers. +/// +public static class DtoMappingExtensions +{ + /// + /// Maps a Service entity to a ServiceListDto. + /// + public static ServiceListDto ToListDto(this Service service) + => new( + service.Id.Value, + service.CategoryId.Value, + service.Name, + service.Description, + service.IsActive); + + /// + /// Maps a Service entity to a ServiceDto. + /// + public static ServiceDto ToDto(this Service service) + { + var categoryName = service.Category?.Name ?? ValidationMessages.Catalogs.UnknownCategoryName; + + return new ServiceDto( + service.Id.Value, + service.CategoryId.Value, + categoryName, + service.Name, + service.Description, + service.IsActive, + service.DisplayOrder, + service.CreatedAt, + service.UpdatedAt); + } + + /// + /// Maps a ServiceCategory entity to a ServiceCategoryDto. + /// + public static ServiceCategoryDto ToDto(this ServiceCategory category) + => new( + category.Id.Value, + category.Name, + category.Description, + category.IsActive, + category.DisplayOrder, + category.CreatedAt, + category.UpdatedAt); +} diff --git a/src/Modules/ServiceCatalogs/Application/MeAjudaAi.Modules.ServiceCatalogs.Application.csproj b/src/Modules/ServiceCatalogs/Application/MeAjudaAi.Modules.ServiceCatalogs.Application.csproj new file mode 100644 index 000000000..06f62376f --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/MeAjudaAi.Modules.ServiceCatalogs.Application.csproj @@ -0,0 +1,20 @@ + + + + net9.0 + enable + enable + + + + + <_Parameter1>MeAjudaAi.Modules.ServiceCatalogs.Tests + + + + + + + + + diff --git a/src/Modules/ServiceCatalogs/Application/ModuleApi/ServiceCatalogsModuleApi.cs b/src/Modules/ServiceCatalogs/Application/ModuleApi/ServiceCatalogsModuleApi.cs new file mode 100644 index 000000000..162ab05c2 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/ModuleApi/ServiceCatalogsModuleApi.cs @@ -0,0 +1,308 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.Service; +using MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.ServiceCategory; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Constants; +using MeAjudaAi.Shared.Contracts.Modules; +// TODO Phase 2: Uncomment when shared contracts are added +// using MeAjudaAi.Shared.Contracts.Modules.ServiceCatalogs; +// using MeAjudaAi.Shared.Contracts.Modules.ServiceCatalogs.DTOs; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.ModuleApi; + +/// +/// Implementação da API pública para o módulo ServiceCatalogs. +/// TODO Phase 2: Uncomment IServiceCatalogsModuleApi interface when shared contracts are added +/// +[ModuleApi(ModuleMetadata.Name, ModuleMetadata.Version)] +public sealed class ServiceCatalogsModuleApi( + IServiceCategoryRepository categoryRepository, + IServiceRepository serviceRepository, + ILogger logger) // : IServiceCatalogsModuleApi +{ + private static class ModuleMetadata + { + public const string Name = "ServiceCatalogs"; + public const string Version = "1.0"; + } + + public string ModuleName => ModuleMetadata.Name; + public string ApiVersion => ModuleMetadata.Version; + + public async Task IsAvailableAsync(CancellationToken cancellationToken = default) + { + try + { + logger.LogDebug("Checking ServiceCatalogs module availability"); + + // Simple database connectivity test + var categories = await categoryRepository.GetAllAsync(activeOnly: true, cancellationToken); + + logger.LogDebug("ServiceCatalogs module is available and healthy"); + return true; + } + catch (OperationCanceledException) + { + logger.LogDebug("ServiceCatalogs module availability check was cancelled"); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking ServiceCatalogs module availability"); + return false; + } + } + + public async Task> GetServiceCategoryByIdAsync( + Guid categoryId, + CancellationToken cancellationToken = default) + { + try + { + if (categoryId == Guid.Empty) + return Result.Failure("Category id must be provided"); + + var id = ServiceCategoryId.From(categoryId); + var category = await categoryRepository.GetByIdAsync(id, cancellationToken); + + if (category is null) + return Result.Success(null); + + var dto = new ModuleServiceCategoryDto( + category.Id.Value, + category.Name, + category.Description, + category.IsActive, + category.DisplayOrder + ); + + return Result.Success(dto); + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving service category {CategoryId}", categoryId); + return Result.Failure($"Error retrieving service category: {ex.Message}"); + } + } + + public async Task>> GetAllServiceCategoriesAsync( + bool activeOnly = true, + CancellationToken cancellationToken = default) + { + try + { + var categories = await categoryRepository.GetAllAsync(activeOnly, cancellationToken); + + var dtos = categories.Select(c => new ModuleServiceCategoryDto( + c.Id.Value, + c.Name, + c.Description, + c.IsActive, + c.DisplayOrder + )).ToList(); + + return Result>.Success(dtos); + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving service categories"); + return Result>.Failure($"Error retrieving service categories: {ex.Message}"); + } + } + + public async Task> GetServiceByIdAsync( + Guid serviceId, + CancellationToken cancellationToken = default) + { + try + { + if (serviceId == Guid.Empty) + return Result.Failure("Service id must be provided"); + + var id = ServiceId.From(serviceId); + var service = await serviceRepository.GetByIdAsync(id, cancellationToken); + + if (service is null) + return Result.Success(null); + + var categoryName = service.Category?.Name ?? ValidationMessages.Catalogs.UnknownCategoryName; + + var dto = new ModuleServiceDto( + service.Id.Value, + service.CategoryId.Value, + categoryName, + service.Name, + service.Description, + service.IsActive, + service.DisplayOrder + ); + + return Result.Success(dto); + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving service {ServiceId}", serviceId); + return Result.Failure($"Error retrieving service: {ex.Message}"); + } + } + + public async Task>> GetAllServicesAsync( + bool activeOnly = true, + CancellationToken cancellationToken = default) + { + try + { + var services = await serviceRepository.GetAllAsync(activeOnly, cancellationToken); + + var dtos = services.Select(s => new ModuleServiceListDto( + s.Id.Value, + s.Name, + s.Description, + s.IsActive + )).ToList(); + + return Result>.Success(dtos); + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving services"); + return Result>.Failure($"Error retrieving services: {ex.Message}"); + } + } + + public async Task>> GetServicesByCategoryAsync( + Guid categoryId, + bool activeOnly = true, + CancellationToken cancellationToken = default) + { + try + { + if (categoryId == Guid.Empty) + return Result>.Failure("Category id must be provided"); + + var id = ServiceCategoryId.From(categoryId); + var services = await serviceRepository.GetByCategoryAsync(id, activeOnly, cancellationToken); + + var dtos = services.Select(s => new ModuleServiceDto( + s.Id.Value, + s.CategoryId.Value, + s.Category?.Name ?? ValidationMessages.Catalogs.UnknownCategoryName, + s.Name, + s.Description, + s.IsActive, + s.DisplayOrder + )).ToList(); + + return Result>.Success(dtos); + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving services for category {CategoryId}", categoryId); + return Result>.Failure($"Error retrieving services: {ex.Message}"); + } + } + + public async Task> IsServiceActiveAsync( + Guid serviceId, + CancellationToken cancellationToken = default) + { + try + { + if (serviceId == Guid.Empty) + return Result.Failure("Service id must be provided"); + + var serviceIdValue = ServiceId.From(serviceId); + var service = await serviceRepository.GetByIdAsync(serviceIdValue, cancellationToken); + + // Return false for not-found to align with query semantics (vs Failure) + if (service is null) + return Result.Success(false); + + return Result.Success(service.IsActive); + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking if service {ServiceId} is active", serviceId); + return Result.Failure($"Error checking service status: {ex.Message}"); + } + } + + public async Task> ValidateServicesAsync( + IReadOnlyCollection serviceIds, + CancellationToken cancellationToken = default) + { + try + { + if (serviceIds is null) + return Result.Failure("Service IDs collection cannot be null"); + + // Short-circuit for empty collection + if (serviceIds.Count == 0) + { + return Result.Success( + new ModuleServiceValidationResultDto(true, [], [])); + } + + var invalidIds = new List(); + var inactiveIds = new List(); + + // Deduplicate input IDs and separate empty GUIDs + var distinctIds = serviceIds.Distinct().ToList(); + var validGuids = new List(); + + foreach (var id in distinctIds) + { + if (id == Guid.Empty) + { + invalidIds.Add(id); + } + else + { + validGuids.Add(id); + } + } + + // Only convert non-empty GUIDs to ServiceId value objects + if (validGuids.Count > 0) + { + var serviceIdValues = validGuids.Select(ServiceId.From).ToList(); + + // Batch query to avoid N+1 problem + var services = await serviceRepository.GetByIdsAsync(serviceIdValues, cancellationToken); + var serviceLookup = services.ToDictionary(s => s.Id.Value); + + foreach (var serviceId in validGuids) + { + if (!serviceLookup.TryGetValue(serviceId, out var service)) + { + invalidIds.Add(serviceId); + } + else if (!service.IsActive) + { + inactiveIds.Add(serviceId); + } + } + } + + var allValid = invalidIds.Count == 0 && inactiveIds.Count == 0; + + var result = new ModuleServiceValidationResultDto( + allValid, + [.. invalidIds], + [.. inactiveIds] + ); + + return Result.Success(result); + } + catch (Exception ex) + { + logger.LogError(ex, "Error validating services"); + return Result.Failure($"Error validating services: {ex.Message}"); + } + } +} diff --git a/src/Modules/ServiceCatalogs/Application/ModuleApi/TemporaryModuleDtos.cs b/src/Modules/ServiceCatalogs/Application/ModuleApi/TemporaryModuleDtos.cs new file mode 100644 index 000000000..952357d84 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/ModuleApi/TemporaryModuleDtos.cs @@ -0,0 +1,47 @@ +// TODO Phase 2: Remove this file when proper shared contracts are added in MeAjudaAi.Shared.Contracts.Modules.ServiceCatalogs.DTOs +// These are temporary placeholders to allow Phase 1b to compile without breaking the module API pattern + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.ModuleApi; + +/// +/// Temporary DTO - will be replaced by shared contract in Phase 2 +/// +public sealed record ModuleServiceCategoryDto( + Guid Id, + string Name, + string? Description, + bool IsActive, + int DisplayOrder +); + +/// +/// Temporary DTO - will be replaced by shared contract in Phase 2 +/// +public sealed record ModuleServiceDto( + Guid Id, + Guid CategoryId, + string CategoryName, + string Name, + string? Description, + bool IsActive, + int DisplayOrder +); + +/// +/// Temporary DTO - will be replaced by shared contract in Phase 2 +/// +public sealed record ModuleServiceListDto( + Guid Id, + string Name, + string? Description, + bool IsActive +); + +/// +/// Temporary DTO - will be replaced by shared contract in Phase 2 +/// +public sealed record ModuleServiceValidationResultDto( + bool AllValid, + IReadOnlyCollection InvalidServiceIds, + IReadOnlyCollection InactiveServiceIds +); diff --git a/src/Modules/ServiceCatalogs/Application/Queries/Service/GetAllServicesQuery.cs b/src/Modules/ServiceCatalogs/Application/Queries/Service/GetAllServicesQuery.cs new file mode 100644 index 000000000..ce43d0494 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Queries/Service/GetAllServicesQuery.cs @@ -0,0 +1,8 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.Service; + +public sealed record GetAllServicesQuery(bool ActiveOnly = false) + : Query>>; diff --git a/src/Modules/ServiceCatalogs/Application/Queries/Service/GetServiceByIdQuery.cs b/src/Modules/ServiceCatalogs/Application/Queries/Service/GetServiceByIdQuery.cs new file mode 100644 index 000000000..4906e923a --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Queries/Service/GetServiceByIdQuery.cs @@ -0,0 +1,8 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.Service; + +public sealed record GetServiceByIdQuery(Guid Id) + : Query>; diff --git a/src/Modules/ServiceCatalogs/Application/Queries/Service/GetServicesByCategoryQuery.cs b/src/Modules/ServiceCatalogs/Application/Queries/Service/GetServicesByCategoryQuery.cs new file mode 100644 index 000000000..ffe18d3b9 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Queries/Service/GetServicesByCategoryQuery.cs @@ -0,0 +1,11 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.Service; + +/// +/// Query to retrieve all services within a specific category. +/// +public sealed record GetServicesByCategoryQuery(Guid CategoryId, bool ActiveOnly = false) + : Query>>; diff --git a/src/Modules/ServiceCatalogs/Application/Queries/ServiceCategory/GetAllServiceCategoriesQuery.cs b/src/Modules/ServiceCatalogs/Application/Queries/ServiceCategory/GetAllServiceCategoriesQuery.cs new file mode 100644 index 000000000..d66fbf4ad --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Queries/ServiceCategory/GetAllServiceCategoriesQuery.cs @@ -0,0 +1,8 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.ServiceCategory; + +public sealed record GetAllServiceCategoriesQuery(bool ActiveOnly = false) + : Query>>; diff --git a/src/Modules/ServiceCatalogs/Application/Queries/ServiceCategory/GetServiceCategoriesWithCountQuery.cs b/src/Modules/ServiceCatalogs/Application/Queries/ServiceCategory/GetServiceCategoriesWithCountQuery.cs new file mode 100644 index 000000000..1207fca32 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Queries/ServiceCategory/GetServiceCategoriesWithCountQuery.cs @@ -0,0 +1,11 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.ServiceCategory; + +/// +/// Query to retrieve service categories with their service counts. +/// +public sealed record GetServiceCategoriesWithCountQuery(bool ActiveOnly = false) + : Query>>; diff --git a/src/Modules/ServiceCatalogs/Application/Queries/ServiceCategory/GetServiceCategoryByIdQuery.cs b/src/Modules/ServiceCatalogs/Application/Queries/ServiceCategory/GetServiceCategoryByIdQuery.cs new file mode 100644 index 000000000..8fdb89504 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Application/Queries/ServiceCategory/GetServiceCategoryByIdQuery.cs @@ -0,0 +1,11 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Application.DTOs; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.ServiceCategory; + +/// +/// Query to retrieve a service category by its identifier. +/// +public sealed record GetServiceCategoryByIdQuery(Guid Id) + : Query>;