diff --git a/backend/src/CCE.Api.External/Endpoints/TopicsPublicEndpoints.cs b/backend/src/CCE.Api.External/Endpoints/TopicsPublicEndpoints.cs index 2e9ad9e7..7c484cf3 100644 --- a/backend/src/CCE.Api.External/Endpoints/TopicsPublicEndpoints.cs +++ b/backend/src/CCE.Api.External/Endpoints/TopicsPublicEndpoints.cs @@ -1,3 +1,4 @@ +using CCE.Api.Common.Extensions; using CCE.Application.Community.Public.Queries.ListPublicTopics; using MediatR; using Microsoft.AspNetCore.Builder; @@ -14,8 +15,8 @@ public static IEndpointRouteBuilder MapTopicsPublicEndpoints(this IEndpointRoute topics.MapGet("", async (IMediator mediator, CancellationToken ct) => { - var result = await mediator.Send(new ListPublicTopicsQuery(), ct).ConfigureAwait(false); - return Results.Ok(result); + var response = await mediator.Send(new ListPublicTopicsQuery(), ct).ConfigureAwait(false); + return response.ToHttpResult(); }) .AllowAnonymous() .WithName("ListPublicTopics"); diff --git a/backend/src/CCE.Api.Internal/Endpoints/ResourceCategoryEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/ResourceCategoryEndpoints.cs index bb635bac..82d3f831 100644 --- a/backend/src/CCE.Api.Internal/Endpoints/ResourceCategoryEndpoints.cs +++ b/backend/src/CCE.Api.Internal/Endpoints/ResourceCategoryEndpoints.cs @@ -1,3 +1,4 @@ +using CCE.Api.Common.Extensions; using CCE.Application.Content.Commands.CreateResourceCategory; using CCE.Application.Content.Commands.DeleteResourceCategory; using CCE.Application.Content.Commands.UpdateResourceCategory; @@ -26,8 +27,8 @@ public static IEndpointRouteBuilder MapResourceCategoryEndpoints(this IEndpointR PageSize: pageSize ?? 20, ParentId: parentId, IsActive: isActive); - var result = await mediator.Send(query, cancellationToken).ConfigureAwait(false); - return Results.Ok(result); + var response = await mediator.Send(query, cancellationToken).ConfigureAwait(false); + return response.ToHttpResult(); }) .RequireAuthorization(Permissions.Resource_Center_Upload) .WithName("ListResourceCategories"); @@ -36,8 +37,8 @@ public static IEndpointRouteBuilder MapResourceCategoryEndpoints(this IEndpointR System.Guid id, IMediator mediator, CancellationToken cancellationToken) => { - var dto = await mediator.Send(new GetResourceCategoryByIdQuery(id), cancellationToken).ConfigureAwait(false); - return dto is null ? Results.NotFound() : Results.Ok(dto); + var response = await mediator.Send(new GetResourceCategoryByIdQuery(id), cancellationToken).ConfigureAwait(false); + return response.ToHttpResult(); }) .RequireAuthorization(Permissions.Resource_Center_Upload) .WithName("GetResourceCategoryById"); @@ -48,8 +49,8 @@ public static IEndpointRouteBuilder MapResourceCategoryEndpoints(this IEndpointR { var cmd = new CreateResourceCategoryCommand( body.NameAr, body.NameEn, body.Slug, body.ParentId, body.OrderIndex); - var dto = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); - return Results.Created($"/api/admin/resource-categories/{dto.Id}", dto); + var response = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); + return response.ToCreatedHttpResult(); }) .RequireAuthorization(Permissions.Resource_Center_Upload) .WithName("CreateResourceCategory"); @@ -61,8 +62,8 @@ public static IEndpointRouteBuilder MapResourceCategoryEndpoints(this IEndpointR { var cmd = new UpdateResourceCategoryCommand( id, body.NameAr, body.NameEn, body.OrderIndex, body.IsActive); - var dto = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); - return dto is null ? Results.NotFound() : Results.Ok(dto); + var response = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); + return response.ToHttpResult(); }) .RequireAuthorization(Permissions.Resource_Center_Upload) .WithName("UpdateResourceCategory"); @@ -71,8 +72,8 @@ public static IEndpointRouteBuilder MapResourceCategoryEndpoints(this IEndpointR System.Guid id, IMediator mediator, CancellationToken cancellationToken) => { - await mediator.Send(new DeleteResourceCategoryCommand(id), cancellationToken).ConfigureAwait(false); - return Results.NoContent(); + var response = await mediator.Send(new DeleteResourceCategoryCommand(id), cancellationToken).ConfigureAwait(false); + return response.ToNoContentHttpResult(); }) .RequireAuthorization(Permissions.Resource_Center_Upload) .WithName("DeleteResourceCategory"); diff --git a/backend/src/CCE.Api.Internal/Endpoints/TopicEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/TopicEndpoints.cs index d7e6955d..574aa34c 100644 --- a/backend/src/CCE.Api.Internal/Endpoints/TopicEndpoints.cs +++ b/backend/src/CCE.Api.Internal/Endpoints/TopicEndpoints.cs @@ -1,3 +1,4 @@ +using CCE.Api.Common.Extensions; using CCE.Application.Community.Commands.CreateTopic; using CCE.Application.Community.Commands.DeleteTopic; using CCE.Application.Community.Commands.UpdateTopic; @@ -27,8 +28,8 @@ public static IEndpointRouteBuilder MapTopicEndpoints(this IEndpointRouteBuilder ParentId: parentId, IsActive: isActive, Search: search); - var result = await mediator.Send(query, cancellationToken).ConfigureAwait(false); - return Results.Ok(result); + var response = await mediator.Send(query, cancellationToken).ConfigureAwait(false); + return response.ToHttpResult(); }) .RequireAuthorization(Permissions.Community_Post_Moderate) .WithName("ListTopics"); @@ -37,8 +38,8 @@ public static IEndpointRouteBuilder MapTopicEndpoints(this IEndpointRouteBuilder System.Guid id, IMediator mediator, CancellationToken cancellationToken) => { - var dto = await mediator.Send(new GetTopicByIdQuery(id), cancellationToken).ConfigureAwait(false); - return dto is null ? Results.NotFound() : Results.Ok(dto); + var response = await mediator.Send(new GetTopicByIdQuery(id), cancellationToken).ConfigureAwait(false); + return response.ToHttpResult(); }) .RequireAuthorization(Permissions.Community_Post_Moderate) .WithName("GetTopicById"); @@ -51,8 +52,8 @@ public static IEndpointRouteBuilder MapTopicEndpoints(this IEndpointRouteBuilder body.NameAr, body.NameEn, body.DescriptionAr, body.DescriptionEn, body.Slug, body.ParentId, body.IconUrl, body.OrderIndex); - var dto = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); - return Results.Created($"/api/admin/topics/{dto.Id}", dto); + var response = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); + return response.ToCreatedHttpResult(); }) .RequireAuthorization(Permissions.Community_Post_Moderate) .WithName("CreateTopic"); @@ -66,8 +67,8 @@ public static IEndpointRouteBuilder MapTopicEndpoints(this IEndpointRouteBuilder id, body.NameAr, body.NameEn, body.DescriptionAr, body.DescriptionEn, body.OrderIndex, body.IsActive); - var dto = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); - return dto is null ? Results.NotFound() : Results.Ok(dto); + var response = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false); + return response.ToHttpResult(); }) .RequireAuthorization(Permissions.Community_Post_Moderate) .WithName("UpdateTopic"); @@ -76,8 +77,8 @@ public static IEndpointRouteBuilder MapTopicEndpoints(this IEndpointRouteBuilder System.Guid id, IMediator mediator, CancellationToken cancellationToken) => { - await mediator.Send(new DeleteTopicCommand(id), cancellationToken).ConfigureAwait(false); - return Results.NoContent(); + var response = await mediator.Send(new DeleteTopicCommand(id), cancellationToken).ConfigureAwait(false); + return response.ToNoContentHttpResult(); }) .RequireAuthorization(Permissions.Community_Post_Moderate) .WithName("DeleteTopic"); diff --git a/backend/src/CCE.Application/Community/Commands/CreateTopic/CreateTopicCommand.cs b/backend/src/CCE.Application/Community/Commands/CreateTopic/CreateTopicCommand.cs index bb05d0eb..0f8b36c0 100644 --- a/backend/src/CCE.Application/Community/Commands/CreateTopic/CreateTopicCommand.cs +++ b/backend/src/CCE.Application/Community/Commands/CreateTopic/CreateTopicCommand.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Community.Dtos; using MediatR; @@ -11,4 +12,4 @@ public sealed record CreateTopicCommand( string Slug, System.Guid? ParentId, string? IconUrl, - int OrderIndex) : IRequest; + int OrderIndex) : IRequest>; diff --git a/backend/src/CCE.Application/Community/Commands/CreateTopic/CreateTopicCommandHandler.cs b/backend/src/CCE.Application/Community/Commands/CreateTopic/CreateTopicCommandHandler.cs index 4c572682..c78bef4c 100644 --- a/backend/src/CCE.Application/Community/Commands/CreateTopic/CreateTopicCommandHandler.cs +++ b/backend/src/CCE.Application/Community/Commands/CreateTopic/CreateTopicCommandHandler.cs @@ -1,20 +1,30 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; using CCE.Application.Community.Dtos; using CCE.Application.Community.Queries.ListTopics; +using CCE.Application.Messages; using CCE.Domain.Community; using MediatR; namespace CCE.Application.Community.Commands.CreateTopic; -public sealed class CreateTopicCommandHandler : IRequestHandler +public sealed class CreateTopicCommandHandler : IRequestHandler> { - private readonly ITopicService _service; + private readonly IRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _messages; - public CreateTopicCommandHandler(ITopicService service) + public CreateTopicCommandHandler( + IRepository repo, + ICceDbContext db, + MessageFactory messages) { - _service = service; + _repo = repo; + _db = db; + _messages = messages; } - public async Task Handle(CreateTopicCommand request, CancellationToken cancellationToken) + public async Task> Handle(CreateTopicCommand request, CancellationToken cancellationToken) { var topic = Topic.Create( request.NameAr, @@ -26,8 +36,9 @@ public async Task Handle(CreateTopicCommand request, CancellationToken request.IconUrl, request.OrderIndex); - await _service.SaveAsync(topic, cancellationToken).ConfigureAwait(false); + await _repo.AddAsync(topic, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return ListTopicsQueryHandler.MapToDto(topic); + return _messages.Ok(ListTopicsQueryHandler.MapToDto(topic), "CONTENT_CREATED"); } } diff --git a/backend/src/CCE.Application/Community/Commands/DeleteTopic/DeleteTopicCommand.cs b/backend/src/CCE.Application/Community/Commands/DeleteTopic/DeleteTopicCommand.cs index fdbefa87..e7dd9a74 100644 --- a/backend/src/CCE.Application/Community/Commands/DeleteTopic/DeleteTopicCommand.cs +++ b/backend/src/CCE.Application/Community/Commands/DeleteTopic/DeleteTopicCommand.cs @@ -1,5 +1,6 @@ +using CCE.Application.Common; using MediatR; namespace CCE.Application.Community.Commands.DeleteTopic; -public sealed record DeleteTopicCommand(System.Guid Id) : IRequest; +public sealed record DeleteTopicCommand(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/Community/Commands/DeleteTopic/DeleteTopicCommandHandler.cs b/backend/src/CCE.Application/Community/Commands/DeleteTopic/DeleteTopicCommandHandler.cs index a6dbc3f0..90e862dd 100644 --- a/backend/src/CCE.Application/Community/Commands/DeleteTopic/DeleteTopicCommandHandler.cs +++ b/backend/src/CCE.Application/Community/Commands/DeleteTopic/DeleteTopicCommandHandler.cs @@ -1,35 +1,47 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; using CCE.Domain.Common; +using CCE.Domain.Community; using MediatR; namespace CCE.Application.Community.Commands.DeleteTopic; -public sealed class DeleteTopicCommandHandler : IRequestHandler +public sealed class DeleteTopicCommandHandler : IRequestHandler> { - private readonly ITopicService _service; + private readonly IRepository _repo; + private readonly ICceDbContext _db; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; + private readonly MessageFactory _messages; - public DeleteTopicCommandHandler(ITopicService service, ICurrentUserAccessor currentUser, ISystemClock clock) + public DeleteTopicCommandHandler( + IRepository repo, + ICceDbContext db, + ICurrentUserAccessor currentUser, + ISystemClock clock, + MessageFactory messages) { - _service = service; + _repo = repo; + _db = db; _currentUser = currentUser; _clock = clock; + _messages = messages; } - public async Task Handle(DeleteTopicCommand request, CancellationToken cancellationToken) + public async Task> Handle(DeleteTopicCommand request, CancellationToken cancellationToken) { - var topic = await _service.FindAsync(request.Id, cancellationToken).ConfigureAwait(false); + var topic = await _repo.GetByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); if (topic is null) - { - throw new System.Collections.Generic.KeyNotFoundException($"Topic {request.Id} not found."); - } + return _messages.TopicNotFound(); - var deletedById = _currentUser.GetUserId() - ?? throw new DomainException("Cannot delete topic from a request without a user identity."); + var deletedById = _currentUser.GetUserId(); + if (deletedById is null) + return _messages.NotAuthenticated(); - topic.SoftDelete(deletedById, _clock); - await _service.UpdateAsync(topic, cancellationToken).ConfigureAwait(false); - return Unit.Value; + topic.SoftDelete(deletedById.Value, _clock); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _messages.Ok("CONTENT_DELETED"); } } diff --git a/backend/src/CCE.Application/Community/Commands/UpdateTopic/UpdateTopicCommand.cs b/backend/src/CCE.Application/Community/Commands/UpdateTopic/UpdateTopicCommand.cs index 0a5aa389..90b3a490 100644 --- a/backend/src/CCE.Application/Community/Commands/UpdateTopic/UpdateTopicCommand.cs +++ b/backend/src/CCE.Application/Community/Commands/UpdateTopic/UpdateTopicCommand.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Community.Dtos; using MediatR; @@ -10,4 +11,4 @@ public sealed record UpdateTopicCommand( string DescriptionAr, string DescriptionEn, int OrderIndex, - bool IsActive) : IRequest; + bool IsActive) : IRequest>; diff --git a/backend/src/CCE.Application/Community/Commands/UpdateTopic/UpdateTopicCommandHandler.cs b/backend/src/CCE.Application/Community/Commands/UpdateTopic/UpdateTopicCommandHandler.cs index 779ac36e..cd18a38f 100644 --- a/backend/src/CCE.Application/Community/Commands/UpdateTopic/UpdateTopicCommandHandler.cs +++ b/backend/src/CCE.Application/Community/Commands/UpdateTopic/UpdateTopicCommandHandler.cs @@ -1,25 +1,34 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; using CCE.Application.Community.Dtos; using CCE.Application.Community.Queries.ListTopics; +using CCE.Application.Messages; +using CCE.Domain.Community; using MediatR; namespace CCE.Application.Community.Commands.UpdateTopic; -public sealed class UpdateTopicCommandHandler : IRequestHandler +public sealed class UpdateTopicCommandHandler : IRequestHandler> { - private readonly ITopicService _service; - - public UpdateTopicCommandHandler(ITopicService service) + private readonly IRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _messages; + + public UpdateTopicCommandHandler( + IRepository repo, + ICceDbContext db, + MessageFactory messages) { - _service = service; + _repo = repo; + _db = db; + _messages = messages; } - public async Task Handle(UpdateTopicCommand request, CancellationToken cancellationToken) + public async Task> Handle(UpdateTopicCommand request, CancellationToken cancellationToken) { - var topic = await _service.FindAsync(request.Id, cancellationToken).ConfigureAwait(false); + var topic = await _repo.GetByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); if (topic is null) - { - return null; - } + return _messages.TopicNotFound(); topic.UpdateContent(request.NameAr, request.NameEn, request.DescriptionAr, request.DescriptionEn); topic.Reorder(request.OrderIndex); @@ -29,8 +38,8 @@ public UpdateTopicCommandHandler(ITopicService service) else topic.Deactivate(); - await _service.UpdateAsync(topic, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return ListTopicsQueryHandler.MapToDto(topic); + return _messages.Ok(ListTopicsQueryHandler.MapToDto(topic), "SUCCESS_OPERATION"); } } diff --git a/backend/src/CCE.Application/Community/ITopicService.cs b/backend/src/CCE.Application/Community/ITopicService.cs index 2a717c00..3941d5dd 100644 --- a/backend/src/CCE.Application/Community/ITopicService.cs +++ b/backend/src/CCE.Application/Community/ITopicService.cs @@ -1,10 +1,3 @@ -using CCE.Domain.Community; - -namespace CCE.Application.Community; - -public interface ITopicService -{ - Task SaveAsync(Topic topic, CancellationToken ct); - Task FindAsync(System.Guid id, CancellationToken ct); - Task UpdateAsync(Topic topic, CancellationToken ct); -} +// This interface is intentionally empty — Topic now uses +// IRepository for all write operations. +// See CCE.Application.Common.Interfaces.IRepository<,>. diff --git a/backend/src/CCE.Application/Community/Public/Queries/GetPublicTopicBySlug/GetPublicTopicBySlugQuery.cs b/backend/src/CCE.Application/Community/Public/Queries/GetPublicTopicBySlug/GetPublicTopicBySlugQuery.cs index 8c32ba09..b95ee2c6 100644 --- a/backend/src/CCE.Application/Community/Public/Queries/GetPublicTopicBySlug/GetPublicTopicBySlugQuery.cs +++ b/backend/src/CCE.Application/Community/Public/Queries/GetPublicTopicBySlug/GetPublicTopicBySlugQuery.cs @@ -1,6 +1,7 @@ +using CCE.Application.Common; using CCE.Application.Community.Public.Dtos; using MediatR; namespace CCE.Application.Community.Public.Queries.GetPublicTopicBySlug; -public sealed record GetPublicTopicBySlugQuery(string Slug) : IRequest; +public sealed record GetPublicTopicBySlugQuery(string Slug) : IRequest>; diff --git a/backend/src/CCE.Application/Community/Public/Queries/GetPublicTopicBySlug/GetPublicTopicBySlugQueryHandler.cs b/backend/src/CCE.Application/Community/Public/Queries/GetPublicTopicBySlug/GetPublicTopicBySlugQueryHandler.cs index 51887074..312bb318 100644 --- a/backend/src/CCE.Application/Community/Public/Queries/GetPublicTopicBySlug/GetPublicTopicBySlugQueryHandler.cs +++ b/backend/src/CCE.Application/Community/Public/Queries/GetPublicTopicBySlug/GetPublicTopicBySlugQueryHandler.cs @@ -1,22 +1,26 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Community.Public.Dtos; using CCE.Application.Community.Public.Queries.ListPublicTopics; +using CCE.Application.Messages; using MediatR; namespace CCE.Application.Community.Public.Queries.GetPublicTopicBySlug; public sealed class GetPublicTopicBySlugQueryHandler - : IRequestHandler + : IRequestHandler> { private readonly ICceDbContext _db; + private readonly MessageFactory _messages; - public GetPublicTopicBySlugQueryHandler(ICceDbContext db) + public GetPublicTopicBySlugQueryHandler(ICceDbContext db, MessageFactory messages) { _db = db; + _messages = messages; } - public async Task Handle( + public async Task> Handle( GetPublicTopicBySlugQuery request, CancellationToken cancellationToken) { @@ -26,6 +30,9 @@ public GetPublicTopicBySlugQueryHandler(ICceDbContext db) .ConfigureAwait(false)) .FirstOrDefault(); - return topic is null ? null : ListPublicTopicsQueryHandler.MapToDto(topic); + if (topic is null) + return _messages.TopicNotFound(); + + return _messages.Ok(ListPublicTopicsQueryHandler.MapToDto(topic), "SUCCESS_OPERATION"); } } diff --git a/backend/src/CCE.Application/Community/Public/Queries/ListPublicTopics/ListPublicTopicsQuery.cs b/backend/src/CCE.Application/Community/Public/Queries/ListPublicTopics/ListPublicTopicsQuery.cs index 1113d5f3..021f7be3 100644 --- a/backend/src/CCE.Application/Community/Public/Queries/ListPublicTopics/ListPublicTopicsQuery.cs +++ b/backend/src/CCE.Application/Community/Public/Queries/ListPublicTopics/ListPublicTopicsQuery.cs @@ -1,6 +1,7 @@ +using CCE.Application.Common; using CCE.Application.Community.Public.Dtos; using MediatR; namespace CCE.Application.Community.Public.Queries.ListPublicTopics; -public sealed record ListPublicTopicsQuery() : IRequest>; +public sealed record ListPublicTopicsQuery() : IRequest>>; diff --git a/backend/src/CCE.Application/Community/Public/Queries/ListPublicTopics/ListPublicTopicsQueryHandler.cs b/backend/src/CCE.Application/Community/Public/Queries/ListPublicTopics/ListPublicTopicsQueryHandler.cs index a890205d..89cab163 100644 --- a/backend/src/CCE.Application/Community/Public/Queries/ListPublicTopics/ListPublicTopicsQueryHandler.cs +++ b/backend/src/CCE.Application/Community/Public/Queries/ListPublicTopics/ListPublicTopicsQueryHandler.cs @@ -1,22 +1,26 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Community.Public.Dtos; +using CCE.Application.Messages; using CCE.Domain.Community; using MediatR; namespace CCE.Application.Community.Public.Queries.ListPublicTopics; public sealed class ListPublicTopicsQueryHandler - : IRequestHandler> + : IRequestHandler>> { private readonly ICceDbContext _db; + private readonly MessageFactory _messages; - public ListPublicTopicsQueryHandler(ICceDbContext db) + public ListPublicTopicsQueryHandler(ICceDbContext db, MessageFactory messages) { _db = db; + _messages = messages; } - public async Task> Handle( + public async Task>> Handle( ListPublicTopicsQuery request, CancellationToken cancellationToken) { @@ -26,7 +30,7 @@ public ListPublicTopicsQueryHandler(ICceDbContext db) .ToListAsyncEither(cancellationToken) .ConfigureAwait(false); - return list.Select(MapToDto).ToList(); + return _messages.Ok((System.Collections.Generic.IReadOnlyList)list.Select(MapToDto).ToList(), "ITEMS_LISTED"); } internal static PublicTopicDto MapToDto(Topic t) => new( diff --git a/backend/src/CCE.Application/Community/Queries/GetTopicById/GetTopicByIdQuery.cs b/backend/src/CCE.Application/Community/Queries/GetTopicById/GetTopicByIdQuery.cs index c38d2c6b..5916e9c3 100644 --- a/backend/src/CCE.Application/Community/Queries/GetTopicById/GetTopicByIdQuery.cs +++ b/backend/src/CCE.Application/Community/Queries/GetTopicById/GetTopicByIdQuery.cs @@ -1,6 +1,7 @@ +using CCE.Application.Common; using CCE.Application.Community.Dtos; using MediatR; namespace CCE.Application.Community.Queries.GetTopicById; -public sealed record GetTopicByIdQuery(System.Guid Id) : IRequest; +public sealed record GetTopicByIdQuery(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/Community/Queries/GetTopicById/GetTopicByIdQueryHandler.cs b/backend/src/CCE.Application/Community/Queries/GetTopicById/GetTopicByIdQueryHandler.cs index 2361081e..d7a7aa1a 100644 --- a/backend/src/CCE.Application/Community/Queries/GetTopicById/GetTopicByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Community/Queries/GetTopicById/GetTopicByIdQueryHandler.cs @@ -1,27 +1,34 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Community.Dtos; using CCE.Application.Community.Queries.ListTopics; +using CCE.Application.Messages; using MediatR; namespace CCE.Application.Community.Queries.GetTopicById; -public sealed class GetTopicByIdQueryHandler : IRequestHandler +public sealed class GetTopicByIdQueryHandler : IRequestHandler> { private readonly ICceDbContext _db; + private readonly MessageFactory _messages; - public GetTopicByIdQueryHandler(ICceDbContext db) + public GetTopicByIdQueryHandler(ICceDbContext db, MessageFactory messages) { _db = db; + _messages = messages; } - public async Task Handle(GetTopicByIdQuery request, CancellationToken cancellationToken) + public async Task> Handle(GetTopicByIdQuery request, CancellationToken cancellationToken) { var list = await _db.Topics .Where(t => t.Id == request.Id) .ToListAsyncEither(cancellationToken) .ConfigureAwait(false); var topic = list.SingleOrDefault(); - return topic is null ? null : ListTopicsQueryHandler.MapToDto(topic); + if (topic is null) + return _messages.TopicNotFound(); + + return _messages.Ok(ListTopicsQueryHandler.MapToDto(topic), "SUCCESS_OPERATION"); } } diff --git a/backend/src/CCE.Application/Community/Queries/ListTopics/ListTopicsQuery.cs b/backend/src/CCE.Application/Community/Queries/ListTopics/ListTopicsQuery.cs index 116173db..df32fa2a 100644 --- a/backend/src/CCE.Application/Community/Queries/ListTopics/ListTopicsQuery.cs +++ b/backend/src/CCE.Application/Community/Queries/ListTopics/ListTopicsQuery.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Common.Pagination; using CCE.Application.Community.Dtos; using MediatR; @@ -9,4 +10,4 @@ public sealed record ListTopicsQuery( int PageSize = 20, System.Guid? ParentId = null, bool? IsActive = null, - string? Search = null) : IRequest>; + string? Search = null) : IRequest>>; diff --git a/backend/src/CCE.Application/Community/Queries/ListTopics/ListTopicsQueryHandler.cs b/backend/src/CCE.Application/Community/Queries/ListTopics/ListTopicsQueryHandler.cs index 0ad7125d..8c3c2cf2 100644 --- a/backend/src/CCE.Application/Community/Queries/ListTopics/ListTopicsQueryHandler.cs +++ b/backend/src/CCE.Application/Community/Queries/ListTopics/ListTopicsQueryHandler.cs @@ -1,22 +1,26 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Community.Dtos; +using CCE.Application.Messages; using CCE.Domain.Community; using MediatR; namespace CCE.Application.Community.Queries.ListTopics; public sealed class ListTopicsQueryHandler - : IRequestHandler> + : IRequestHandler>> { private readonly ICceDbContext _db; + private readonly MessageFactory _messages; - public ListTopicsQueryHandler(ICceDbContext db) + public ListTopicsQueryHandler(ICceDbContext db, MessageFactory messages) { _db = db; + _messages = messages; } - public async Task> Handle( + public async Task>> Handle( ListTopicsQuery request, CancellationToken cancellationToken) { @@ -46,8 +50,7 @@ public async Task> Handle( var page = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken) .ConfigureAwait(false); - var items = page.Items.Select(MapToDto).ToList(); - return new PagedResult(items, page.Page, page.PageSize, page.Total); + return _messages.Ok(page.Map(MapToDto), "ITEMS_LISTED"); } internal static TopicDto MapToDto(Topic t) => new( diff --git a/backend/src/CCE.Application/Content/Commands/CreateResourceCategory/CreateResourceCategoryCommand.cs b/backend/src/CCE.Application/Content/Commands/CreateResourceCategory/CreateResourceCategoryCommand.cs index 4c1ce229..012087ff 100644 --- a/backend/src/CCE.Application/Content/Commands/CreateResourceCategory/CreateResourceCategoryCommand.cs +++ b/backend/src/CCE.Application/Content/Commands/CreateResourceCategory/CreateResourceCategoryCommand.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Content.Dtos; using MediatR; @@ -8,4 +9,4 @@ public sealed record CreateResourceCategoryCommand( string NameEn, string Slug, System.Guid? ParentId, - int OrderIndex) : IRequest; + int OrderIndex) : IRequest>; diff --git a/backend/src/CCE.Application/Content/Commands/CreateResourceCategory/CreateResourceCategoryCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/CreateResourceCategory/CreateResourceCategoryCommandHandler.cs index 439314f5..bf70d285 100644 --- a/backend/src/CCE.Application/Content/Commands/CreateResourceCategory/CreateResourceCategoryCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/CreateResourceCategory/CreateResourceCategoryCommandHandler.cs @@ -1,20 +1,30 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; using CCE.Application.Content.Dtos; using CCE.Application.Content.Queries.ListResourceCategories; +using CCE.Application.Messages; using CCE.Domain.Content; using MediatR; namespace CCE.Application.Content.Commands.CreateResourceCategory; -public sealed class CreateResourceCategoryCommandHandler : IRequestHandler +public sealed class CreateResourceCategoryCommandHandler : IRequestHandler> { - private readonly IResourceCategoryRepository _service; + private readonly IRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _messages; - public CreateResourceCategoryCommandHandler(IResourceCategoryRepository service) + public CreateResourceCategoryCommandHandler( + IRepository repo, + ICceDbContext db, + MessageFactory messages) { - _service = service; + _repo = repo; + _db = db; + _messages = messages; } - public async Task Handle(CreateResourceCategoryCommand request, CancellationToken cancellationToken) + public async Task> Handle(CreateResourceCategoryCommand request, CancellationToken cancellationToken) { var category = ResourceCategory.Create( request.NameAr, @@ -23,8 +33,9 @@ public async Task Handle(CreateResourceCategoryCommand requ request.ParentId, request.OrderIndex); - await _service.SaveAsync(category, cancellationToken).ConfigureAwait(false); + await _repo.AddAsync(category, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return ListResourceCategoriesQueryHandler.MapToDto(category); + return _messages.Ok(ListResourceCategoriesQueryHandler.MapToDto(category), "CONTENT_CREATED"); } } diff --git a/backend/src/CCE.Application/Content/Commands/DeleteResourceCategory/DeleteResourceCategoryCommand.cs b/backend/src/CCE.Application/Content/Commands/DeleteResourceCategory/DeleteResourceCategoryCommand.cs index 2dd894ed..b1886256 100644 --- a/backend/src/CCE.Application/Content/Commands/DeleteResourceCategory/DeleteResourceCategoryCommand.cs +++ b/backend/src/CCE.Application/Content/Commands/DeleteResourceCategory/DeleteResourceCategoryCommand.cs @@ -1,5 +1,6 @@ +using CCE.Application.Common; using MediatR; namespace CCE.Application.Content.Commands.DeleteResourceCategory; -public sealed record DeleteResourceCategoryCommand(System.Guid Id) : IRequest; +public sealed record DeleteResourceCategoryCommand(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/Content/Commands/DeleteResourceCategory/DeleteResourceCategoryCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/DeleteResourceCategory/DeleteResourceCategoryCommandHandler.cs index f301b40b..458f8235 100644 --- a/backend/src/CCE.Application/Content/Commands/DeleteResourceCategory/DeleteResourceCategoryCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/DeleteResourceCategory/DeleteResourceCategoryCommandHandler.cs @@ -1,26 +1,36 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using CCE.Domain.Content; using MediatR; namespace CCE.Application.Content.Commands.DeleteResourceCategory; -public sealed class DeleteResourceCategoryCommandHandler : IRequestHandler +public sealed class DeleteResourceCategoryCommandHandler : IRequestHandler> { - private readonly IResourceCategoryRepository _service; + private readonly IRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _messages; - public DeleteResourceCategoryCommandHandler(IResourceCategoryRepository service) + public DeleteResourceCategoryCommandHandler( + IRepository repo, + ICceDbContext db, + MessageFactory messages) { - _service = service; + _repo = repo; + _db = db; + _messages = messages; } - public async Task Handle(DeleteResourceCategoryCommand request, CancellationToken cancellationToken) + public async Task> Handle(DeleteResourceCategoryCommand request, CancellationToken cancellationToken) { - var category = await _service.FindAsync(request.Id, cancellationToken).ConfigureAwait(false); + var category = await _repo.GetByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); if (category is null) - { - throw new System.Collections.Generic.KeyNotFoundException($"ResourceCategory {request.Id} not found."); - } + return _messages.CategoryNotFound(); category.Deactivate(); - await _service.UpdateAsync(category, cancellationToken).ConfigureAwait(false); - return Unit.Value; + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _messages.Ok("CONTENT_DELETED"); } } diff --git a/backend/src/CCE.Application/Content/Commands/UpdateResourceCategory/UpdateResourceCategoryCommand.cs b/backend/src/CCE.Application/Content/Commands/UpdateResourceCategory/UpdateResourceCategoryCommand.cs index 15525005..c2fb7c09 100644 --- a/backend/src/CCE.Application/Content/Commands/UpdateResourceCategory/UpdateResourceCategoryCommand.cs +++ b/backend/src/CCE.Application/Content/Commands/UpdateResourceCategory/UpdateResourceCategoryCommand.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Content.Dtos; using MediatR; @@ -8,4 +9,4 @@ public sealed record UpdateResourceCategoryCommand( string NameAr, string NameEn, int OrderIndex, - bool IsActive) : IRequest; + bool IsActive) : IRequest>; diff --git a/backend/src/CCE.Application/Content/Commands/UpdateResourceCategory/UpdateResourceCategoryCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/UpdateResourceCategory/UpdateResourceCategoryCommandHandler.cs index 9ff90e1d..939f102c 100644 --- a/backend/src/CCE.Application/Content/Commands/UpdateResourceCategory/UpdateResourceCategoryCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/UpdateResourceCategory/UpdateResourceCategoryCommandHandler.cs @@ -1,25 +1,34 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; using CCE.Application.Content.Dtos; using CCE.Application.Content.Queries.ListResourceCategories; +using CCE.Application.Messages; +using CCE.Domain.Content; using MediatR; namespace CCE.Application.Content.Commands.UpdateResourceCategory; -public sealed class UpdateResourceCategoryCommandHandler : IRequestHandler +public sealed class UpdateResourceCategoryCommandHandler : IRequestHandler> { - private readonly IResourceCategoryRepository _service; - - public UpdateResourceCategoryCommandHandler(IResourceCategoryRepository service) + private readonly IRepository _repo; + private readonly ICceDbContext _db; + private readonly MessageFactory _messages; + + public UpdateResourceCategoryCommandHandler( + IRepository repo, + ICceDbContext db, + MessageFactory messages) { - _service = service; + _repo = repo; + _db = db; + _messages = messages; } - public async Task Handle(UpdateResourceCategoryCommand request, CancellationToken cancellationToken) + public async Task> Handle(UpdateResourceCategoryCommand request, CancellationToken cancellationToken) { - var category = await _service.FindAsync(request.Id, cancellationToken).ConfigureAwait(false); + var category = await _repo.GetByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); if (category is null) - { - return null; - } + return _messages.CategoryNotFound(); category.UpdateNames(request.NameAr, request.NameEn); category.Reorder(request.OrderIndex); @@ -29,8 +38,8 @@ public UpdateResourceCategoryCommandHandler(IResourceCategoryRepository service) else category.Deactivate(); - await _service.UpdateAsync(category, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - return ListResourceCategoriesQueryHandler.MapToDto(category); + return _messages.Ok(ListResourceCategoriesQueryHandler.MapToDto(category), "SUCCESS_OPERATION"); } } diff --git a/backend/src/CCE.Application/Content/IResourceCategoryRepository.cs b/backend/src/CCE.Application/Content/IResourceCategoryRepository.cs index e0e82897..9b6f2b2d 100644 --- a/backend/src/CCE.Application/Content/IResourceCategoryRepository.cs +++ b/backend/src/CCE.Application/Content/IResourceCategoryRepository.cs @@ -1,10 +1,3 @@ -using CCE.Domain.Content; - -namespace CCE.Application.Content; - -public interface IResourceCategoryRepository -{ - Task SaveAsync(ResourceCategory category, CancellationToken ct); - Task FindAsync(System.Guid id, CancellationToken ct); - Task UpdateAsync(ResourceCategory category, CancellationToken ct); -} +// This interface is intentionally empty — ResourceCategory now uses +// IRepository for all write operations. +// See CCE.Application.Common.Interfaces.IRepository<,>. diff --git a/backend/src/CCE.Application/Content/Queries/GetResourceCategoryById/GetResourceCategoryByIdQuery.cs b/backend/src/CCE.Application/Content/Queries/GetResourceCategoryById/GetResourceCategoryByIdQuery.cs index 47e70a65..f29ad5cf 100644 --- a/backend/src/CCE.Application/Content/Queries/GetResourceCategoryById/GetResourceCategoryByIdQuery.cs +++ b/backend/src/CCE.Application/Content/Queries/GetResourceCategoryById/GetResourceCategoryByIdQuery.cs @@ -1,6 +1,7 @@ +using CCE.Application.Common; using CCE.Application.Content.Dtos; using MediatR; namespace CCE.Application.Content.Queries.GetResourceCategoryById; -public sealed record GetResourceCategoryByIdQuery(System.Guid Id) : IRequest; +public sealed record GetResourceCategoryByIdQuery(System.Guid Id) : IRequest>; diff --git a/backend/src/CCE.Application/Content/Queries/GetResourceCategoryById/GetResourceCategoryByIdQueryHandler.cs b/backend/src/CCE.Application/Content/Queries/GetResourceCategoryById/GetResourceCategoryByIdQueryHandler.cs index 387131c8..51578b5f 100644 --- a/backend/src/CCE.Application/Content/Queries/GetResourceCategoryById/GetResourceCategoryByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Queries/GetResourceCategoryById/GetResourceCategoryByIdQueryHandler.cs @@ -1,25 +1,35 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Content.Dtos; +using CCE.Application.Messages; using CCE.Domain.Content; using MediatR; namespace CCE.Application.Content.Queries.GetResourceCategoryById; -public sealed class GetResourceCategoryByIdQueryHandler : IRequestHandler +public sealed class GetResourceCategoryByIdQueryHandler : IRequestHandler> { private readonly ICceDbContext _db; + private readonly MessageFactory _messages; - public GetResourceCategoryByIdQueryHandler(ICceDbContext db) => _db = db; + public GetResourceCategoryByIdQueryHandler(ICceDbContext db, MessageFactory messages) + { + _db = db; + _messages = messages; + } - public async Task Handle(GetResourceCategoryByIdQuery request, CancellationToken cancellationToken) + public async Task> Handle(GetResourceCategoryByIdQuery request, CancellationToken cancellationToken) { var list = await _db.ResourceCategories .Where(c => c.Id == request.Id) .ToListAsyncEither(cancellationToken) .ConfigureAwait(false); var category = list.SingleOrDefault(); - return category is null ? null : MapToDto(category); + if (category is null) + return _messages.CategoryNotFound(); + + return _messages.Ok(MapToDto(category), "SUCCESS_OPERATION"); } internal static ResourceCategoryDto MapToDto(ResourceCategory c) => new( diff --git a/backend/src/CCE.Application/Content/Queries/ListResourceCategories/ListResourceCategoriesQuery.cs b/backend/src/CCE.Application/Content/Queries/ListResourceCategories/ListResourceCategoriesQuery.cs index 6ea08eb6..e32e4e83 100644 --- a/backend/src/CCE.Application/Content/Queries/ListResourceCategories/ListResourceCategoriesQuery.cs +++ b/backend/src/CCE.Application/Content/Queries/ListResourceCategories/ListResourceCategoriesQuery.cs @@ -1,3 +1,4 @@ +using CCE.Application.Common; using CCE.Application.Common.Pagination; using CCE.Application.Content.Dtos; using MediatR; @@ -8,4 +9,4 @@ public sealed record ListResourceCategoriesQuery( int Page = 1, int PageSize = 20, System.Guid? ParentId = null, - bool? IsActive = null) : IRequest>; + bool? IsActive = null) : IRequest>>; diff --git a/backend/src/CCE.Application/Content/Queries/ListResourceCategories/ListResourceCategoriesQueryHandler.cs b/backend/src/CCE.Application/Content/Queries/ListResourceCategories/ListResourceCategoriesQueryHandler.cs index 25a64084..2b5607e7 100644 --- a/backend/src/CCE.Application/Content/Queries/ListResourceCategories/ListResourceCategoriesQueryHandler.cs +++ b/backend/src/CCE.Application/Content/Queries/ListResourceCategories/ListResourceCategoriesQueryHandler.cs @@ -1,19 +1,26 @@ +using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Common.Pagination; using CCE.Application.Content.Dtos; +using CCE.Application.Messages; using CCE.Domain.Content; using MediatR; namespace CCE.Application.Content.Queries.ListResourceCategories; public sealed class ListResourceCategoriesQueryHandler - : IRequestHandler> + : IRequestHandler>> { private readonly ICceDbContext _db; + private readonly MessageFactory _messages; - public ListResourceCategoriesQueryHandler(ICceDbContext db) => _db = db; + public ListResourceCategoriesQueryHandler(ICceDbContext db, MessageFactory messages) + { + _db = db; + _messages = messages; + } - public async Task> Handle( + public async Task>> Handle( ListResourceCategoriesQuery request, CancellationToken cancellationToken) { @@ -23,7 +30,7 @@ public async Task> Handle( .OrderBy(c => c.OrderIndex); var result = await query.ToPagedResultAsync(request.Page, request.PageSize, cancellationToken).ConfigureAwait(false); - return result.Map(MapToDto); + return _messages.Ok(result.Map(MapToDto), "ITEMS_LISTED"); } internal static ResourceCategoryDto MapToDto(ResourceCategory c) => new( diff --git a/backend/src/CCE.Application/Messages/MessageFactory.cs b/backend/src/CCE.Application/Messages/MessageFactory.cs index 3f248781..f090a3a4 100644 --- a/backend/src/CCE.Application/Messages/MessageFactory.cs +++ b/backend/src/CCE.Application/Messages/MessageFactory.cs @@ -79,6 +79,7 @@ public FieldError Field(string fieldName, string domainKey) public Response EventNotFound() => NotFound("EVENT_NOT_FOUND"); public Response ResourceNotFound() => NotFound("RESOURCE_NOT_FOUND"); public Response PageNotFound() => NotFound("PAGE_NOT_FOUND"); + public Response TopicNotFound() => NotFound("TOPIC_NOT_FOUND"); public Response CategoryNotFound() => NotFound("CATEGORY_NOT_FOUND"); public Response AssetNotFound() => NotFound("ASSET_NOT_FOUND"); public Response AssetNotClean() => BusinessRule("ASSET_NOT_CLEAN"); diff --git a/backend/src/CCE.Infrastructure/Community/TopicService.cs b/backend/src/CCE.Infrastructure/Community/TopicService.cs index f3bde3fe..a18769a9 100644 --- a/backend/src/CCE.Infrastructure/Community/TopicService.cs +++ b/backend/src/CCE.Infrastructure/Community/TopicService.cs @@ -1,32 +1,3 @@ -using CCE.Application.Community; -using CCE.Domain.Community; -using CCE.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace CCE.Infrastructure.Community; - -public sealed class TopicService : ITopicService -{ - private readonly CceDbContext _db; - - public TopicService(CceDbContext db) - { - _db = db; - } - - public async Task SaveAsync(Topic topic, CancellationToken ct) - { - _db.Topics.Add(topic); - await _db.SaveChangesAsync(ct).ConfigureAwait(false); - } - - public async Task FindAsync(System.Guid id, CancellationToken ct) - { - return await _db.Topics.FirstOrDefaultAsync(t => t.Id == id, ct).ConfigureAwait(false); - } - - public async Task UpdateAsync(Topic topic, CancellationToken ct) - { - await _db.SaveChangesAsync(ct).ConfigureAwait(false); - } -} +// This class is intentionally empty — Topic now uses +// Repository for all write operations. +// See CCE.Infrastructure.Persistence.Repository<,>. diff --git a/backend/src/CCE.Infrastructure/Content/ResourceCategoryRepository.cs b/backend/src/CCE.Infrastructure/Content/ResourceCategoryRepository.cs index a7a6c7f7..70bf2f9d 100644 --- a/backend/src/CCE.Infrastructure/Content/ResourceCategoryRepository.cs +++ b/backend/src/CCE.Infrastructure/Content/ResourceCategoryRepository.cs @@ -1,32 +1,3 @@ -using CCE.Application.Content; -using CCE.Domain.Content; -using CCE.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace CCE.Infrastructure.Content; - -public sealed class ResourceCategoryRepository : IResourceCategoryRepository -{ - private readonly CceDbContext _db; - - public ResourceCategoryRepository(CceDbContext db) - { - _db = db; - } - - public async Task SaveAsync(ResourceCategory category, CancellationToken ct) - { - _db.ResourceCategories.Add(category); - await _db.SaveChangesAsync(ct).ConfigureAwait(false); - } - - public async Task FindAsync(System.Guid id, CancellationToken ct) - { - return await _db.ResourceCategories.FirstOrDefaultAsync(c => c.Id == id, ct).ConfigureAwait(false); - } - - public async Task UpdateAsync(ResourceCategory category, CancellationToken ct) - { - await _db.SaveChangesAsync(ct).ConfigureAwait(false); - } -} +// This class is intentionally empty — ResourceCategory now uses +// Repository for all write operations. +// See CCE.Infrastructure.Persistence.Repository<,>. diff --git a/backend/src/CCE.Infrastructure/DependencyInjection.cs b/backend/src/CCE.Infrastructure/DependencyInjection.cs index 1a610727..e34e653b 100644 --- a/backend/src/CCE.Infrastructure/DependencyInjection.cs +++ b/backend/src/CCE.Infrastructure/DependencyInjection.cs @@ -173,7 +173,7 @@ public static IServiceCollection AddInfrastructure( services.AddTransient(); services.AddSingleton(); services.AddScoped(); - services.AddScoped(); + // ResourceCategory uses IRepository (registered below) services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -183,7 +183,7 @@ public static IServiceCollection AddInfrastructure( services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + // Topic uses IRepository (registered below) services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/docs/crud-implementation-guide.md b/backend/src/docs/crud-implementation-guide.md new file mode 100644 index 00000000..ea60b7a4 --- /dev/null +++ b/backend/src/docs/crud-implementation-guide.md @@ -0,0 +1,1165 @@ +# CRUD Implementation Guide — CCE Project Patterns + +## Overview + +This document captures the **architectural patterns and conventions** used in the CCE project for implementing CRUD features. It is based on the Service Evaluation (US018) implementation and the team leader's requirements for the FAQ CRUD. + +### Core Principles + +| Principle | Description | +|---|---| +| **Clean Architecture** | Domain → Application → Infrastructure → API (4 layers) | +| **CQRS** | Separate Command (write) and Query (read) via MediatR | +| **Unit of Work** | Repository tracks, handler commits (`ICceDbContext.SaveChangesAsync`) | +| **Reads → ICceDbContext** | All read operations inject `ICceDbContext` directly, no repository | +| **Writes → Repository** | Write operations use repository interface + domain factory | +| **Response Envelope** | Every endpoint returns `Response` via `MessageFactory` | +| **No validation in endpoints** | All validation is in FluentValidation validators only | + +--- + +## Table of Contents + +1. [Step-by-Step: Complete CRUD Creation](#step-by-step-complete-crud-creation) +2. [Pattern: Write-Only Repository + Unit of Work](#pattern-write-only-repository--unit-of-work) +3. [Pattern: Read via ICceDbContext](#pattern-read-via-iccedbcontext) +4. [Pattern: Generic Repository for Write Operations (Update/Delete)](#pattern-generic-repository-for-write-operations-updatedelete) +5. [Pattern: Response\ Envelope + MessageFactory](#pattern-response-t-envelope--messagefactory) +6. [Pattern: FluentValidation + ERR900 Handling](#pattern-fluentvalidation--err900-handling) +7. [Pattern: ToHttpResult for Endpoints](#pattern-tohttpresult-for-endpoints) +8. [Pattern: Pagination with PagedResult\](#pattern-pagination-with-pagedresultt) +9. [Pattern: Enum Handling (int Request, String Response)](#pattern-enum-handling-int-request-string-response) +10. [Pattern: Anonymous Users + Nullable CreatedById](#pattern-anonymous-users--nullable-createdbyid) +11. [Pattern: Error/Success Codes (SystemCode, ApplicationErrors, Resources.yaml)](#pattern-errorsuccess-codes) +12. [Pattern: LocalizedText Value Object](#pattern-localizedtext-value-object) +13. [Pattern: SuperAdmin Authorization](#pattern-superadmin-authorization) +14. [Pattern: Domain Factory + Mutation Methods](#pattern-domain-factory--mutation-methods) +15. [Pattern: Mapping (DTOs)](#pattern-mapping-dtos) +16. [File Checklist](#file-checklist) +17. [Common Pitfalls](#common-pitfalls) + +--- + +## Step-by-Step: Complete CRUD Creation + +### Step 1 — Domain Layer + +Create the entity and any value objects/enums. + +**Entity:** +```csharp +// CCE.Domain\YourDomain\YourEntity.cs +public sealed class YourEntity : AuditableEntity +{ + // Properties — private set for immutability via domain methods + public string Name { get; private set; } + public int Order { get; private set; } + + // EF Core materialization constructor + private YourEntity() : base(Guid.NewGuid()) { } + + // Domain factory + public static YourEntity Create(string name, int order, Guid by, ISystemClock clock) + { + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Name is required."); + + var entity = new YourEntity { Name = name, Order = order }; + entity.MarkAsCreated(by, clock); + return entity; + } + + // Domain mutation + public void Update(string name, int order, Guid by, ISystemClock clock) + { + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Name is required."); + + Name = name; + Order = order; + MarkAsModified(by, clock); + } +} +``` + +**Entity with enum:** +```csharp +// CCE.Domain\YourDomain\YourRating.cs +public enum YourRating +{ + None = 0, // Sentinel — always rejected by validation + Good = 1, + Bad = 2, +} +``` + +**Entity with value object:** +```csharp +// CCE.Domain\YourDomain\YourEntity.cs +public sealed class YourEntity : AuditableEntity +{ + public LocalizedText Title { get; private set; } + public LocalizedText Description { get; private set; } + + private YourEntity() : base(Guid.NewGuid()) { } + + public static YourEntity Create(LocalizedText title, LocalizedText description, Guid by, ISystemClock clock) + { + var entity = new YourEntity { Title = title, Description = description }; + entity.MarkAsCreated(by, clock); + return entity; + } + + public void Update(LocalizedText title, LocalizedText description, Guid by, ISystemClock clock) + { + Title = title; + Description = description; + MarkAsModified(by, clock); + } +} +``` + +--- + +### Step 2 — Application Layer: Write-Side (Command) + +**Write-only repository interface:** +```csharp +// CCE.Application\YourDomain\IYourEntityRepository.cs +public interface IYourEntityRepository +{ + Task AddAsync(YourEntity entity, CancellationToken ct = default); +} +``` + +> **Note:** For Update/Delete operations that need to fetch first, use `IRepository` directly instead of creating a repository interface. See [Pattern: Generic Repository](#pattern-generic-repository-for-write-operations-updatedelete). + +**Command:** +```csharp +// CCE.Application\YourDomain\Commands\CreateYourEntity\CreateYourEntityCommand.cs +public sealed record CreateYourEntityCommand( + string Name, + int Order +) : IRequest>; +``` + +**Command Handler:** +```csharp +// CCE.Application\YourDomain\Commands\CreateYourEntity\CreateYourEntityCommandHandler.cs +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; + +internal sealed class CreateYourEntityCommandHandler( + IYourEntityRepository _repo, + ICceDbContext _db, + ICurrentUserAccessor _currentUser, + ISystemClock _clock, + MessageFactory _msg) + : IRequestHandler> +{ + public async Task> Handle(CreateYourEntityCommand cmd, CancellationToken ct) + { + var userId = _currentUser.GetUserId(); + // For endpoints with [AllowAnonymous], userId may be null + // Domain factory requires non-null, so handle accordingly: + if (userId is null) + return _msg.Unauthorized("NOT_AUTHENTICATED"); + + var entity = YourEntity.Create(cmd.Name, cmd.Order, userId.Value, _clock); + await _repo.AddAsync(entity, ct); + await _db.SaveChangesAsync(ct); // Unit of Work — single commit point + + return _msg.Ok("YOUR_ENTITY_CREATED"); + } +} +``` + +**For Create with anonymous access (no user required):** +```csharp +public async Task> Handle(CreateYourEntityCommand cmd, CancellationToken ct) +{ + var userId = _currentUser.GetUserId(); // null for anonymous + + var entity = YourEntity.Create(cmd.Name, cmd.Order); // factory without user + // OR: + var entity = YourEntity.Create(cmd.Name, cmd.Order, userId, _clock); + // where factory handles null userId gracefully + + await _repo.AddAsync(entity, ct); + await _db.SaveChangesAsync(ct); + + return _msg.Ok("YOUR_ENTITY_CREATED"); +} +``` + +**FluentValidation Validator:** +```csharp +// CCE.Application\YourDomain\Commands\CreateYourEntity\CreateYourEntityCommandValidator.cs +internal sealed class CreateYourEntityCommandValidator : AbstractValidator +{ + public CreateYourEntityCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithErrorCode("REQUIRED_FIELD") + .MaximumLength(200).WithErrorCode("MAX_LENGTH"); + + RuleFor(x => x.Order) + .GreaterThan(0).WithErrorCode("INVALID_VALUE"); + } +} +``` + +--- + +### Step 3 — Application Layer: Write-Side (Update/Delete) + +**For Update/Delete, use `IRepository` (generic interface):** +```csharp +// No custom repository interface needed — use generic IRepository +// Located at: CCE.Application\Common\Interfaces\IRepository.cs +public interface IRepository + where T : Entity + where TId : IEquatable +{ + Task GetByIdAsync(TId id, CancellationToken ct = default); + Task AddAsync(T entity, CancellationToken ct = default); + void Update(T entity); + void Delete(T entity); +} +``` + +**Update Command Handler:** +```csharp +internal sealed class UpdateYourEntityCommandHandler( + IRepository _repo, + ICceDbContext _db, + ICurrentUserAccessor _currentUser, + ISystemClock _clock, + MessageFactory _msg) + : IRequestHandler> +{ + public async Task> Handle(UpdateYourEntityCommand cmd, CancellationToken ct) + { + var entity = await _repo.GetByIdAsync(cmd.Id, ct); + if (entity is null) + return _msg.NotFound("YOUR_ENTITY_NOT_FOUND"); + + var userId = _currentUser.GetUserId(); + if (userId is null) + return _msg.Unauthorized("NOT_AUTHENTICATED"); + + entity.Update(cmd.Name, cmd.Order, userId.Value, _clock); + // No need to call _repo.Update() — EF tracks changes automatically + // when the entity was fetched via GetByIdAsync (same DbContext) + await _db.SaveChangesAsync(ct); + + return _msg.Ok("YOUR_ENTITY_UPDATED"); + } +} +``` + +**Delete Command Handler:** +```csharp +internal sealed class DeleteYourEntityCommandHandler( + IRepository _repo, + ICceDbContext _db, + MessageFactory _msg) + : IRequestHandler> +{ + public async Task> Handle(DeleteYourEntityCommand cmd, CancellationToken ct) + { + var entity = await _repo.GetByIdAsync(cmd.Id, ct); + if (entity is null) + return _msg.NotFound("YOUR_ENTITY_NOT_FOUND"); + + _repo.Delete(entity); // Marks for deletion + await _db.SaveChangesAsync(ct); // Unit of Work commit + + return _msg.Ok("YOUR_ENTITY_DELETED"); + } +} +``` + +--- + +### Step 4 — Application Layer: Read-Side (Queries) + +**Queries inject `ICceDbContext` directly — no repository involvement.** + +**DTO:** +```csharp +// CCE.Application\YourDomain\DTOs\YourEntityDto.cs +public sealed record YourEntityDto( + Guid Id, + string Name, + int Order, + DateTimeOffset CreatedOn, + Guid? CreatedById +); +``` + +**GetById Query:** +```csharp +// CCE.Application\YourDomain\Queries\GetYourEntityById\GetYourEntityByIdQuery.cs +public sealed record GetYourEntityByIdQuery(Guid Id) : IRequest>; + +// Handler: +internal sealed class GetYourEntityByIdQueryHandler( + ICceDbContext _db, + MessageFactory _msg) + : IRequestHandler> +{ + public async Task> Handle(GetYourEntityByIdQuery q, CancellationToken ct) + { + var entity = await _db.Set() + .Where(e => e.Id == q.Id) + .Select(e => new YourEntityDto( + e.Id, e.Name, e.Order, e.CreatedOn, e.CreatedById)) + .FirstOrDefaultAsync(ct); + + if (entity is null) + return _msg.NotFound("YOUR_ENTITY_NOT_FOUND"); + + return _msg.Ok(entity, "ITEMS_LISTED"); + } +} +``` + +**GetAll (paginated) Query:** +```csharp +// CCE.Application\YourDomain\Queries\GetAllYourEntities\GetAllYourEntitiesQuery.cs +public sealed record GetAllYourEntitiesQuery( + int Page = 1, + int PageSize = 20 +) : IRequest>>; + +// Handler: +internal sealed class GetAllYourEntitiesQueryHandler( + ICceDbContext _db, + MessageFactory _msg) + : IRequestHandler>> +{ + public async Task>> Handle( + GetAllYourEntitiesQuery q, CancellationToken ct) + { + var result = await _db.Set() + .OrderByDescending(e => e.CreatedOn) + .Select(e => new YourEntityDto( + e.Id, e.Name, e.Order, e.CreatedOn, e.CreatedById)) + .ToPagedResultAsync(q.Page, q.PageSize, ct); + + return _msg.Ok(result, "ITEMS_LISTED"); + } +} +``` + +> `ToPagedResultAsync` is defined in `CCE.Application.Common.Pagination.PaginationExtensions`. It clamps `page >= 1` and `pageSize` to `[1, 100]`. See [Pattern: Pagination](#pattern-pagination-with-pagedresultt). + +**GetAll (non-paginated) Query:** +```csharp +public async Task>> Handle( + GetAllYourEntitiesQuery q, CancellationToken ct) +{ + var items = await _db.Set() + .OrderBy(e => e.Order) + .Select(e => new YourEntityDto( + e.Id, e.Name, e.Order, e.CreatedOn, e.CreatedById)) + .ToListAsync(ct); + + return _msg.Ok(items, "ITEMS_LISTED"); +} +``` + +--- + +### Step 5 — Infrastructure Layer + +**EF Core Configuration:** +```csharp +// CCE.Infrastructure\Persistence\Configurations\YourDomain\YourEntityConfiguration.cs +internal sealed class YourEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("your_entities"); + + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).ValueGeneratedNever(); + + builder.Property(e => e.Name) + .IsRequired() + .HasMaxLength(200); + + builder.Property(e => e.Order) + .IsRequired(); + + builder.Property(e => e.CreatedOn) + .IsRequired(); + + // Index on CreatedOn for ordered queries + builder.HasIndex(e => e.CreatedOn) + .HasDatabaseName("ix_your_entity_created_on"); + } +} +``` + +**For LocalizedText (owned entity):** +```csharp +builder.OwnsOne(e => e.Title, nav => +{ + nav.Property(t => t.Ar).IsRequired().HasColumnName("title_ar"); + nav.Property(t => t.En).IsRequired().HasColumnName("title_en"); +}); + +builder.OwnsOne(e => e.Description, nav => +{ + nav.Property(t => t.Ar).IsRequired().HasColumnName("description_ar"); + nav.Property(t => t.En).IsRequired().HasColumnName("description_en"); +}); +``` + +**For enum (int conversion):** +```csharp +builder.Property(e => e.Rating) + .IsRequired() + .HasConversion(); +``` + +**Concrete Repository (for custom write-only repository pattern):** +```csharp +// CCE.Infrastructure\YourDomain\YourEntityRepository.cs +public sealed class YourEntityRepository : IYourEntityRepository +{ + private readonly CceDbContext _db; + + public YourEntityRepository(CceDbContext db) => _db = db; + + public async Task AddAsync(YourEntity entity, CancellationToken ct) + => await _db.Set().AddAsync(entity, ct); + // NOTE: No SaveChangesAsync here — handler calls _db.SaveChangesAsync() +} +``` + +**When using generic `Repository`, no implementation needed — it's already registered in DI:** +```csharp +// Already in DependencyInjection.cs: +services.AddScoped(typeof(IRepository<,>), typeof(Repository<,>)); +``` + +**Migration:** +```powershell +dotnet ef migrations add AddYourEntity --context CceDbContext --startup-project ../CCE.Api.Internal +``` + +--- + +### Step 6 — Error/Success Codes & Localization + +**ApplicationErrors.cs — constant domain keys:** +```csharp +// CCE.Application\Errors\ApplicationErrors.cs +public static class YourEntity +{ + public const string YOUR_ENTITY_NOT_FOUND = "YOUR_ENTITY_NOT_FOUND"; + public const string YOUR_ENTITY_CREATED = "YOUR_ENTITY_CREATED"; + public const string YOUR_ENTITY_UPDATED = "YOUR_ENTITY_UPDATED"; + public const string YOUR_ENTITY_DELETED = "YOUR_ENTITY_DELETED"; +} +``` + +**SystemCode.cs — assign ERR/CON codes:** +```csharp +// CCE.Application\Messages\SystemCode.cs +// Pick next available code: +public const string ERR999 = "ERR999"; // YourEntity not found +public const string CON999 = "CON999"; // YourEntity created/updated/deleted +``` + +**SystemCodeMap.cs — map domain keys to system codes:** +```csharp +// CCE.Application\Messages\SystemCodeMap.cs +// In the dictionary: +["YOUR_ENTITY_NOT_FOUND"] = SystemCode.ERR999, +["YOUR_ENTITY_CREATED"] = SystemCode.CON999, +["YOUR_ENTITY_UPDATED"] = SystemCode.CON999, +["YOUR_ENTITY_DELETED"] = SystemCode.CON999, +``` + +**MessageFactory.cs — convenience shortcuts:** +```csharp +// CCE.Application\Messages\MessageFactory.cs +// ─── Convenience shortcuts (YourEntity) ─── +public Response YourEntityCreated() => Ok(ApplicationErrors.YourEntity.YOUR_ENTITY_CREATED); +public Response YourEntityUpdated() => Ok(ApplicationErrors.YourEntity.YOUR_ENTITY_UPDATED); +public Response YourEntityDeleted() => Ok(ApplicationErrors.YourEntity.YOUR_ENTITY_DELETED); +public Response YourEntityNotFound() => NotFound(ApplicationErrors.YourEntity.YOUR_ENTITY_NOT_FOUND); +``` + +**Resources.yaml — bilingual messages:** +```yaml +YOUR_ENTITY_NOT_FOUND: + ar: "المنشأة غير موجودة" + en: "Your entity not found" + +YOUR_ENTITY_CREATED: + ar: "تم إنشاء المنشأة بنجاح" + en: "Your entity created successfully" + +YOUR_ENTITY_UPDATED: + ar: "تم تحديث المنشأة بنجاح" + en: "Your entity updated successfully" + +YOUR_ENTITY_DELETED: + ar: "تم حذف المنشأة بنجاح" + en: "Your entity deleted successfully" +``` + +--- + +### Step 7 — API Endpoints + +**External API Endpoints (public):** +```csharp +// CCE.Api.External\Endpoints\YourEntityEndpoints.cs +public static class YourEntityEndpoints +{ + public static void MapYourEntityEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/your-entities"); + + group.MapPost("/", Submit) + .AllowAnonymous(); // Or use RequireAuthorization() for authenticated-only + } + + private static async Task Submit( + SubmitYourEntityRequest request, + ISender sender) + { + var cmd = new CreateYourEntityCommand(request.Name, request.Order); + var result = await sender.Send(cmd); + return result.ToHttpResult(StatusCodes.Status201Created); + } +} + +// Request DTO — uses primitive types (int for enums) +public sealed record SubmitYourEntityRequest(string Name, int Order); +``` + +**Internal API Endpoints (admin):** +```csharp +// CCE.Api.Internal\Endpoints\YourEntityEndpoints.cs +public static class YourEntityEndpoints +{ + public static void MapYourEntityEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/admin/your-entities") + .RequireAuthorization(Permissions.Survey_ReadAll); + + group.MapPost("/", Create); + group.MapPut("/{id:guid}", Update); + group.MapDelete("/{id:guid}", Delete); + group.MapGet("/{id:guid}", GetById); + group.MapGet("/", GetAll); + } + + private static async Task Create(CreateYourEntityRequest request, ISender sender) + { + var cmd = new CreateYourEntityCommand(request.Name, request.Order); + var result = await sender.Send(cmd); + return result.ToHttpResult(StatusCodes.Status201Created); + } + + private static async Task Update(Guid id, UpdateYourEntityRequest request, ISender sender) + { + var cmd = new UpdateYourEntityCommand(id, request.Name, request.Order); + var result = await sender.Send(cmd); + return result.ToHttpResult(); + } + + private static async Task Delete(Guid id, ISender sender) + { + var result = await sender.Send(new DeleteYourEntityCommand(id)); + return result.ToHttpResult(); + } + + private static async Task GetById(Guid id, ISender sender) + { + var result = await sender.Send(new GetYourEntityByIdQuery(id)); + return result.ToHttpResult(); + } + + private static async Task GetAll( + int page = 1, int pageSize = 20, ISender sender = default!) + { + var result = await sender.Send(new GetAllYourEntitiesQuery(page, pageSize)); + return result.ToHttpResult(); + } +} +``` + +--- + +### Step 8 — DI Registration + +```csharp +// CCE.Infrastructure\DependencyInjection.cs + +// Custom write-only repository: +services.AddScoped(); + +// Generic repository (already registered — add only if you need it): +// services.AddScoped(typeof(IRepository<,>), typeof(Repository<,>)); +``` + +### Step 9 — Program.cs (both APIs) + +```csharp +// CCE.Api.External\Program.cs +app.MapYourEntityEndpoints(); + +// CCE.Api.Internal\Program.cs +app.MapYourEntityEndpoints(); +``` + +--- + +## Pattern: Write-Only Repository + Unit of Work + +``` +┌─────────────────────────────────────────────────────┐ +│ CommandHandler │ +│ │ +│ 1. Create entity via domain factory │ +│ 2. _repo.AddAsync(entity, ct) ← tracks only │ +│ 3. _db.SaveChangesAsync(ct) ← single commit │ +│ 4. Return MessageFactory result │ +└─────────────────────────────────────────────────────┘ +``` + +### Rules: +- Repository **never calls** `SaveChangesAsync` +- Handler calls `_db.SaveChangesAsync()` **exactly once** +- Repository only adds/attaches entity to the change tracker +- Use when feature only needs Create (no Update/Delete) + +### Key Files: +- `CCE.Application\Evaluation\IEvaluationRepository.cs` — write-only interface +- `CCE.Infrastructure\Evaluation\EvaluationRepository.cs` — tracks only + +--- + +## Pattern: Generic Repository for Write Operations (Update/Delete) + +``` +┌─────────────────────────────────────────────────────┐ +│ CommandHandler (Update/Delete) │ +│ │ +│ 1. _repo.GetByIdAsync(id, ct) ← fetch entity │ +│ 2. entity.Update(...) ← domain mutation │ +│ (or _repo.Delete(entity) ← mark for removal)│ +│ 3. _db.SaveChangesAsync(ct) ← single commit │ +│ 4. Return MessageFactory result │ +└─────────────────────────────────────────────────────┘ +``` + +### Key Points: +- Inject `IRepository` (from `CCE.Application.Common.Interfaces`) +- `GetByIdAsync` returns tracked entity — no need to call `Update()` after mutation +- `Delete()` marks for removal +- Same `SaveChangesAsync` pattern +- Generic repository is **already registered** in DI + +### Key Files: +- `CCE.Application\Common\Interfaces\IRepository.cs` — interface +- `CCE.Infrastructure\Persistence\Repository.cs` — concrete implementation +- `CCE.Infrastructure\Persistence\EntityRepository.cs` — abstract base (without interface) + +--- + +## Pattern: Read via ICceDbContext + +``` +┌─────────────────────────────────────────────────────┐ +│ QueryHandler │ +│ │ +│ injects ICceDbContext directly │ +│ _db.Set().Where(...) │ +│ .Select(e => new Dto(...)) — projection │ +│ .FirstOrDefaultAsync / .ToListAsync │ +│ .ToPagedResultAsync(...) — pagination │ +│ │ +│ Returns _msg.Ok(data, "ITEMS_LISTED") │ +└─────────────────────────────────────────────────────┘ +``` + +### Rules: +- **No repository** for read operations +- Use `.Select()` to project to DTO directly in SQL +- Use `.ToPagedResultAsync()` for paginated lists +- Always use `AsNoTracking()` (already set in ICceDbContext implementation) + +### Key Files: +- `CCE.Infrastructure\Persistence\ICceDbContext.cs` — `IQueryable` properties +- `CCE.Infrastructure\Persistence\CceDbContext.cs` — `AsNoTracking()` in explicit interface impl + +--- + +## Pattern: Response\ Envelope + MessageFactory + +### Response\ structure: +```json +{ + "success": true, + "code": "CON008", + "message": "Evaluation submitted successfully", + "data": { ... }, + "errors": [], + "traceId": "...", + "timestamp": "..." +} +``` + +### MessageFactory usage: + +| Method | HTTP Status | When to Use | +|---|---|---| +| `_msg.Ok(data, domainKey)` | 200 | Success with data | +| `_msg.Ok(domainKey)` | 200 | Success, no data | +| `_msg.NotFound(domainKey)` | 404 | Entity not found | +| `_msg.Conflict(domainKey)` | 409 | Duplicate/conflict | +| `_msg.Unauthorized(domainKey)` | 401 | Not authenticated | +| `_msg.Forbidden(domainKey)` | 403 | Not authorized | +| `_msg.BusinessRule(domainKey)` | 422 | Business rule violation | +| `_msg.ValidationError(domainKey, errors)` | 400 | Validation errors | + +### How it works: +1. Handler passes a **domain key** (e.g., `"YOUR_ENTITY_CREATED"`) +2. `MessageFactory` calls `SystemCodeMap.ToSystemCode(key)` → e.g., `"CON999"` +3. `MessageFactory` calls `ILocalizationService.GetString(key)` → localized message +4. Returns `Response` with code + message + +### Key Files: +- `CCE.Application\Common\Response.cs` — `Response`, `VoidData`, `Response` +- `CCE.Application\Messages\MessageFactory.cs` — factory +- `CCE.Application\Messages\SystemCode.cs` — code constants +- `CCE.Application\Messages\SystemCodeMap.cs` — domain key → code mapping + +--- + +## Pattern: FluentValidation + ERR900 Handling + +### Validator Rules: +```csharp +internal sealed class CreateCommandValidator : AbstractValidator +{ + public CreateCommandValidator() + { + RuleFor(x => x.Field) + .NotEmpty().WithErrorCode("REQUIRED_FIELD") + .MaximumLength(500).WithErrorCode("MAX_LENGTH"); + + // For enum fields (int in request): + RuleFor(x => x.Rating) + .NotEqual(0).WithErrorCode("REQUIRED_FIELD") + .IsInEnum().WithErrorCode("INVALID_ENUM"); + + // For required enums where 0 = None (sentinel): + RuleFor(x => x.OverallSatisfaction) + .NotEqual(EvaluationRating.None).WithErrorCode("REQUIRED_FIELD"); + // NOTE: .IsInEnum() is not needed when request uses int 1-5 + // (out-of-range values can't happen from valid request body) + } +} +``` + +### ERR900 Fallback Chain: +``` +Validator: .WithErrorCode("REQUIRED_FIELD") + ↓ +ResponseValidationBehavior: f.ErrorCode ?? f.ErrorMessage + ↓ +ExceptionHandlingMiddleware: e.ErrorCode ?? e.Message +``` + +If `SystemCodeMap` doesn't have the domain key → `SystemCode.ERR900` is returned → middleware uses `fallbackMessage`. + +### Key Files: +- `CCE.Application\Common\Behaviors\ResponseValidationBehavior.cs` +- `CCE.Api.Common\Middleware\ExceptionHandlingMiddleware.cs` + +--- + +## Pattern: ToHttpResult for Endpoints + +```csharp +// CCE.Api.Common.Extensions.ResponseExtensions + +public static IResult ToHttpResult(this Response response, int successStatusCode = 200); +public static IResult ToCreatedHttpResult(this Response response); // → 201 +public static IResult ToNoContentHttpResult(this Response response); // → 204 +``` + +### HTTP Status Mapping: + +| MessageType | HTTP Status | +|---|---| +| Success | `successStatusCode` (default 200) | +| NotFound | 404 | +| Validation | 400 | +| Conflict | 409 | +| Unauthorized | 401 | +| Forbidden | 403 | +| BusinessRule | 422 | +| Internal | 500 | + +### Key Files: +- `CCE.Api.Common\Extensions\ResponseExtensions.cs` + +--- + +## Pattern: Pagination with PagedResult\ + +```csharp +public sealed record PagedResult( + IReadOnlyList Items, + int Page, + int PageSize, + long Total +); +``` + +### Usage in Query Handlers: +```csharp +public async Task>> Handle( + GetAllQuery q, CancellationToken ct) +{ + var result = await _db.Set() + .OrderByDescending(e => e.CreatedOn) + .Select(e => new YourEntityDto(e.Id, e.Name, e.CreatedOn)) + .ToPagedResultAsync(q.Page, q.PageSize, ct); + + return _msg.Ok(result, "ITEMS_LISTED"); +} +``` + +### Extension Methods: +```csharp +// Project to DTO in single SQL round trip: +query.ToPagedResultAsync(page, pageSize, ct) + +// Or with explicit projection expression: +query.ToPagedResultAsync(q => new Dto(q.Id, q.Name), page, pageSize, ct) +``` + +### Behavior: +- `page` clamped to `>= 1` +- `pageSize` clamped to `[1, 100]` +- Returns `PagedResult` with `Items`, `Page`, `PageSize`, `Total` + +### Key Files: +- `CCE.Application\Common\Pagination\PagedResult.cs` + +--- + +## Pattern: Enum Handling (int Request, String Response) + +### Request (int): +```csharp +public sealed record SubmitEvaluationRequest( + int OverallSatisfaction, // 1-5 (not None=0) + int EaseOfUse, + int ContentSuitability, + string Feedback); +``` + +### Command (enum): +```csharp +public sealed record SubmitEvaluationCommand( + EvaluationRating OverallSatisfaction, + EvaluationRating EaseOfUse, + EvaluationRating ContentSuitability, + string Feedback) : IRequest>; +``` + +### MediatR automatically converts int → enum when sending command. + +### Response (string — enum name): +`JsonStringEnumConverter` is configured globally in both Program.cs files: +```csharp +.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); +``` + +So response shows: `"overallSatisfaction": "Excellent"` not `"overallSatisfaction": 1`. + +### Validation: +```csharp +// Rejects 0 (sentinel None): +RuleFor(x => x.OverallSatisfaction) + .NotEqual(EvaluationRating.None).WithErrorCode("REQUIRED_FIELD"); + +// .IsInEnum() is NOT needed because int→enum conversion at MediatR +// ensures only valid int values (1-5) pass through +``` + +--- + +## Pattern: Anonymous Users + Nullable CreatedById + +### Background: +- `AuditableEntity.CreatedById` is `Guid?` (nullable) +- `MarkAsCreated(Guid by, ISystemClock clock)` requires non-null `Guid` (throws on `Guid.Empty`) +- For endpoints with `[AllowAnonymous]`, the user may not be authenticated +- `ICurrentUserAccessor.GetUserId()` returns `null` for unauthenticated requests + +### Solution: +- If endpoint is `AllowAnonymous`, **don't pass userId to domain factory** or handle null: +```csharp +var userId = _currentUser.GetUserId(); +// Option A: Skip CreatedById for anonymous submissions +var entity = ServiceEvaluation.Submit(cmd.OverallSatisfaction, ...); // factory without user +// CreatedById stays null + +// Option B: Pass even null and let factory handle it +var entity = ServiceEvaluation.Submit(cmd.OverallSatisfaction, ..., userId, _clock); +// factory stores CreatedById = userId (may be null) +``` + +### Key Files: +- `CCE.Domain\Common\AuditableEntity.cs` — `CreatedById` as `Guid?` +- `CCE.Api.Common.Identity\HttpContextCurrentUserAccessor.cs` — `GetUserId()` + +--- + +## Pattern: LocalizedText Value Object + +### Definition: +```csharp +// CCE.Domain.PlatformSettings.ValueObjects.LocalizedText +public sealed class LocalizedText +{ + public string Ar { get; private init; } + public string En { get; private init; } + + // Factory with validation: + public static LocalizedText Create(string ar, string en); // throws if empty + + // Factory without validation: + public static LocalizedText From(string ar, string en); // allows empty +} +``` + +### Usage in Entity: +```csharp +public LocalizedText Question { get; private set; } +public LocalizedText Answer { get; private set; } +``` + +### EF Core Configuration: +```csharp +builder.OwnsOne(e => e.Question, nav => +{ + nav.Property(t => t.Ar).IsRequired().HasColumnName("question_ar"); + nav.Property(t => t.En).IsRequired().HasColumnName("question_en"); +}); +``` + +### DTO for LocalizedText: +```csharp +public sealed record FaqDto( + Guid Id, + string QuestionEn, + string QuestionAr, + string AnswerEn, + string AnswerAr, + int Order, + DateTimeOffset CreatedOn, + Guid? CreatedById); +``` + +### Mapping LocalizedText to DTO: +```csharp +.Select(e => new FaqDto( + e.Id, + e.Question.En, + e.Question.Ar, + e.Answer.En, + e.Answer.Ar, + e.Order, + e.CreatedOn, + e.CreatedById)) +``` + +### Key Files: +- `CCE.Domain.PlatformSettings.ValueObjects.LocalizedText` — value object + +--- + +## Pattern: SuperAdmin Authorization + +```csharp +// In Internal API endpoints: +var group = app.MapGroup("/api/admin/faqs") + .RequireAuthorization(Permissions.Survey_ReadAll); + // Or use a specific SuperAdmin permission if it exists + +// If no dedicated SuperAdmin permission exists, check existing ones: +// Permissions.Survey_ReadAll — used for evaluation admin endpoints +// Or add a new permission constant in Permissions.cs +``` + +### Key Files: +- Check `CCE.Application\Common\Authorization\Permissions.cs` for available permissions +- Policies are registered in `AddCcePermissionPolicies` (both API Program.cs) + +--- + +## Pattern: Domain Factory + Mutation Methods + +### Factory (static Create method): +```csharp +public static YourEntity Create(string name, Guid by, ISystemClock clock) +{ + // Validate + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Name is required."); + + // Create + var entity = new YourEntity { Name = name }; + + // Audit + entity.MarkAsCreated(by, clock); + + return entity; +} +``` + +### Mutation (instance Update method): +```csharp +public void Update(string name, Guid by, ISystemClock clock) +{ + // Validate + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Name is required."); + + // Mutate + Name = name; + + // Audit + MarkAsModified(by, clock); +} +``` + +### Rules: +- Factory validates all inputs before creating +- Mutation validates and changes state +- Both call `MarkAsCreated` / `MarkAsModified` with `ISystemClock` +- Private constructor ensures entity is only created via factory +- `MarkAsCreated` throws if `by == Guid.Empty` + +--- + +## Pattern: Mapping (DTOs) + +### Manual Projection in Query Handlers: +```csharp +.Select(e => new YourEntityDto( + e.Id, + e.Name, + e.Order, + e.CreatedOn, + e.CreatedById)) +``` + +This is the current project convention — **no AutoMapper** is used. DTO projection happens directly in `.Select()` for single SQL round trip. + +### Mapping LocalizedText → Flat DTO Fields: +```csharp +.Select(e => new FaqDto( + e.Id, + e.Question.En, + e.Question.Ar, + e.Answer.En, + e.Answer.Ar, + e.Order, + e.CreatedOn, + e.CreatedById)) +``` + +--- + +## File Checklist + +Use this checklist when creating a new CRUD feature. + +### Domain Layer +- [ ] `CCE.Domain\YourDomain\YourEntity.cs` — entity (inherits `AuditableEntity`) +- [ ] `CCE.Domain\YourDomain\YourRating.cs` — enum (if needed) +- [ ] `CCE.Domain\YourDomain\ValueObjects\LocalizedText.cs` — value object (if needed) + +### Application Layer — Repository Interface +- [ ] `CCE.Application\YourDomain\IYourEntityRepository.cs` — write-only interface (if creating custom repo) +- [ ] OR use `IRepository` from `CCE.Application.Common.Interfaces` (for generic) + +### Application Layer — Commands +- [ ] `Commands\CreateYourEntity\CreateYourEntityCommand.cs` +- [ ] `Commands\CreateYourEntity\CreateYourEntityCommandHandler.cs` +- [ ] `Commands\CreateYourEntity\CreateYourEntityCommandValidator.cs` +- [ ] `Commands\UpdateYourEntity\UpdateYourEntityCommand.cs` (if needed) +- [ ] `Commands\UpdateYourEntity\UpdateYourEntityCommandHandler.cs` (if needed) +- [ ] `Commands\UpdateYourEntity\UpdateYourEntityCommandValidator.cs` (if needed) +- [ ] `Commands\DeleteYourEntity\DeleteYourEntityCommand.cs` (if needed) +- [ ] `Commands\DeleteYourEntity\DeleteYourEntityCommandHandler.cs` (if needed) + +### Application Layer — Queries +- [ ] `Queries\GetAllYourEntities\GetAllYourEntitiesQuery.cs` +- [ ] `Queries\GetAllYourEntities\GetAllYourEntitiesQueryHandler.cs` +- [ ] `Queries\GetYourEntityById\GetYourEntityByIdQuery.cs` +- [ ] `Queries\GetYourEntityById\GetYourEntityByIdQueryHandler.cs` + +### Application Layer — DTOs +- [ ] `DTOs\YourEntityDto.cs` + +### Application Layer — Error/Success Codes +- [ ] `Errors\ApplicationErrors.cs` — add `YourEntity` static class with constants +- [ ] `Messages\SystemCode.cs` — add ERR/CON constants +- [ ] `Messages\SystemCodeMap.cs` — map domain keys to codes +- [ ] `Messages\MessageFactory.cs` — add convenience shortcut methods + +### Infrastructure Layer +- [ ] `Persistence\Configurations\YourDomain\YourEntityConfiguration.cs` — EF Core config +- [ ] `YourDomain\YourEntityRepository.cs` — concrete repository (if custom) +- [ ] `Persistence\Migrations\…_AddYourEntity.cs` — migration + +### API Layer — Endpoints +- [ ] `CCE.Api.External\Endpoints\YourEntityEndpoints.cs` — public endpoints +- [ ] `CCE.Api.Internal\Endpoints\YourEntityEndpoints.cs` — admin endpoints +- [ ] `CCE.Api.External\Program.cs` — add `app.MapYourEntityEndpoints()` +- [ ] `CCE.Api.Internal\Program.cs` — add `app.MapYourEntityEndpoints()` + +### Localization +- [ ] `CCE.Api.Common\Localization\Resources.yaml` — add AR/EN messages + +### Registration +- [ ] `CCE.Infrastructure\DependencyInjection.cs` — register repository + +--- + +## Common Pitfalls + +| Pitfall | Solution | +|---|---| +| Forgetting to add `DbSet` to `ICceDbContext` + `CceDbContext` | Always add both the interface property and the class property | +| Forgetting to register repository in DI | Add `services.AddScoped()` | +| Using repository for reads instead of `ICceDbContext` | Inject `ICceDbContext` directly in QueryHandlers | +| Adding validation in endpoint | All validation goes in FluentValidation validators only | +| `SaveChangesAsync` in repository | Repository only tracks — handler commits | +| Using `None=0` enum as valid value | Validator must reject `None` via `.NotEqual(None)` | +| Not updating `created_by_id` to nullable for anonymous entities | `CreatedById` is `Guid?` — migration must reflect that | +| Forgetting to add `MapYourEntityEndpoints()` to Program.cs | Both External and Internal Program.cs need it | +| Using `Result` instead of `Response` | Use `Response` everywhere — `Result` is legacy | +| `.WithErrorCode("REQUIRED_FIELD")` vs `.WithMessage("...")` | Use `.WithErrorCode()` with domain keys, not inline messages | +| Not adding `ISystemClock` to handler DI | Domain factory methods need `ISystemClock` for audit timestamps | +| `IRepository` import from wrong namespace | Import from `CCE.Application.Common.Interfaces` | +| Entity not tracked after `GetByIdAsync` | Entity fetched via same DbContext is auto-tracked — no need for `Update()` | +| `ToHttpResult` missing | Import `CCE.Api.Common.Extensions` | +| `ToPagedResultAsync` missing | Import `CCE.Application.Common.Pagination` |