Initial code commit.

This commit is contained in:
2026-02-03 10:44:31 +08:00
parent 8927c5ae0e
commit d69fe2cc1f
99 changed files with 10839 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
namespace Sufi.Demo.PeopleDirectory.Application.Contracts.Common
{
public interface IService
{
}
}

View File

@@ -0,0 +1,24 @@
using Sufi.Demo.PeopleDirectory.Domain.Common;
namespace Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories
{
public interface IAsyncRepository<T, in TId> where T : class, IEntity<TId>
{
IQueryable<T> Entities { get; }
Task<T?> GetByIdAsync(TId id);
Task<List<T>> GetAllAsync();
Task<List<T>> GetPagedResponseAsync(int pageNumber, int pageSize);
Task<T> AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);
Task<int> DeleteByIdAsync(TId id);
Task<int> CountAsync();
}
}

View File

@@ -0,0 +1,15 @@
using Sufi.Demo.PeopleDirectory.Domain.Common;
namespace Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories
{
public interface IUnitOfWork<TId> : IDisposable
{
IAsyncRepository<T, TId> Repository<T>() where T : AuditableEntity<TId>;
Task<int> Commit(CancellationToken cancellationToken);
Task<int> CommitAndRemoveCache(CancellationToken cancellationToken, params string[] cacheKeys);
Task Rollback();
}
}

View File

@@ -0,0 +1,11 @@
namespace Sufi.Demo.PeopleDirectory.Application.Contracts.Services
{
public interface IAppCache
{
ValueTask<T> GetOrAddAsync<T>(string key, Func<CancellationToken, ValueTask<T>> factory,
IEnumerable<string>? tags = null, TimeSpan? absoluteExpireTime = null);
ValueTask RemoveAsync(string key);
ValueTask RemoveByTagAsync(string tag);
ValueTask ResetAsync();
}
}

View File

@@ -0,0 +1,9 @@
using Sufi.Demo.PeopleDirectory.Application.Contracts.Common;
namespace Sufi.Demo.PeopleDirectory.Application.Contracts.Services
{
public interface ICurrentUserService : IService
{
string? UserId { get; }
}
}

View File

@@ -0,0 +1,10 @@
namespace Sufi.Demo.PeopleDirectory.Application.Enums
{
public enum AuditType
{
None = 0,
Create = 1,
Update = 2,
Delete = 3
}
}

View File

@@ -0,0 +1,28 @@
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
namespace Sufi.Demo.PeopleDirectory.Application.Extensions
{
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddApplicationLayer(this IServiceCollection services, string licenseKey)
{
services.AddAutoMapper(config =>
{
config.LicenseKey = licenseKey;
config.AddMaps(Assembly.GetExecutingAssembly());
});
services.AddMediatR(config =>
{
config.LicenseKey = licenseKey;
config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
});
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
return services;
}
}
}

View File

