backend phase 0: foundation, cross-cutting seams & starter cleanup
Remove the Order demo (entity/feature/repo/config/gRPC/proto) and the three pre-marketplace migrations; regenerate a fresh InitialBaseline migration. Stand up the REST surface (PingController + System/Ping CQRS) proving the Mediator -> behaviors -> OperationResult -> ApiResult envelope end to end. Close wiring gaps: register LoggingBehavior (outermost) and add the built-in rate limiter (per-IP global + otp/auth/sensitive policies), placed before authentication. Add current-user + audit plumbing: ICurrentUser (HttpContext + null impls), rename BaseEntity audit fields to CreatedAt/ModifiedAt (DateTimeOffset) + CreatedById/ModifiedById, stamped by a new AuditFieldInterceptor. Introduce five cross-cutting seams (IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher) with in-memory/local mocks registered via AddCrossCuttingSeams. Add Baya.Test.Foundation (encryptor, audit interceptor, ping handler) and update docs, contracts (swagger.v1.json), handoff, report, and mocks registry.
This commit is contained in:
@@ -10,6 +10,9 @@
|
||||
<EmbeddedResource Remove="Common\ValidationBase\**" />
|
||||
<None Remove="Common\ValidationBase\**" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Baya.Test.Foundation" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Baya.Domain\Baya.Domain.csproj" />
|
||||
<PackageReference Include="Mediator.SourceGenerator">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Typed caching seam for read-heavy and reference data. The mock is backed by in-process memory; the
|
||||
/// real implementation swaps to Redis while keeping the same key/TTL scheme.
|
||||
/// </summary>
|
||||
public interface ICacheService
|
||||
{
|
||||
ValueTask<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask SetAsync<T>(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, ValueTask<T>> factory,
|
||||
TimeSpan? ttl = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Baya.Application.Contracts.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Ambient accessor for the authenticated caller, wrapping the HTTP context behind an Application
|
||||
/// contract. Registered Scoped. A null-object implementation serves non-HTTP contexts (jobs, tests)
|
||||
/// so the audit interceptor and handlers never depend on <c>IHttpContextAccessor</c> directly.
|
||||
/// </summary>
|
||||
public interface ICurrentUser
|
||||
{
|
||||
int? UserId { get; }
|
||||
|
||||
bool IsAuthenticated { get; }
|
||||
|
||||
IReadOnlyList<string> Roles { get; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Baya.Application.Contracts.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Abstracts the system clock so handlers and the audit interceptor never call <c>DateTime.Now</c>
|
||||
/// directly and time can be frozen in tests.
|
||||
/// </summary>
|
||||
public interface IDateTimeProvider
|
||||
{
|
||||
DateTimeOffset UtcNow { get; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Baya.Application.Contracts.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Seam for field-level PII encryption (phone, national id, IBAN, addresses, clinical notes).
|
||||
/// Every encrypted-at-rest column flows through this. The mock uses a local symmetric key; the real
|
||||
/// implementation swaps to a KMS / column-encryption / Key Vault provider without touching callers.
|
||||
/// Implementations must never log or surface plaintext.
|
||||
/// </summary>
|
||||
public interface IFieldEncryptor
|
||||
{
|
||||
string Encrypt(string plaintext);
|
||||
|
||||
string Decrypt(string ciphertext);
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic keyed hash for equality lookups on encrypted columns (e.g. <c>iban_hash</c>) —
|
||||
/// the same input always yields the same hash so it can be indexed and queried.
|
||||
/// </summary>
|
||||
string Hash(string value);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Baya.Application.Contracts.Common;
|
||||
|
||||
/// <summary>
|
||||
/// The channel a notification is delivered through. Only <see cref="InApp"/> is wired now; SMS/push
|
||||
/// arrive in later phases behind the same seam.
|
||||
/// </summary>
|
||||
public enum NotificationChannel
|
||||
{
|
||||
InApp,
|
||||
Sms,
|
||||
Push
|
||||
}
|
||||
|
||||
/// <summary>A notification to dispatch to a recipient over one channel.</summary>
|
||||
/// <param name="RecipientUserId">The target user.</param>
|
||||
/// <param name="Channel">Delivery channel.</param>
|
||||
/// <param name="Title">Short title/subject.</param>
|
||||
/// <param name="Body">Message body (no secrets/PII in logs).</param>
|
||||
public sealed record Notification(
|
||||
int RecipientUserId,
|
||||
NotificationChannel Channel,
|
||||
string Title,
|
||||
string Body);
|
||||
|
||||
/// <summary>
|
||||
/// Seam for emitting notifications from domains like booking and payments. The mock logs/no-ops; the
|
||||
/// real in-app write lands in backend-phase-15, with SMS/push added behind the same interface.
|
||||
/// </summary>
|
||||
public interface INotificationDispatcher
|
||||
{
|
||||
ValueTask DispatchAsync(Notification notification, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Blob/object storage seam keyed by an opaque storage key. The mock stores blobs on local disk; the
|
||||
/// real implementation swaps to MinIO/S3/ArvanCloud (presigned URLs) without changing callers.
|
||||
/// </summary>
|
||||
public interface IObjectStorage
|
||||
{
|
||||
ValueTask PutAsync(
|
||||
string key,
|
||||
Stream content,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<Stream?> GetAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>A retrievable URL for the stored object (presigned by the real provider).</summary>
|
||||
string GetUrl(string key);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using Baya.Domain.Entities.Order;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
public interface IOrderRepository
|
||||
{
|
||||
Task AddOrderAsync(Order order);
|
||||
Task<List<Order>> GetAllUserOrdersAsync(int userId);
|
||||
Task<List<Order>> GetAllOrdersWithRelatedUserAsync();
|
||||
Task<Order> GetUserOrderByIdAndUserIdAsync(int userId,int orderId,bool trackEntity);
|
||||
Task DeleteUserOrdersAsync(int userId);
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
public interface IUnitOfWork
|
||||
{
|
||||
public IUserRefreshTokenRepository UserRefreshTokenRepository { get; }
|
||||
public IOrderRepository OrderRepository { get; }
|
||||
Task CommitAsync();
|
||||
ValueTask RollBackAsync();
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using Baya.Application.Contracts.Identity;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Order.Commands;
|
||||
|
||||
internal class AddOrderCommandHandler(IUnitOfWork unitOfWork, IAppUserManager userManager)
|
||||
: IRequestHandler<AddOrderCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(AddOrderCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await userManager.GetUserByIdAsync(request.UserId);
|
||||
|
||||
if(user==null)
|
||||
return OperationResult<bool>.FailureResult("User Not Found");
|
||||
|
||||
await unitOfWork.OrderRepository.AddOrderAsync(new Domain.Entities.Order.Order()
|
||||
{ UserId = user.Id, OrderName = request.OrderName });
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.SharedKernel.ValidationBase;
|
||||
using Baya.SharedKernel.ValidationBase.Contracts;
|
||||
using FluentValidation;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Order.Commands;
|
||||
|
||||
public record AddOrderCommand( string OrderName) : IRequest<OperationResult<bool>>,
|
||||
IValidatableModel<AddOrderCommand>
|
||||
{
|
||||
[JsonIgnore]
|
||||
public int UserId { get; set; }
|
||||
|
||||
public IValidator<AddOrderCommand> ValidateApplicationModel(ApplicationBaseValidationModelProvider<AddOrderCommand> validator)
|
||||
{
|
||||
validator.RuleFor(c => c.OrderName)
|
||||
.NotEmpty()
|
||||
.NotNull()
|
||||
.WithMessage("Please enter your role name");
|
||||
|
||||
return validator;
|
||||
}
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Order.Commands;
|
||||
|
||||
public class DeleteUserOrdersCommandHandler(IUnitOfWork unitOfWork) : IRequestHandler<DeleteUserOrdersCommand,OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(DeleteUserOrdersCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
await unitOfWork.OrderRepository.DeleteUserOrdersAsync(request.UserId);
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Order.Commands;
|
||||
|
||||
public record DeleteUserOrdersCommand(int UserId):IRequest<OperationResult<bool>>;
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Order.Commands;
|
||||
|
||||
public class UpdateUserOrderCommandHandler(IUnitOfWork unitOfWork) : IRequestHandler<UpdateUserOrderCommand,OperationResult<bool>>
|
||||
{
|
||||
|
||||
|
||||
public async ValueTask<OperationResult<bool>> Handle(UpdateUserOrderCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var order = await unitOfWork.OrderRepository.GetUserOrderByIdAndUserIdAsync(request.UserId, request.OrderId,
|
||||
true);
|
||||
|
||||
if(order is null)
|
||||
return OperationResult<bool>.NotFoundResult("Specified Order not found");
|
||||
|
||||
order.OrderName=request.OrderName;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.SharedKernel.ValidationBase;
|
||||
using Baya.SharedKernel.ValidationBase.Contracts;
|
||||
using FluentValidation;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Order.Commands;
|
||||
|
||||
public record UpdateUserOrderCommand
|
||||
(int OrderId, string OrderName) : IRequest<OperationResult<bool>>,IValidatableModel<UpdateUserOrderCommand>
|
||||
{
|
||||
[JsonIgnore]
|
||||
public int UserId { get; set; }
|
||||
|
||||
public IValidator<UpdateUserOrderCommand> ValidateApplicationModel(ApplicationBaseValidationModelProvider<UpdateUserOrderCommand> validator)
|
||||
{
|
||||
validator.RuleFor(c => c.OrderId).NotEmpty().GreaterThan(0);
|
||||
validator.RuleFor(c => c.OrderName).NotEmpty().NotNull();
|
||||
|
||||
return validator;
|
||||
}
|
||||
};
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Order.Queries.GetAllOrders;
|
||||
|
||||
public record GetAllOrdersQuery():IRequest<OperationResult<List<GetAllOrdersQueryResult>>>;
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using MapsterMapper;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Order.Queries.GetAllOrders
|
||||
{
|
||||
internal class GetAllOrdersQueryHandler(IUnitOfWork unitOfWork, IMapper mapper)
|
||||
: IRequestHandler<GetAllOrdersQuery, OperationResult<List<GetAllOrdersQueryResult>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<List<GetAllOrdersQueryResult>>> Handle(GetAllOrdersQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var orders = await unitOfWork.OrderRepository.GetAllOrdersWithRelatedUserAsync();
|
||||
|
||||
var result = orders.Select(mapper.Map<Domain.Entities.Order.Order,GetAllOrdersQueryResult>).ToList();
|
||||
|
||||
return OperationResult<List<GetAllOrdersQueryResult>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
|
||||
|
||||
using Mapster;
|
||||
|
||||
namespace Baya.Application.Features.Order.Queries.GetAllOrders;
|
||||
|
||||
public record GetAllOrdersQueryResult(int OrderId, string OrderName, int OrderOwnerId, string OrderOwnerUserName);
|
||||
|
||||
class GetAllOrdersQueryResultMapping : IRegister
|
||||
{
|
||||
public void Register(TypeAdapterConfig config)
|
||||
{
|
||||
config.NewConfig<Domain.Entities.Order.Order, GetAllOrdersQueryResult>()
|
||||
.Map(dest => dest.OrderId, src => src.Id)
|
||||
.Map(dest => dest.OrderName, src => src.OrderName)
|
||||
.Map(dest => dest.OrderOwnerId, src => src.User.Id)
|
||||
.Map(dest => dest.OrderOwnerUserName, src => src.User.UserName);
|
||||
}
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Order.Queries.GetUserOrders;
|
||||
|
||||
internal class GetUserOrdersQueryHandler(IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetUserOrdersQueryModel, OperationResult<List<GetUsersQueryResultModel>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<List<GetUsersQueryResultModel>>> Handle(GetUserOrdersQueryModel request, CancellationToken cancellationToken)
|
||||
{
|
||||
var orders = await unitOfWork.OrderRepository.GetAllUserOrdersAsync(request.UserId);
|
||||
|
||||
if(!orders.Any())
|
||||
return OperationResult<List<GetUsersQueryResultModel>>.NotFoundResult("You Don't Have Any Orders");
|
||||
|
||||
var result = orders.Select(c => new GetUsersQueryResultModel(c.Id, c.OrderName));
|
||||
|
||||
return OperationResult<List<GetUsersQueryResultModel>>.SuccessResult(result.ToList());
|
||||
}
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Order.Queries.GetUserOrders;
|
||||
|
||||
public record GetUserOrdersQueryModel(int UserId) : IRequest<OperationResult<List<GetUsersQueryResultModel>>>;
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
namespace Baya.Application.Features.Order.Queries.GetUserOrders;
|
||||
|
||||
public record GetUsersQueryResultModel(int OrderId, string OrderName);
|
||||
@@ -0,0 +1,16 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.System.Queries.Ping;
|
||||
|
||||
internal sealed class PingQueryHandler(IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<PingQuery, OperationResult<PingQueryResult>>
|
||||
{
|
||||
public ValueTask<OperationResult<PingQueryResult>> Handle(PingQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new PingQueryResult("Baya.Web.Api", "ok", dateTimeProvider.UtcNow);
|
||||
|
||||
return ValueTask.FromResult(OperationResult<PingQueryResult>.SuccessResult(result));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Baya.Application.Features.System.Queries.Ping;
|
||||
|
||||
/// <summary>Liveness payload proving the REST → Mediator → envelope pipeline end-to-end.</summary>
|
||||
public record PingQueryResult(string Service, string Status, DateTimeOffset ServerTimeUtc);
|
||||
@@ -0,0 +1,6 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.System.Queries.Ping;
|
||||
|
||||
public record PingQuery : IRequest<OperationResult<PingQueryResult>>;
|
||||
@@ -16,8 +16,10 @@ public static class ServiceCollectionExtension
|
||||
options.Namespace = "Baya.Application.Mediator";
|
||||
});
|
||||
|
||||
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
|
||||
|
||||
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(MetricsBehaviour<,>));
|
||||
|
||||
|
||||
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidateCommandBehavior<,>));
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Baya.Domain.Common;
|
||||
namespace Baya.Domain.Common;
|
||||
|
||||
public interface IEntity
|
||||
{
|
||||
@@ -6,11 +6,21 @@ public interface IEntity
|
||||
|
||||
public interface ITimeModification
|
||||
{
|
||||
DateTime CreatedTime { get; set; }
|
||||
DateTime? ModifiedDate { get; set; }
|
||||
DateTimeOffset CreatedAt { get; set; }
|
||||
DateTimeOffset? ModifiedAt { get; set; }
|
||||
}
|
||||
|
||||
public abstract class BaseEntity<TKey> : IEntity, ITimeModification
|
||||
/// <summary>
|
||||
/// An entity whose create/modify timestamps and the acting user are stamped automatically by the
|
||||
/// SaveChanges audit interceptor — handlers must never set these fields themselves.
|
||||
/// </summary>
|
||||
public interface IAuditableEntity : ITimeModification
|
||||
{
|
||||
int? CreatedById { get; set; }
|
||||
int? ModifiedById { get; set; }
|
||||
}
|
||||
|
||||
public abstract class BaseEntity<TKey> : IEntity, IAuditableEntity
|
||||
{
|
||||
public TKey Id { get; protected set; }
|
||||
|
||||
@@ -49,11 +59,13 @@ public abstract class BaseEntity<TKey> : IEntity, ITimeModification
|
||||
return (GetType().ToString() + Id).GetHashCode();
|
||||
}
|
||||
|
||||
public DateTime CreatedTime { get; set; }
|
||||
public DateTime? ModifiedDate { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? ModifiedAt { get; set; }
|
||||
public int? CreatedById { get; set; }
|
||||
public int? ModifiedById { get; set; }
|
||||
}
|
||||
|
||||
public abstract class BaseEntity : BaseEntity<int>
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Order;
|
||||
|
||||
public class Order:BaseEntity
|
||||
{
|
||||
public string OrderName { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
|
||||
#region Navigation Properties
|
||||
|
||||
public User.User User { get; set; }
|
||||
public int UserId { get; set; }
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -19,11 +19,4 @@ public class User:IdentityUser<int>,IEntity
|
||||
public ICollection<UserClaim> Claims { get; set; }
|
||||
public ICollection<UserToken> Tokens { get; set; }
|
||||
public ICollection<UserRefreshToken> UserRefreshTokens { get; set; }
|
||||
|
||||
#region Navigation Properties
|
||||
|
||||
public IList<Order.Order> Orders { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
using Baya.Domain.Common;
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.User;
|
||||
|
||||
public class UserRefreshToken:BaseEntity<Guid>
|
||||
{
|
||||
public UserRefreshToken()
|
||||
{
|
||||
CreatedAt=DateTime.Now;
|
||||
}
|
||||
|
||||
public int UserId { get; set; }
|
||||
public User User { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public bool IsValid { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user