diff --git a/src/Category/Category.csproj b/src/Category/Category.csproj new file mode 100644 index 000000000..64ede4c9f --- /dev/null +++ b/src/Category/Category.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/src/Category/CategoryModule.cs b/src/Category/CategoryModule.cs new file mode 100644 index 000000000..4c53c067c --- /dev/null +++ b/src/Category/CategoryModule.cs @@ -0,0 +1,45 @@ +using Carter; +using Category.Domain; +using Category.Features.Create.v1; +using Category.Features.Delete.v1; +using Category.Features.Get.v1; +using Category.Features.GetList.v1; +using Category.Features.Update.v1; +using Category.Persistence; +using FSH.Framework.Core.Persistence; +using FSH.Framework.Infrastructure.Persistence; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +namespace Category; + +public static class CategoryModule +{ + + public class Endpoints : CarterModule + { + public override void AddRoutes(IEndpointRouteBuilder app) + { + var categoryItemGroup = app.MapGroup("categoryItems").WithTags("categoryItems"); + categoryItemGroup.MapCategoryItemCreationEndpoint(); + categoryItemGroup.MapGetCategoryItemEndpoint(); + categoryItemGroup.MapGetCategoryItemListEndpoint(); + categoryItemGroup.MapCategoryItemUpdationEndpoint(); + categoryItemGroup.MapCategoryItemDeletionEndpoint(); + } + } + public static WebApplicationBuilder RegisterCategoryItemServices(this WebApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Services.BindDbContext(); + builder.Services.AddScoped(); + builder.Services.AddKeyedScoped, CategoryItemRepository>("categoryItem"); + builder.Services.AddKeyedScoped, CategoryItemRepository>("categoryItem"); + return builder; + } + public static WebApplication UseCategoryItemModule(this WebApplication app) + { + return app; + } +} diff --git a/src/Category/Domain/CategoryItem.cs b/src/Category/Domain/CategoryItem.cs new file mode 100644 index 000000000..125766d18 --- /dev/null +++ b/src/Category/Domain/CategoryItem.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Category.Domain.Events; +using FSH.Framework.Core.Domain; +using FSH.Framework.Core.Domain.Contracts; + +namespace Category.Domain; + +public sealed class CategoryItem : AuditableEntity, IAggregateRoot +{ + public string Name { get; private set; } = string.Empty; + public string Description { get; private set; } = string.Empty; + + private CategoryItem() { } + + private CategoryItem(string name, string description) + { + Name = name; + Description = description; + QueueDomainEvent(new CategoryItemCreated(Id, Name, Description)); + } + + public static CategoryItem Create(string name, string description) => new(name, description); + + public CategoryItem Update(string? name, string? description) + { + bool isUpdated = false; + + if (!string.IsNullOrWhiteSpace(name) && !string.Equals(Name, name, StringComparison.OrdinalIgnoreCase)) + { + Name = name; + isUpdated = true; + } + + if (!string.IsNullOrWhiteSpace(description) && !string.Equals(Description, description, StringComparison.OrdinalIgnoreCase)) + { + Description = description; + isUpdated = true; + } + + if (isUpdated) + { + QueueDomainEvent(new CategoryItemUpdated(this)); + } + + return this; + } +} diff --git a/src/Category/Domain/Events/CategoryItemCreated.cs b/src/Category/Domain/Events/CategoryItemCreated.cs new file mode 100644 index 000000000..daba999b7 --- /dev/null +++ b/src/Category/Domain/Events/CategoryItemCreated.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Category.Features.Get.v1; +using FSH.Framework.Core.Caching; +using FSH.Framework.Core.Domain.Events; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Category.Domain.Events; + +public record CategoryItemCreated(Guid Id, string Name, string Description) : DomainEvent; + +public class CategoryItemCreatedEventHandler( + ILogger logger, + ICacheService cache) + : INotificationHandler +{ + public async Task Handle(CategoryItemCreated notification, CancellationToken cancellationToken) + { + logger.LogInformation("handling categoryItem item created domain event.."); + var cacheResponse = new GetCategoryItemResponse(notification.Id, notification.Name, notification.Description); + await cache.SetAsync($"categoryItem:{notification.Id}", cacheResponse, cancellationToken: cancellationToken); + } +} diff --git a/src/Category/Domain/Events/CategoryItemUpdated.cs b/src/Category/Domain/Events/CategoryItemUpdated.cs new file mode 100644 index 000000000..b32d25a34 --- /dev/null +++ b/src/Category/Domain/Events/CategoryItemUpdated.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Category.Features.Get.v1; +using FSH.Framework.Core.Caching; +using FSH.Framework.Core.Domain.Events; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Category.Domain.Events; + +public record CategoryItemUpdated(CategoryItem item) : DomainEvent; + +public class CategoryItemUpdatedEventHandler( + ILogger logger, + ICacheService cache) + : INotificationHandler +{ + public async Task Handle(CategoryItemUpdated notification, CancellationToken cancellationToken) + { + logger.LogInformation("handling CategoryItem update domain event.."); + var cacheResponse = new GetCategoryItemResponse(notification.item.Id, notification.item.Name, notification.item.Description ); + await cache.SetAsync($"categoryItem:{notification.item.Id}", cacheResponse, cancellationToken: cancellationToken); + } +} diff --git a/src/Category/Exceptions/CategoryItemNotFoundException.cs b/src/Category/Exceptions/CategoryItemNotFoundException.cs new file mode 100644 index 000000000..ddf85209b --- /dev/null +++ b/src/Category/Exceptions/CategoryItemNotFoundException.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FSH.Framework.Core.Exceptions; + +namespace Category.Exceptions; + +internal sealed class CategoryItemNotFoundException : NotFoundException +{ + public CategoryItemNotFoundException(Guid id) + : base($"category item with id {id} not found") + { + } +} diff --git a/src/Category/Features/Create/v1/CreateCategoryItemCommand.cs b/src/Category/Features/Create/v1/CreateCategoryItemCommand.cs new file mode 100644 index 000000000..9c12d772a --- /dev/null +++ b/src/Category/Features/Create/v1/CreateCategoryItemCommand.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using MediatR; + +namespace Category.Features.Create.v1; + +public record CreateCategoryItemCommand( + [property: DefaultValue("Hello World!")] string Name, + [property: DefaultValue("Important Description.")] string Description) : IRequest; + + diff --git a/src/Category/Features/Create/v1/CreateCategoryItemEndPoint.cs b/src/Category/Features/Create/v1/CreateCategoryItemEndPoint.cs new file mode 100644 index 000000000..a1696e067 --- /dev/null +++ b/src/Category/Features/Create/v1/CreateCategoryItemEndPoint.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Asp.Versioning; +using FSH.Framework.Infrastructure.Auth.Policy; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Category.Features.Create.v1; + +public static class CreateCategoryItemEndPoint +{ + internal static RouteHandlerBuilder MapCategoryItemCreationEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/", async (CreateCategoryItemCommand request, ISender mediator) => + { + var response = await mediator.Send(request); + return Results.CreatedAtRoute(nameof(CreateCategoryItemEndPoint), new { id = response.Id }, response); + }) + .WithName(nameof(CreateCategoryItemEndPoint)) + .WithSummary("Creates a CategoryItem item") + .WithDescription("Creates a CategoryItem item") + .Produces(StatusCodes.Status201Created) + .RequirePermission("Permissions.CategoryItems.Create") + .MapToApiVersion(new ApiVersion(1, 0)); + + } +} diff --git a/src/Category/Features/Create/v1/CreateCategoryItemHandler.cs b/src/Category/Features/Create/v1/CreateCategoryItemHandler.cs new file mode 100644 index 000000000..81257345a --- /dev/null +++ b/src/Category/Features/Create/v1/CreateCategoryItemHandler.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Category.Domain; +using FSH.Framework.Core.Persistence; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Category.Features.Create.v1; + +public sealed class CreateCategoryItemHandler( + ILogger logger, + [FromKeyedServices("categoryItem")] IRepository repository) + : IRequestHandler +{ + public async Task Handle(CreateCategoryItemCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + var item = CategoryItem.Create(request.Name, request.Description); + await repository.AddAsync(item, cancellationToken).ConfigureAwait(false); + await repository.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + logger.LogInformation("CategoryItem item created {CategoryItemId}", item.Id); + return new CreateCategoryItemResponse(item.Id); + } +} diff --git a/src/Category/Features/Create/v1/CreateCategoryItemResponse.cs b/src/Category/Features/Create/v1/CreateCategoryItemResponse.cs new file mode 100644 index 000000000..4c3896004 --- /dev/null +++ b/src/Category/Features/Create/v1/CreateCategoryItemResponse.cs @@ -0,0 +1,9 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Category.Features.Create.v1; + +public record CreateCategoryItemResponse(Guid? Id); diff --git a/src/Category/Features/Create/v1/CreateCategoryItemValidator.cs b/src/Category/Features/Create/v1/CreateCategoryItemValidator.cs new file mode 100644 index 000000000..a751d6276 --- /dev/null +++ b/src/Category/Features/Create/v1/CreateCategoryItemValidator.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Category.Persistence; +using FluentValidation; + +namespace Category.Features.Create.v1; + +public class CreateCategoryItemValidator : AbstractValidator +{ + public CreateCategoryItemValidator(CategoryItemDbContext context) + { + RuleFor(p => p.Name).NotEmpty(); + RuleFor(p => p.Description).NotEmpty(); + } +} diff --git a/src/Category/Features/Delete/v1/DeleteCategoryItemCommand.cs b/src/Category/Features/Delete/v1/DeleteCategoryItemCommand.cs new file mode 100644 index 000000000..12c19bb9c --- /dev/null +++ b/src/Category/Features/Delete/v1/DeleteCategoryItemCommand.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using MediatR; + +namespace Category.Features.Delete.v1; + +public sealed record DeleteCategoryItemCommand( + Guid Id) : IRequest; + + + diff --git a/src/Category/Features/Delete/v1/DeleteCategoryItemEndpoint.cs b/src/Category/Features/Delete/v1/DeleteCategoryItemEndpoint.cs new file mode 100644 index 000000000..32e90f632 --- /dev/null +++ b/src/Category/Features/Delete/v1/DeleteCategoryItemEndpoint.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Asp.Versioning; +using FSH.Framework.Infrastructure.Auth.Policy; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Category.Features.Delete.v1; + +public static class DeleteCategoryItemEndpoint +{ + internal static RouteHandlerBuilder MapCategoryItemDeletionEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapDelete("/{id:guid}", async (Guid id, ISender mediator) => + { + await mediator.Send(new DeleteCategoryItemCommand(id)); + return Results.NoContent(); + }) + .WithName(nameof(DeleteCategoryItemEndpoint)) + .WithSummary("Deletes a category item") + .WithDescription("Deleted a category item") + .Produces(StatusCodes.Status204NoContent) + .RequirePermission("Permissions.CategoryItems.Delete") + .MapToApiVersion(new ApiVersion(1, 0)); + + } +} diff --git a/src/Category/Features/Delete/v1/DeleteCategoryItemHandler.cs b/src/Category/Features/Delete/v1/DeleteCategoryItemHandler.cs new file mode 100644 index 000000000..9abeedf4c --- /dev/null +++ b/src/Category/Features/Delete/v1/DeleteCategoryItemHandler.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Category.Domain; +using Category.Exceptions; +using FSH.Framework.Core.Persistence; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Category.Features.Delete.v1; + +public sealed class DeleteCategoryItemHandler( + ILogger logger, + [FromKeyedServices("categoryItem")] IRepository repository) + : IRequestHandler +{ + public async Task Handle(DeleteCategoryItemCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + var categoryItem = await repository.GetByIdAsync(request.Id, cancellationToken); + _ = categoryItem ?? throw new CategoryItemNotFoundException(request.Id); + await repository.DeleteAsync(categoryItem, cancellationToken); + logger.LogInformation("categoryItem with id : {CategoryItemId} deleted", categoryItem.Id); + } +} diff --git a/src/Category/Features/Get/v1/GetCategoryItemEndpoint.cs b/src/Category/Features/Get/v1/GetCategoryItemEndpoint.cs new file mode 100644 index 000000000..4ba80e9df --- /dev/null +++ b/src/Category/Features/Get/v1/GetCategoryItemEndpoint.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FSH.Framework.Infrastructure.Auth.Policy; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Category.Features.Get.v1; + +public static class GetCategoryItemEndpoint +{ + internal static RouteHandlerBuilder MapGetCategoryItemEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/{id:guid}", async (Guid id, ISender mediator) => + { + var response = await mediator.Send(new GetCategoryItemRequest(id)); + return Results.Ok(response); + }) + .WithName(nameof(GetCategoryItemEndpoint)) + .WithSummary("gets category item by id") + .WithDescription("gets category item by id") + .Produces() + .RequirePermission("Permissions.CategoryItems.View") + .MapToApiVersion(1); + } +} diff --git a/src/Category/Features/Get/v1/GetCategoryItemHandler.cs b/src/Category/Features/Get/v1/GetCategoryItemHandler.cs new file mode 100644 index 000000000..1c187521f --- /dev/null +++ b/src/Category/Features/Get/v1/GetCategoryItemHandler.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Category.Domain; +using Category.Exceptions; +using FSH.Framework.Core.Caching; +using FSH.Framework.Core.Persistence; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace Category.Features.Get.v1; + +public sealed class GetCategoryItemHandler( + [FromKeyedServices("categoryItem")] IReadRepository repository, + ICacheService cache) + : IRequestHandler +{ + public async Task Handle(GetCategoryItemRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + var item = await cache.GetOrSetAsync( + $"categoryItem:{request.Id}", + async () => + { + var categoryItem = await repository.GetByIdAsync(request.Id, cancellationToken); + if (categoryItem == null) throw new CategoryItemNotFoundException(request.Id); + return new GetCategoryItemResponse(categoryItem.Id, categoryItem.Name!, categoryItem.Description!); + }, + cancellationToken: cancellationToken); + return item!; + } +} diff --git a/src/Category/Features/Get/v1/GetCategoryItemRequest.cs b/src/Category/Features/Get/v1/GetCategoryItemRequest.cs new file mode 100644 index 000000000..5209a967a --- /dev/null +++ b/src/Category/Features/Get/v1/GetCategoryItemRequest.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using MediatR; + +namespace Category.Features.Get.v1; + +public class GetCategoryItemRequest : IRequest +{ + public Guid Id { get; set; } + public GetCategoryItemRequest(Guid id) => Id = id; +} diff --git a/src/Category/Features/Get/v1/GetCategoryItemResponse.cs b/src/Category/Features/Get/v1/GetCategoryItemResponse.cs new file mode 100644 index 000000000..95c60ac80 --- /dev/null +++ b/src/Category/Features/Get/v1/GetCategoryItemResponse.cs @@ -0,0 +1,9 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Category.Features.Get.v1; + +public record GetCategoryItemResponse(Guid? Id, string? Name, string? Description); diff --git a/src/Category/Features/GetList/v1/CategoryItemDto.cs b/src/Category/Features/GetList/v1/CategoryItemDto.cs new file mode 100644 index 000000000..688c41ef9 --- /dev/null +++ b/src/Category/Features/GetList/v1/CategoryItemDto.cs @@ -0,0 +1,9 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Category.Features.GetList.v1; + +public record CategoryItemDto(Guid? Id, string Name, string Description); diff --git a/src/Category/Features/GetList/v1/GetCategoryItemListEndpoint.cs b/src/Category/Features/GetList/v1/GetCategoryItemListEndpoint.cs new file mode 100644 index 000000000..61734987b --- /dev/null +++ b/src/Category/Features/GetList/v1/GetCategoryItemListEndpoint.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FSH.Framework.Core.Paging; +using FSH.Framework.Infrastructure.Auth.Policy; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace Category.Features.GetList.v1; + +public static class GetCategoryItemListEndpoint +{ + internal static RouteHandlerBuilder MapGetCategoryItemListEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/search", async (ISender mediator, [FromBody] PaginationFilter filter) => + { + var response = await mediator.Send(new GetCategoryItemListRequest(filter)); + return Results.Ok(response); + }) + .WithName(nameof(GetCategoryItemListEndpoint)) + .WithSummary("Gets a list of Category items with paging support") + .WithDescription("Gets a list of Category items with paging support") + .Produces>() + .RequirePermission("Permissions.CategoryItems.View") + .MapToApiVersion(1); + } +} diff --git a/src/Category/Features/GetList/v1/GetCategoryItemListHandler.cs b/src/Category/Features/GetList/v1/GetCategoryItemListHandler.cs new file mode 100644 index 000000000..de31d91a9 --- /dev/null +++ b/src/Category/Features/GetList/v1/GetCategoryItemListHandler.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Category.Domain; +using FSH.Framework.Core.Paging; +using FSH.Framework.Core.Persistence; +using FSH.Framework.Core.Specifications; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace Category.Features.GetList.v1; + +public sealed class GetCategoryItemListHandler( + [FromKeyedServices("categoryItem")] IReadRepository repository) + : IRequestHandler> +{ + public async Task> Handle(GetCategoryItemListRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var spec = new EntitiesByPaginationFilterSpec(request.Filter); + + var items = await repository.ListAsync(spec, cancellationToken).ConfigureAwait(false); + var totalCount = await repository.CountAsync(spec, cancellationToken).ConfigureAwait(false); + + return new PagedList(items, request.Filter.PageNumber, request.Filter.PageSize, totalCount); + } +} diff --git a/src/Category/Features/GetList/v1/GetCategoryItemListRequest.cs b/src/Category/Features/GetList/v1/GetCategoryItemListRequest.cs new file mode 100644 index 000000000..61e9affa1 --- /dev/null +++ b/src/Category/Features/GetList/v1/GetCategoryItemListRequest.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FSH.Framework.Core.Paging; +using MediatR; + +namespace Category.Features.GetList.v1; + +public record GetCategoryItemListRequest(PaginationFilter Filter) : IRequest>; diff --git a/src/Category/Features/Update/v1/UpdateCategoryItemCommand.cs b/src/Category/Features/Update/v1/UpdateCategoryItemCommand.cs new file mode 100644 index 000000000..a2f812c31 --- /dev/null +++ b/src/Category/Features/Update/v1/UpdateCategoryItemCommand.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using MediatR; + +namespace Category.Features.Update.v1; + +public sealed record UpdateCategoryItemCommand( + Guid Id, + string? Name, + string? Description = null) : IRequest; + + + diff --git a/src/Category/Features/Update/v1/UpdateCategoryItemEndpoint.cs b/src/Category/Features/Update/v1/UpdateCategoryItemEndpoint.cs new file mode 100644 index 000000000..1da55adf3 --- /dev/null +++ b/src/Category/Features/Update/v1/UpdateCategoryItemEndpoint.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Asp.Versioning; +using FSH.Framework.Infrastructure.Auth.Policy; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Category.Features.Update.v1; + +public static class UpdateCategoryItemEndpoint +{ + internal static RouteHandlerBuilder MapCategoryItemUpdationEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints. + MapPut("/{id:guid}", async (Guid id, UpdateCategoryItemCommand request, ISender mediator) => + { + if (id != request.Id) return Results.BadRequest(); + var response = await mediator.Send(request); + return Results.Ok(response); + }) + .WithName(nameof(UpdateCategoryItemEndpoint)) + .WithSummary("Updates a Category item") + .WithDescription("Updated a Category item") + .Produces(StatusCodes.Status200OK) + .RequirePermission("Permissions.CategoryItems.Update") + .MapToApiVersion(new ApiVersion(1, 0)); + + } +} diff --git a/src/Category/Features/Update/v1/UpdateCategoryItemHandler.cs b/src/Category/Features/Update/v1/UpdateCategoryItemHandler.cs new file mode 100644 index 000000000..cd4a2a454 --- /dev/null +++ b/src/Category/Features/Update/v1/UpdateCategoryItemHandler.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Category.Domain; +using Category.Exceptions; +using FSH.Framework.Core.Persistence; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Category.Features.Update.v1; + +public sealed class UpdateCategoryItemHandler( + ILogger logger, + [FromKeyedServices("categoryItem")] IRepository repository) + : IRequestHandler +{ + public async Task Handle(UpdateCategoryItemCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + var categoryItem = await repository.GetByIdAsync(request.Id, cancellationToken); + _ = categoryItem ?? throw new CategoryItemNotFoundException(request.Id); + var updatedCategoryItem = categoryItem.Update(request.Name, request.Description); + await repository.UpdateAsync(updatedCategoryItem, cancellationToken); + logger.LogInformation("categoryItem item updated {CategoryItemItemId}", updatedCategoryItem.Id); + return new UpdateCategoryItemResponse(updatedCategoryItem.Id); + } +} diff --git a/src/Category/Features/Update/v1/UpdateCategoryItemResponse.cs b/src/Category/Features/Update/v1/UpdateCategoryItemResponse.cs new file mode 100644 index 000000000..11b7bdb07 --- /dev/null +++ b/src/Category/Features/Update/v1/UpdateCategoryItemResponse.cs @@ -0,0 +1,9 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Category.Features.Update.v1; + +public record UpdateCategoryItemResponse(Guid? Id); diff --git a/src/Category/Features/Update/v1/UpdateCategoryItemValidator.cs b/src/Category/Features/Update/v1/UpdateCategoryItemValidator.cs new file mode 100644 index 000000000..0d3eccaf6 --- /dev/null +++ b/src/Category/Features/Update/v1/UpdateCategoryItemValidator.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Category.Persistence; +using FluentValidation; + +namespace Category.Features.Update.v1; + +public class UpdateCategoryItemValidator : AbstractValidator +{ + public UpdateCategoryItemValidator(CategoryItemDbContext context) + { + RuleFor(p => p.Name).NotEmpty(); + RuleFor(p => p.Description).NotEmpty(); + } +} diff --git a/src/Category/Persistence/CategoryItemDbContext.cs b/src/Category/Persistence/CategoryItemDbContext.cs new file mode 100644 index 000000000..4e76a995a --- /dev/null +++ b/src/Category/Persistence/CategoryItemDbContext.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection.Emit; +using System.Text; +using System.Threading.Tasks; +using Category.Domain; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Persistence; +using FSH.Framework.Infrastructure.Persistence; +using FSH.Framework.Infrastructure.Tenant; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Shared.Constants; + +namespace Category.Persistence; + +public sealed class CategoryItemDbContext : FshDbContext +{ + public CategoryItemDbContext(IMultiTenantContextAccessor multiTenantContextAccessor, DbContextOptions options, IPublisher publisher, IOptions settings) + : base(multiTenantContextAccessor, options, publisher, settings) + { + } + + public DbSet CategoryItems { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(CategoryItemDbContext).Assembly); + modelBuilder.HasDefaultSchema(SchemaNames.CategoryItem); + } +} diff --git a/src/Category/Persistence/CategoryItemDbInitializer.cs b/src/Category/Persistence/CategoryItemDbInitializer.cs new file mode 100644 index 000000000..a1d4c269b --- /dev/null +++ b/src/Category/Persistence/CategoryItemDbInitializer.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Category.Domain; +using FSH.Framework.Core.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Category.Persistence; + +internal sealed class CategoryItemDbInitializer( + ILogger logger, + CategoryItemDbContext context) : IDbInitializer +{ + public async Task MigrateAsync(CancellationToken cancellationToken) + { + if ((await context.Database.GetPendingMigrationsAsync(cancellationToken).ConfigureAwait(false)).Any()) + { + await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + logger.LogInformation("[{Tenant}] applied database migrations for CategoryItem module", context.TenantInfo!.Identifier); + } + } + + public async Task SeedAsync(CancellationToken cancellationToken) + { + const string name = "Hello World!"; + const string description = "This is your first task"; + if (await context.CategoryItems.FirstOrDefaultAsync(t => t.Name == name, cancellationToken).ConfigureAwait(false) is null) + { + var categoryItem = CategoryItem.Create(name , description); + await context.CategoryItems.AddAsync(categoryItem, cancellationToken); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + logger.LogInformation("[{Tenant}] seeding default categoryItem data", context.TenantInfo!.Identifier); + } + } +} diff --git a/src/Category/Persistence/CategoryItemRepository.cs b/src/Category/Persistence/CategoryItemRepository.cs new file mode 100644 index 000000000..870460681 --- /dev/null +++ b/src/Category/Persistence/CategoryItemRepository.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Ardalis.Specification.EntityFrameworkCore; +using Ardalis.Specification; +using FSH.Framework.Core.Domain.Contracts; +using FSH.Framework.Core.Persistence; +using Mapster; + +namespace Category.Persistence; + +internal sealed class CategoryItemRepository : RepositoryBase, IReadRepository, IRepository + where T : class, IAggregateRoot +{ + public CategoryItemRepository(CategoryItemDbContext context) + : base(context) + { + } + + // We override the default behavior when mapping to a dto. + // We're using Mapster's ProjectToType here to immediately map the result from the database. + // This is only done when no Selector is defined, so regular specifications with a selector also still work. + protected override IQueryable ApplySpecification(ISpecification specification) => + specification.Selector is not null + ? base.ApplySpecification(specification) + : ApplySpecification(specification, false) + .ProjectToType(); +} diff --git a/src/Category/Persistence/Configurations/CategoryItemConfiguration.cs b/src/Category/Persistence/Configurations/CategoryItemConfiguration.cs new file mode 100644 index 000000000..9f31b838f --- /dev/null +++ b/src/Category/Persistence/Configurations/CategoryItemConfiguration.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Category.Domain; +using Finbuckle.MultiTenant; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Category.Persistence.Configurations; + +internal sealed class CategoryItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.IsMultiTenant(); + builder.HasKey(x => x.Id); + builder.Property(x => x.Name).HasMaxLength(100); + builder.Property(x => x.Description).HasMaxLength(1000); + } +} diff --git a/src/FSH.Starter.sln b/src/FSH.Starter.sln index 904c59f77..21181d177 100644 --- a/src/FSH.Starter.sln +++ b/src/FSH.Starter.sln @@ -64,6 +64,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Auth", "Auth", "{F17769D7-0 GetToken.http = GetToken.http EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Category", "Category", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Category", "Category\Category.csproj", "{902D2705-E1A7-40C0-934A-436E66B2EED0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -254,6 +258,18 @@ Global {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Release|x64.Build.0 = Release|Any CPU {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Release|x86.ActiveCfg = Release|Any CPU {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Release|x86.Build.0 = Release|Any CPU + {902D2705-E1A7-40C0-934A-436E66B2EED0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {902D2705-E1A7-40C0-934A-436E66B2EED0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {902D2705-E1A7-40C0-934A-436E66B2EED0}.Debug|x64.ActiveCfg = Debug|Any CPU + {902D2705-E1A7-40C0-934A-436E66B2EED0}.Debug|x64.Build.0 = Debug|Any CPU + {902D2705-E1A7-40C0-934A-436E66B2EED0}.Debug|x86.ActiveCfg = Debug|Any CPU + {902D2705-E1A7-40C0-934A-436E66B2EED0}.Debug|x86.Build.0 = Debug|Any CPU + {902D2705-E1A7-40C0-934A-436E66B2EED0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {902D2705-E1A7-40C0-934A-436E66B2EED0}.Release|Any CPU.Build.0 = Release|Any CPU + {902D2705-E1A7-40C0-934A-436E66B2EED0}.Release|x64.ActiveCfg = Release|Any CPU + {902D2705-E1A7-40C0-934A-436E66B2EED0}.Release|x64.Build.0 = Release|Any CPU + {902D2705-E1A7-40C0-934A-436E66B2EED0}.Release|x86.ActiveCfg = Release|Any CPU + {902D2705-E1A7-40C0-934A-436E66B2EED0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -280,6 +296,8 @@ Global {2119CE89-308D-4932-BFCE-8CDC0A05EB9E} = {D36E77BC-4568-4BC8-9506-1EFB7B1CD335} {FE1B1E84-F993-4840-9CAB-9082EB523FDD} = {CE64E92B-E088-46FB-9028-7FB6B67DEC55} {F17769D7-0E41-4E80-BDD4-282EBE7B5199} = {FE1B1E84-F993-4840-9CAB-9082EB523FDD} + {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {F3DF5AC5-8CDC-46D4-969D-1245A6880215} + {902D2705-E1A7-40C0-934A-436E66B2EED0} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EA8248C2-3877-4AF7-8777-A17E7881E030} diff --git a/src/Shared/Authorization/FshPermissions.cs b/src/Shared/Authorization/FshPermissions.cs index fad6676ee..dad9800a2 100644 --- a/src/Shared/Authorization/FshPermissions.cs +++ b/src/Shared/Authorization/FshPermissions.cs @@ -57,6 +57,14 @@ public static class FshPermissions //audit new("View Audit Trails", FshActions.View, FshResources.AuditTrails), + + //categoryItems + new("View CategoryItems", FshActions.View, FshResources.CategoryItems, IsBasic: true), + new("Search CategoryItems", FshActions.Search, FshResources.CategoryItems, IsBasic: true), + new("Create CategoryItems", FshActions.Create, FshResources.CategoryItems), + new("Update CategoryItems", FshActions.Update, FshResources.CategoryItems), + new("Delete CategoryItems", FshActions.Delete, FshResources.CategoryItems), + new("Export CategoryItems", FshActions.Export, FshResources.CategoryItems), ]; public static IReadOnlyList All { get; } = new ReadOnlyCollection(AllPermissions); diff --git a/src/Shared/Authorization/FshResources.cs b/src/Shared/Authorization/FshResources.cs index e8d276c47..735e92a39 100644 --- a/src/Shared/Authorization/FshResources.cs +++ b/src/Shared/Authorization/FshResources.cs @@ -12,4 +12,5 @@ public static class FshResources public const string Brands = nameof(Brands); public const string Todos = nameof(Todos); public const string AuditTrails = nameof(AuditTrails); + public const string CategoryItems = nameof(CategoryItems); } diff --git a/src/Shared/Constants/SchemaNames.cs b/src/Shared/Constants/SchemaNames.cs index 6f6763a8b..dcbb651b8 100644 --- a/src/Shared/Constants/SchemaNames.cs +++ b/src/Shared/Constants/SchemaNames.cs @@ -4,4 +4,5 @@ public static class SchemaNames public const string Todo = "todo"; public const string Catalog = "catalog"; public const string Tenant = "tenant"; + public const string CategoryItem = "categoryItem"; } diff --git a/src/api/server/Server.csproj b/src/api/server/Server.csproj index 11c255c90..dd5563554 100644 --- a/src/api/server/Server.csproj +++ b/src/api/server/Server.csproj @@ -16,6 +16,7 @@ +