@@ -0,0 +1,102 @@
using AutoMapper;
using FluentValidation;
using MediatR;
using Microsoft.Extensions.Logging;
using Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories;
using Sufi.Demo.PeopleDirectory.Application.Contracts.Services;
using Sufi.Demo.PeopleDirectory.Domain.Entities.Misc;
using Sufi.Demo.PeopleDirectory.Shared.Wrapper;
namespace Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Commands
{
public class AddEditContactCommand : IRequest<IResult<int>>
{
public int Id { get; set; }
public string UserName { get; set; } = null!;
public string Phone { get; set; } = Random.Shared.Next(1000000000, 1999999999).ToString();
public string Email { get; set; } = "user@example.com";
public string SkillSets { get; set; } = "skill1, skill2, skill3";
public string Hobby { get; set; } = "Hobby";
}
public sealed class AddEditContactCommandValidator : AbstractValidator<AddEditContactCommand>
{
public AddEditContactCommandValidator()
{
RuleFor(v => v.UserName)
.NotEmpty().WithMessage("UserName is required.")
.MaximumLength(50).WithMessage("UserName must not exceed 50 characters.");
RuleFor(v => v.Phone)
.NotEmpty().WithMessage("Phone is required.")
.MaximumLength(20).WithMessage("Phone must not exceed 20 characters.");
RuleFor(v => v.Email)
.NotEmpty().WithMessage("Email is required.")
.EmailAddress().WithMessage("A valid email is required.")
.MaximumLength(100)
.WithMessage("Email must not exceed 100 characters.");
RuleFor(v => v.SkillSets)
.NotEmpty().WithMessage("SkillSets is required.")
.MaximumLength(255).WithMessage("SkillSets must not exceed 255 characters.");
RuleFor(v => v.Hobby)
.NotEmpty().WithMessage("Hobby is required.")
.MaximumLength(255).WithMessage("Hobby must not exceed 255 characters.");
}
}
public class AddEditContactCommandHandler(
IMapper mapper,
IUnitOfWork<int> unitOfWork,
ILogger<AddEditContactCommandHandler> logger,
IAppCache appCache
) : IRequestHandler<AddEditContactCommand, IResult<int>>
{
public async Task<IResult<int>> Handle(AddEditContactCommand command, CancellationToken cancellationToken)
{
if (command.Id == 0)
{
// Only add if max count is not more than 100.
var count = await unitOfWork.Repository<Contact>().CountAsync();
if (count > 100)
{
return await Result<int>.FailAsync("Max item count reached. Please delete some first.");
}
var contact = mapper.Map<Contact>(command);
await unitOfWork.Repository<Contact>().AddAsync(contact);
await unitOfWork.Commit(cancellationToken);
// Invalidate cache.
await appCache.RemoveAsync("contact_all");
logger.LogInformation("New contact added with ID: {Id}", contact.Id);
return await Result<int>.SuccessAsync(contact.Id, "New contact saved.");
}
else
{
var contact = await unitOfWork.Repository<Contact>().GetByIdAsync(command.Id);
if (contact != null)
{
mapper.Map(command, contact);
await unitOfWork.Repository<Contact>().UpdateAsync(contact);
await unitOfWork.Commit(cancellationToken);
// Invalidate cache.
await appCache.RemoveAsync($"contact_{command.Id}");
await appCache.RemoveAsync("contact_all");
logger.LogInformation("Contact updated with ID: {Id}", contact.Id);
return await Result<int>.SuccessAsync(contact.Id, "Contact updated.");
}
else
{
logger.LogWarning("Contact not found with ID: {Id}", command.Id);
return await Result<int>.FailAsync("Contact not found!");
}
}
}
}
}

View File

@@ -0,0 +1,53 @@
using FluentValidation;
using MediatR;
using Microsoft.Extensions.Logging;
using Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories;
using Sufi.Demo.PeopleDirectory.Application.Contracts.Services;
using Sufi.Demo.PeopleDirectory.Domain.Entities.Misc;
using Sufi.Demo.PeopleDirectory.Shared.Wrapper;
namespace Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Commands
{
public class DeleteContactCommand : IRequest<IResult>
{
public int Id { get; set; }
}
public sealed class DeleteContactCommandValidator : AbstractValidator<DeleteContactCommand>
{
public DeleteContactCommandValidator()
{
RuleFor(v => v.Id)
.GreaterThan(0)
.WithMessage("A valid Id is required.");
}
}
public class DeleteContactCommandHandler(
IUnitOfWork<int> unitOfWork,
ILogger<DeleteContactCommandHandler> logger,
IAppCache appCache
) : IRequestHandler<DeleteContactCommand, IResult>
{
public async Task<IResult> Handle(DeleteContactCommand request, CancellationToken cancellationToken)
{
var itemToDelete = await unitOfWork.Repository<Contact>().GetByIdAsync(request.Id);
if (itemToDelete != null)
{
await unitOfWork.Repository<Contact>().DeleteByIdAsync(request.Id);
// Clear cache entries related to contacts.
await appCache.RemoveAsync($"contact_{request.Id}");
await appCache.RemoveAsync("contact_all");
logger.LogInformation("Contact with ID: {Id} deleted.", request.Id);
return Result.Success();
}
logger.LogWarning("No contact found with ID: {Id}", request.Id);
return Result.Fail("No data to delete.");
}
}
}

View File

@@ -0,0 +1,35 @@
using AutoMapper;
using MediatR;
using Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories;
using Sufi.Demo.PeopleDirectory.Application.Contracts.Services;
using Sufi.Demo.PeopleDirectory.Domain.Entities.Misc;
using Sufi.Demo.PeopleDirectory.Shared.Wrapper;
namespace Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetAll
{
public class GetAllContactsQuery : IRequest<IResult<List<GetAllContactsResponse>>>
{
}
public class GetAllContactsQueryHandler(
IUnitOfWork<int> unitOfWork,
IMapper mapper,
IAppCache appCache
) : IRequestHandler<GetAllContactsQuery, IResult<List<GetAllContactsResponse>>>
{
public async Task<IResult<List<GetAllContactsResponse>>> Handle(GetAllContactsQuery request, CancellationToken cancellationToken)
{
Task<List<Contact>> allContactsFunc() => unitOfWork.Repository<Contact>().GetAllAsync();
var allContacts = await appCache.GetOrAddAsync(
"contact_all",
async token => await allContactsFunc(),
absoluteExpireTime: TimeSpan.FromMinutes(2),
tags: ["contacts"]
);
var mappedContacts = mapper.Map<List<GetAllContactsResponse>>(allContacts);
return await Result<List<GetAllContactsResponse>>.SuccessAsync(mappedContacts);
}
}
}

View File

@@ -0,0 +1,12 @@
namespace Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetAll
{
public record GetAllContactsResponse
{
public int Id { get; set; }
public string UserName { get; set; } = null!;
public string Phone { get; set; } = null!;
public string Email { get; set; } = null!;
public string SkillSets { get; set; } = null!;
public string Hobby { get; set; } = null!;
}
}

View File

@@ -0,0 +1,47 @@
using AutoMapper;
using FluentValidation;
using MediatR;
using Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories;
using Sufi.Demo.PeopleDirectory.Application.Contracts.Services;
using Sufi.Demo.PeopleDirectory.Domain.Entities.Misc;
using Sufi.Demo.PeopleDirectory.Shared.Wrapper;
namespace Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetById
{
public class GetContactByIdQuery : IRequest<IResult<GetContactByIdResponse>>
{
public int Id { get; set; }
}
public sealed class GetContactByIdQueryValidator : AbstractValidator<GetContactByIdQuery>
{
public GetContactByIdQueryValidator()
{
RuleFor(v => v.Id)
.GreaterThan(0)
.WithMessage("A valid Id is required.");
}
}
public class GetContactByIdQueryHandler(
IUnitOfWork<int> unitOfWork,
IMapper mapper,
IAppCache appCache
) : IRequestHandler<GetContactByIdQuery, IResult<GetContactByIdResponse>>
{
public async Task<IResult<GetContactByIdResponse>> Handle(GetContactByIdQuery request, CancellationToken cancellationToken)
{
Task<Contact?> getContactByIdFunc() => unitOfWork.Repository<Contact>().GetByIdAsync(request.Id);
var contact = await appCache.GetOrAddAsync(
$"contact_{request.Id}",
async token => await getContactByIdFunc(),
absoluteExpireTime: TimeSpan.FromMinutes(2),
tags: ["contacts"]
);
var mappedContact = mapper.Map<GetContactByIdResponse>(contact);
return await Result<GetContactByIdResponse>.SuccessAsync(mappedContact);
}
}
}

View File

@@ -0,0 +1,12 @@
namespace Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetById
{
public record GetContactByIdResponse
{
public int Id { get; set; }
public string UserName { get; set; } = null!;
public string Phone { get; set; } = null!;
public string Email { get; set; } = null!;
public string SkillSets { get; set; } = null!;
public string Hobby { get; set; } = null!;
}
}

View File

@@ -0,0 +1,20 @@
using AutoMapper;
using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Commands;
using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetAll;
using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetById;
using Sufi.Demo.PeopleDirectory.Application.Responses;
using Sufi.Demo.PeopleDirectory.Domain.Entities.Misc;
namespace Sufi.Demo.PeopleDirectory.Application.Mappings
{
public class ContactProfile : Profile
{
public ContactProfile()
{
CreateMap<AddEditContactCommand, Contact>().ReverseMap();
CreateMap<GetAllContactsResponse, Contact>().ReverseMap();
CreateMap<Contact, GetContactByIdResponse>();
CreateMap<Contact, ContactResponse>();
}
}
}

View File

@@ -0,0 +1,12 @@
namespace Sufi.Demo.PeopleDirectory.Application.Responses
{
public class ContactResponse
{
public int Id { get; set; }
public string UserName { get; set; } = null!;
public string Phone { get; set; } = null!;
public string Email { get; set; } = null!;
public string SkillSets { get; set; } = null!;
public string Hobby { get; set; } = null!;
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="15.0.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
<PackageReference Include="MediatR" Version="13.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Sufi.Demo.PeopleDirectory.Domain\Sufi.Demo.PeopleDirectory.Domain.csproj" />
<ProjectReference Include="..\Sufi.Demo.PeopleDirectory.Shared\Sufi.Demo.PeopleDirectory.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Features\Boomerang\" />
</ItemGroup>
</Project>