diff --git a/samples/FastCrud.Samples.Api/Data/AppDbContext.cs b/samples/FastCrud.Samples.Api/Data/AppDbContext.cs index 58cb23b..e3495f9 100644 --- a/samples/FastCrud.Samples.Api/Data/AppDbContext.cs +++ b/samples/FastCrud.Samples.Api/Data/AppDbContext.cs @@ -1,5 +1,6 @@ using FastCrud.Samples.Api.Models; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace FastCrud.Samples.Api.Data; @@ -7,4 +8,19 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon { public DbSet Customers => Set(); public DbSet Orders => Set(); + public DbSet AuditEntries => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.Property(e => e.OldValues) + .HasColumnType("nvarchar(max)"); + + entity.Property(e => e.NewValues) + .HasColumnType("nvarchar(max)"); + }); + } } \ No newline at end of file diff --git a/samples/FastCrud.Samples.Api/Dtos/AuditLogDto.cs b/samples/FastCrud.Samples.Api/Dtos/AuditLogDto.cs new file mode 100644 index 0000000..6957d22 --- /dev/null +++ b/samples/FastCrud.Samples.Api/Dtos/AuditLogDto.cs @@ -0,0 +1,15 @@ +namespace FastCrud.Samples.Api.Dtos +{ + public record AuditLogDto( + object Id, + string Entity, + string EntityId, + string Action, + string Timestamp, + string User, + string? OldValues, + string? NewValues + ); + + public record AuditLogsResponse(int TotalLogs, IEnumerable Logs); +} diff --git a/samples/FastCrud.Samples.Api/FastCrud.Samples.Api.csproj b/samples/FastCrud.Samples.Api/FastCrud.Samples.Api.csproj index 3c089e9..3c18529 100644 --- a/samples/FastCrud.Samples.Api/FastCrud.Samples.Api.csproj +++ b/samples/FastCrud.Samples.Api/FastCrud.Samples.Api.csproj @@ -16,6 +16,7 @@ + diff --git a/samples/FastCrud.Samples.Api/Models/AuditEntry.cs b/samples/FastCrud.Samples.Api/Models/AuditEntry.cs new file mode 100644 index 0000000..14e58c0 --- /dev/null +++ b/samples/FastCrud.Samples.Api/Models/AuditEntry.cs @@ -0,0 +1,18 @@ +using FastCrud.Abstractions.Abstractions; +using FastCrud.Abstractions.Primitives; + +namespace FastCrud.Samples.Api.Models +{ + public sealed class AuditEntry : IAuditEntry + { + public long Id { get; set; } + public string EntityName { get; set; } = default!; + public string EntityId { get; set; } = default!; + public AuditAction Action { get; set; } + public string? OldValues { get; set; } + public string? NewValues { get; set; } + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + public string? UserId { get; set; } + public string? UserName { get; set; } + } +} diff --git a/samples/FastCrud.Samples.Api/Program.cs b/samples/FastCrud.Samples.Api/Program.cs index 3fc1d4a..125512c 100644 --- a/samples/FastCrud.Samples.Api/Program.cs +++ b/samples/FastCrud.Samples.Api/Program.cs @@ -1,5 +1,6 @@ using FastCrud.Core.DI; using FastCrud.Mapping.Mapster.DI; +using FastCrud.Persistence.EFCore; using FastCrud.Persistence.EFCore.DI; using FastCrud.Query.Gridify.DI; using FastCrud.Samples.Api.Data; @@ -12,8 +13,15 @@ var builder = WebApplication.CreateBuilder(args); -builder.Services.AddDbContext(options => - options.UseInMemoryDatabase("fastcrud-demo")); +// Add audit logging +builder.Services.AddEfAuditing(); + +builder.Services.AddDbContext((serviceProvider, options) => +{ + options.UseInMemoryDatabase("fastcrud-demo"); + var auditInterceptor = serviceProvider.GetRequiredService>(); + options.AddInterceptors(auditInterceptor); +}); // FastCrud core services builder.Services.AddFastCrudCore(); @@ -22,15 +30,17 @@ builder.Services.UseFluentValidationAdapter(); // Register EF repositories per aggregate. Required for CrudService to resolve IRepository. + builder.Services.AddEfRepository(); builder.Services.AddEfRepository(); + builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { - Title = "FastCrud Sample API", + Title = "FastCrud Sample API with Audit Logging", Version = "v1", Description = "Demo API showcasing FastCrud with Customer and Order entities using EF Core InMemory" }); @@ -47,10 +57,9 @@ // var c1 = new Customer { FirstName = "Samane", LastName = "Yaghoubi", Email = "samane@example.com" }; // var c2 = new Customer { FirstName = "Ashkan", LastName = "Rahmani", Email = "ashkan@example.com" }; // db.Customers.AddRange(c1, c2); - // db.Orders.AddRange( - // new Order { CustomerId = c1.Id, Number = "ORD-2025-0001", Amount = 120.50m }, - // new Order { CustomerId = c2.Id, Number = "ORD-2025-0002", Amount = 260.00m } + // new Order { CustomerId = c1.Id, Number = "ORD-2025-0001", Amount = 120.50m }, + // new Order { CustomerId = c2.Id, Number = "ORD-2025-0002", Amount = 260.00m } // ); // db.SaveChanges(); } @@ -59,6 +68,14 @@ app.UseSwagger(); app.UseSwaggerUI(); +app.MapAuditLogs( + "/api/audit-logs", + includeEntities: null, + excludeEntities: null, + tagName: nameof(AuditEntry), + groupName: "v1" +); + // Map CRUD endpoints for entities using FastCrud. app.MapFastCrud( "/api/customers", @@ -68,8 +85,8 @@ app.MapFastCrud( "/api/orders", - ops: ~CrudOps.Delete, - tagName: nameof(Order), + ops: ~CrudOps.Delete, + tagName: nameof(Order), groupName: "v1"); app.Run(); \ No newline at end of file diff --git a/src/FastCrud.Abstractions/Abstractions/IAuditEntry.cs b/src/FastCrud.Abstractions/Abstractions/IAuditEntry.cs new file mode 100644 index 0000000..49c9434 --- /dev/null +++ b/src/FastCrud.Abstractions/Abstractions/IAuditEntry.cs @@ -0,0 +1,16 @@ +using FastCrud.Abstractions.Primitives; + +namespace FastCrud.Abstractions.Abstractions +{ + public interface IAuditEntry + { + string EntityName { get; set; } + string EntityId { get; set; } + AuditAction Action { get; set; } + string? OldValues { get; set; } + string? NewValues { get; set; } + DateTime Timestamp { get; set; } + string? UserId { get; set; } + string? UserName { get; set; } + } +} diff --git a/src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs b/src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs new file mode 100644 index 0000000..e1b3890 --- /dev/null +++ b/src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs @@ -0,0 +1,9 @@ +namespace FastCrud.Abstractions.Abstractions +{ + public interface IAuditQueryService + where TAuditEntry : class, IAuditEntry + { + Task GetRecentAuditLogsAsync(int count = 100, CancellationToken cancellationToken = default); + Task GetAuditLogsByEntityAsync(string entityName, int count = 100, CancellationToken cancellationToken = default); + } +} diff --git a/src/FastCrud.Abstractions/Abstractions/IAuditService.cs b/src/FastCrud.Abstractions/Abstractions/IAuditService.cs new file mode 100644 index 0000000..96b19b6 --- /dev/null +++ b/src/FastCrud.Abstractions/Abstractions/IAuditService.cs @@ -0,0 +1,9 @@ +using FastCrud.Abstractions.Primitives; + +namespace FastCrud.Abstractions.Abstractions +{ + public interface IAuditService + { + Task LogAsync(T entity, AuditAction action, object? oldValues = null, object? newValues = null, CancellationToken cancellationToken = default); + } +} diff --git a/src/FastCrud.Abstractions/Abstractions/IAuditUserProvider.cs b/src/FastCrud.Abstractions/Abstractions/IAuditUserProvider.cs new file mode 100644 index 0000000..16fae45 --- /dev/null +++ b/src/FastCrud.Abstractions/Abstractions/IAuditUserProvider.cs @@ -0,0 +1,7 @@ +namespace FastCrud.Abstractions.Abstractions +{ + public interface IAuditUserProvider + { + (string? UserId, string? UserName) GetCurrentUser(); + } +} diff --git a/src/FastCrud.Abstractions/Abstractions/IAuditable.cs b/src/FastCrud.Abstractions/Abstractions/IAuditable.cs new file mode 100644 index 0000000..d28c017 --- /dev/null +++ b/src/FastCrud.Abstractions/Abstractions/IAuditable.cs @@ -0,0 +1,10 @@ +namespace FastCrud.Abstractions.Abstractions +{ + public interface IAuditable + { + DateTime CreatedAt { get; set; } + string? CreatedBy { get; set; } + DateTime? UpdatedAt { get; set; } + string? UpdatedBy { get; set; } + } +} \ No newline at end of file diff --git a/src/FastCrud.Abstractions/FastCrud.Abstractions.csproj b/src/FastCrud.Abstractions/FastCrud.Abstractions.csproj index 35e3d84..6b512ec 100644 --- a/src/FastCrud.Abstractions/FastCrud.Abstractions.csproj +++ b/src/FastCrud.Abstractions/FastCrud.Abstractions.csproj @@ -1,2 +1 @@ - - + diff --git a/src/FastCrud.Abstractions/Primitives/AuditAction.cs b/src/FastCrud.Abstractions/Primitives/AuditAction.cs new file mode 100644 index 0000000..5208a43 --- /dev/null +++ b/src/FastCrud.Abstractions/Primitives/AuditAction.cs @@ -0,0 +1,9 @@ +namespace FastCrud.Abstractions.Primitives +{ + public enum AuditAction + { + Create, + Update, + Delete + } +} diff --git a/src/FastCrud.Core/Services/CrudService.cs b/src/FastCrud.Core/Services/CrudService.cs index a344293..8e2b9bd 100644 --- a/src/FastCrud.Core/Services/CrudService.cs +++ b/src/FastCrud.Core/Services/CrudService.cs @@ -1,29 +1,39 @@ -using System.Collections; using FastCrud.Abstractions.Abstractions; using FastCrud.Abstractions.Primitives; using FastCrud.Abstractions.Query; +using System.Collections; namespace FastCrud.Core.Services; public class CrudService( - IRepository repository, - IObjectMapper mapper, - IEnumerable> validators, - IServiceProvider serviceProvider, - IQueryEngine queryEngine) - : ICrudService + IRepository repository, + IObjectMapper mapper, + IEnumerable> validators, + IServiceProvider serviceProvider, + IQueryEngine queryEngine, + IAuditService? auditService = null) + : ICrudService { public async Task> CreateAsync(TCreateDto input, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(input); var (ok, message) = await ValidateDtoAsync(input!, serviceProvider, cancellationToken); - if(!ok) return new OpResult(false, message); + if (!ok) return new OpResult(false, message); + var entity = mapper.Map(input); - await ValidateModelAsync(entity, cancellationToken); + var (modelOk, modelMessage) = await ValidateModelAsync(entity, cancellationToken); + if (!modelOk) return new OpResult(false, modelMessage); + await repository.AddAsync(entity, cancellationToken); await repository.SaveChangesAsync(cancellationToken); + + if (auditService != null) + { + await auditService.LogAsync(entity, AuditAction.Create, newValues: entity, cancellationToken: cancellationToken); + } + return new OpResult(true, string.Empty, entity); } @@ -31,6 +41,12 @@ public async Task DeleteAsync(TId id, CancellationToken cancellationToken) { var entity = await repository.FindAsync(id, cancellationToken); if (entity is null) return; + + if (auditService != null) + { + await auditService.LogAsync(entity, AuditAction.Delete, oldValues: entity, cancellationToken: cancellationToken); + } + await repository.DeleteAsync(entity, cancellationToken); await repository.SaveChangesAsync(cancellationToken); } @@ -50,14 +66,34 @@ public async Task> UpdateAsync(TId id, TUpdateDto input, Cancella var entity = await repository.FindAsync(id, ct) ?? throw new InvalidOperationException($"{typeof(TAgg).Name} with id {id} not found"); + TAgg? oldValues = auditService != null ? CloneEntity(entity) : default; + mapper.Map(input, entity); var (ok, message) = await ValidateModelAsync(entity, ct); - if(!ok) return new OpResult(false, message); + if (!ok) return new OpResult(false, message); + await repository.SaveChangesAsync(ct); + + if (auditService != null && oldValues != null) + { + await auditService.LogAsync(entity, AuditAction.Update, oldValues: oldValues, newValues: entity, cancellationToken: ct); + } + return new OpResult(true, string.Empty, entity); } + private TAgg? CloneEntity(TAgg entity) + { + try + { + return mapper.Map(entity); + } + catch + { + return default; + } + } private async Task<(bool ok, string message)> ValidateModelAsync( TAgg entity, @@ -89,11 +125,8 @@ public async Task> UpdateAsync(TId id, TUpdateDto input, Cancella var method = v.GetType().GetMethod("ValidateAsync", [dtoType, typeof(CancellationToken)])!; var task = (Task<(bool ok, string message)>)method.Invoke(v, [dto, cancellationToken])!; var result = await task.ConfigureAwait(false); - if(!result.ok) return (false, result.message); + if (!result.ok) return (false, result.message); } return (true, string.Empty); } -} - - - +} \ No newline at end of file diff --git a/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs b/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs new file mode 100644 index 0000000..5eebb3e --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs @@ -0,0 +1,56 @@ +using FastCrud.Abstractions.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FastCrud.Persistence.EFCore.DI; + +public static class AuditServiceCollectionExtensions +{ + public static IServiceCollection AddEfAuditing(this IServiceCollection services) + where TDbContext : DbContext + where TAuditEntry : class, IAuditEntry, new() + { + services.AddScoped(); + services.AddScoped>(); + services.AddScoped(sp => + new EfAuditService( + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddScoped>(sp => + new EfAuditQueryService(sp.GetRequiredService())); + + return services; + } + + public static IServiceCollection AddCustomAuditUserProvider(this IServiceCollection services) + where TProvider : class, IAuditUserProvider + { + services.AddScoped(); + return services; + } + + public static IServiceCollection AddEfAuditing(this IServiceCollection services, + Type auditEntryType) + where TDbContext : DbContext + { + if (!typeof(IAuditEntry).IsAssignableFrom(auditEntryType)) + { + throw new ArgumentException($"Type: {auditEntryType.Name} and IAuditEntry", nameof(auditEntryType)); + } + + services.AddScoped(); + var interceptorType = typeof(EntityAuditingInterceptor<>).MakeGenericType(auditEntryType); + services.AddScoped(interceptorType); + + services.AddScoped(sp => + { + var auditServiceType = typeof(EfAuditService<,>).MakeGenericType(typeof(TDbContext), auditEntryType); + return (IAuditService)Activator.CreateInstance(auditServiceType, + sp.GetRequiredService(), + sp.GetRequiredService())!; + }); + + return services; + } +} \ No newline at end of file diff --git a/src/FastCrud.PersistenceEfCore/DefaultAuditUserProvider.cs b/src/FastCrud.PersistenceEfCore/DefaultAuditUserProvider.cs new file mode 100644 index 0000000..95c317f --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/DefaultAuditUserProvider.cs @@ -0,0 +1,11 @@ +using FastCrud.Abstractions.Abstractions; + +namespace FastCrud.Persistence.EFCore; + +public sealed class DefaultAuditUserProvider : IAuditUserProvider +{ + public (string? UserId, string? UserName) GetCurrentUser() + { + return ("system", "System"); + } +} \ No newline at end of file diff --git a/src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs b/src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs new file mode 100644 index 0000000..f65c3a8 --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs @@ -0,0 +1,75 @@ +using FastCrud.Abstractions.Abstractions; +using Microsoft.EntityFrameworkCore; + +namespace FastCrud.Persistence.EFCore; + +public class EfAuditQueryService : IAuditQueryService + where TAuditEntry : class, IAuditEntry +{ + private readonly DbContext _context; + + public EfAuditQueryService(DbContext context) + { + _context = context; + } + + public async Task GetRecentAuditLogsAsync(int count = 100, CancellationToken cancellationToken = default) + { + var auditLogs = await _context.Set() + .OrderByDescending(x => x.Timestamp) + .Take(count) + .ToListAsync(cancellationToken); + + var logs = auditLogs.Select(log => new + { + Id = GetAuditEntryId(log), + Entity = log.EntityName, + EntityId = log.EntityId.Length > 8 ? log.EntityId[..8] : log.EntityId, + Action = log.Action.ToString(), + Timestamp = log.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"), + User = $"{log.UserName ?? "Unknown"} ({log.UserId ?? "N/A"})", + OldValues = log.OldValues, + NewValues = log.NewValues + }); + + return new + { + TotalLogs = auditLogs.Count, + Logs = logs + }; + } + + public async Task GetAuditLogsByEntityAsync(string entityName, int count = 100, CancellationToken cancellationToken = default) + { + var auditLogs = await _context.Set() + .Where(x => x.EntityName == entityName) + .OrderByDescending(x => x.Timestamp) + .Take(count) + .ToListAsync(cancellationToken); + + var logs = auditLogs.Select(log => new + { + Id = GetAuditEntryId(log), + Entity = log.EntityName, + EntityId = log.EntityId.Length > 8 ? log.EntityId[..8] : log.EntityId, + Action = log.Action.ToString(), + Timestamp = log.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"), + User = $"{log.UserName ?? "Unknown"} ({log.UserId ?? "N/A"})", + OldValues = log.OldValues, + NewValues = log.NewValues + }); + + return new + { + EntityName = entityName, + TotalLogs = auditLogs.Count, + Logs = logs + }; + } + + private static object GetAuditEntryId(IAuditEntry entry) + { + var idProperty = entry.GetType().GetProperty("Id"); + return idProperty?.GetValue(entry) ?? 0; + } +} \ No newline at end of file diff --git a/src/FastCrud.PersistenceEfCore/EfAuditService.cs b/src/FastCrud.PersistenceEfCore/EfAuditService.cs new file mode 100644 index 0000000..c499fa2 --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/EfAuditService.cs @@ -0,0 +1,56 @@ +using FastCrud.Abstractions.Abstractions; +using FastCrud.Abstractions.Primitives; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; + +namespace FastCrud.Persistence.EFCore; + +public sealed class EfAuditService : IAuditService + where TDbContext : DbContext + where TAuditEntry : class, IAuditEntry, new() +{ + private readonly TDbContext _context; + private readonly IAuditUserProvider _userProvider; + + public EfAuditService(TDbContext context, IAuditUserProvider userProvider) + { + _context = context; + _userProvider = userProvider; + } + + public async Task LogAsync(T entity, AuditAction action, object? oldValues = null, object? newValues = null, CancellationToken cancellationToken = default) + { + try + { + var entityId = GetEntityId(entity); + if (string.IsNullOrEmpty(entityId)) return; + + var user = _userProvider.GetCurrentUser(); + + var auditEntry = new TAuditEntry + { + EntityName = typeof(T).Name, + EntityId = entityId, + Action = action, + OldValues = oldValues != null ? JsonSerializer.Serialize(oldValues) : null, + NewValues = newValues != null ? JsonSerializer.Serialize(newValues) : null, + Timestamp = DateTime.UtcNow, + UserId = user.UserId, + UserName = user.UserName + }; + + _context.Set().Add(auditEntry); + await _context.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + } + + private static string? GetEntityId(T entity) + { + var idProperty = typeof(T).GetProperty("Id"); + return idProperty?.GetValue(entity)?.ToString(); + } +} \ No newline at end of file diff --git a/src/FastCrud.PersistenceEfCore/EntityAuditingInterceptor .cs b/src/FastCrud.PersistenceEfCore/EntityAuditingInterceptor .cs new file mode 100644 index 0000000..16fbe8c --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/EntityAuditingInterceptor .cs @@ -0,0 +1,215 @@ +using FastCrud.Abstractions.Abstractions; +using FastCrud.Abstractions.Primitives; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using System.Text.Json; + +namespace FastCrud.Persistence.EFCore; + +public sealed class EntityAuditingInterceptor : SaveChangesInterceptor + where TAuditEntry : class, IAuditEntry, new() +{ + private readonly IAuditUserProvider _userProvider; + + public EntityAuditingInterceptor(IAuditUserProvider userProvider) + { + _userProvider = userProvider; + } + + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + AddAuditEntries(eventData.Context); + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + AddAuditEntries(eventData.Context); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private void AddAuditEntries(DbContext? context) + { + if (context == null) return; + + try + { + var user = _userProvider.GetCurrentUser(); + var auditEntries = new List(); + + foreach (var entry in context.ChangeTracker.Entries()) + { + if (entry.Entity is IAuditEntry) continue; + if (!HasIdProperty(entry.Entity)) continue; + + var auditEntry = CreateAuditEntry(entry, user); + if (auditEntry != null) + { + auditEntries.Add(auditEntry); + } + UpdateAuditableFields(entry, user); + } + + if (auditEntries.Any()) + { + context.Set().AddRange(auditEntries); + } + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + } + + private static bool HasIdProperty(object entity) + { + return entity.GetType().GetProperty("Id") != null; + } + + private TAuditEntry? CreateAuditEntry(EntityEntry entry, (string? UserId, string? UserName) user) + { + try + { + var entityName = entry.Entity.GetType().Name; + var entityId = GetEntityId(entry); + + if (string.IsNullOrEmpty(entityId)) return null; + + var action = entry.State switch + { + EntityState.Added => AuditAction.Create, + EntityState.Modified => AuditAction.Update, + EntityState.Deleted => AuditAction.Delete, + _ => (AuditAction?)null + }; + + if (action == null) return null; + + var auditEntry = new TAuditEntry + { + EntityName = entityName, + EntityId = entityId, + Action = action.Value, + Timestamp = DateTime.UtcNow, + UserId = user.UserId, + UserName = user.UserName + }; + + switch (entry.State) + { + case EntityState.Added: + auditEntry.NewValues = SerializeValues(entry, entry.CurrentValues); + break; + case EntityState.Modified: + var modifiedProperties = entry.Properties + .Where(p => p.IsModified && !p.Metadata.IsPrimaryKey()) + .ToList(); + + if (modifiedProperties.Any()) + { + auditEntry.OldValues = SerializeModifiedValues(entry, entry.OriginalValues, modifiedProperties); + auditEntry.NewValues = SerializeModifiedValues(entry, entry.CurrentValues, modifiedProperties); + } + break; + case EntityState.Deleted: + auditEntry.OldValues = SerializeValues(entry, entry.OriginalValues); + break; + } + + return auditEntry; + } + catch + { + return null; + } + } + + private static void UpdateAuditableFields(EntityEntry entry, (string? UserId, string? UserName) user) + { + if (entry.Entity is not IAuditable auditable) return; + + var now = DateTime.UtcNow; + var userName = user.UserName ?? user.UserId ?? "System"; + + switch (entry.State) + { + case EntityState.Added: + auditable.CreatedAt = now; + auditable.CreatedBy = userName; + break; + case EntityState.Modified: + auditable.UpdatedAt = now; + auditable.UpdatedBy = userName; + break; + } + } + + private static string? GetEntityId(EntityEntry entry) + { + var keyProperty = entry.Properties.FirstOrDefault(p => p.Metadata.IsPrimaryKey()); + return keyProperty?.CurrentValue?.ToString(); + } + + private static string? SerializeValues(EntityEntry entry, PropertyValues values) + { + try + { + var result = new Dictionary(); + + foreach (var property in entry.Properties) + { + if (property.Metadata.IsForeignKey() || + property.Metadata.IsShadowProperty() || + property.Metadata.IsKey()) continue; + + var propertyName = property.Metadata.Name; + var value = values[propertyName]; + + if (value is DateTime dt) + value = dt.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + + result[propertyName] = value; + } + + return result.Any() ? JsonSerializer.Serialize(result, new JsonSerializerOptions + { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }) : null; + } + catch + { + return null; + } + } + + private static string? SerializeModifiedValues(EntityEntry entry, PropertyValues values, List modifiedProperties) + { + try + { + var result = new Dictionary(); + + foreach (var property in modifiedProperties) + { + var propertyName = property.Metadata.Name; + var value = values[propertyName]; + + if (value is DateTime dt) + value = dt.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + + result[propertyName] = value; + } + + return result.Any() ? JsonSerializer.Serialize(result, new JsonSerializerOptions + { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }) : null; + } + catch + { + return null; + } + } +} \ No newline at end of file diff --git a/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs b/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs new file mode 100644 index 0000000..7a87175 --- /dev/null +++ b/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs @@ -0,0 +1,112 @@ +using FastCrud.Abstractions.Abstractions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using System.Reflection; + +namespace FastCrud.Web.MinimalApi; + +public static class AuditEndpointExtensions +{ + public static IEndpointRouteBuilder MapAuditLogs( + this IEndpointRouteBuilder builder, + string routePrefix = "/api/audit-logs", + IEnumerable? includeEntities = null, + IEnumerable? excludeEntities = null, + string? tagName = null, + string? groupName = null) + where TAuditEntry : class, IAuditEntry + { + tagName ??= "Audit"; + var prefix = routePrefix.StartsWith('/') ? routePrefix : $"/{routePrefix}"; + var group = builder.MapGroup(prefix).WithTags(tagName); + + if (groupName != null) + { + group.WithGroupName(groupName); + } + + group.MapGet("/", async ( + IAuditQueryService auditService, + CancellationToken ct) => + { + var result = await auditService.GetRecentAuditLogsAsync(100, ct); + return Results.Ok(result); + }); + + var auditableEntities = GetAuditableEntities(includeEntities, excludeEntities); + + foreach (var entityName in auditableEntities) + { + var entityRoute = $"/{entityName.ToLowerInvariant()}"; + group.MapGet(entityRoute, async ( + IAuditQueryService auditService, + CancellationToken ct) => + { + var result = await auditService.GetAuditLogsByEntityAsync(entityName, 100, ct); + return Results.Ok(result); + }) + .WithName($"GetAuditLogsFor{entityName}"); + } + + return builder; + } + + public static IEndpointRouteBuilder MapAuditLogsFor( + this IEndpointRouteBuilder builder, + string routePrefix, + string? tagName = null, + string? groupName = null, + params Type[] entityTypes) + where TAuditEntry : class, IAuditEntry + { + return MapAuditLogs( + builder, + routePrefix, + includeEntities: entityTypes.Length > 0 ? entityTypes : null, + excludeEntities: null, + tagName: tagName, + groupName: groupName); + } + + private static IEnumerable GetAuditableEntities( + IEnumerable? includeEntities = null, + IEnumerable? excludeEntities = null) + where TAuditEntry : class, IAuditEntry + { + var dbContextType = typeof(TDbContext); + var auditEntryType = typeof(TAuditEntry); + var iAuditEntryType = typeof(IAuditEntry); + + var allEntityTypes = dbContextType.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.PropertyType.IsGenericType && + p.PropertyType.GetGenericTypeDefinition().FullName == "Microsoft.EntityFrameworkCore.DbSet`1") + .Select(p => p.PropertyType.GetGenericArguments()[0]) + .Where(entityType => + !entityType.IsAssignableTo(iAuditEntryType) && + entityType != auditEntryType && + entityType.GetProperty("Id") != null) + .ToList(); + + if (includeEntities != null && includeEntities.Any()) + { + var includeSet = new HashSet(includeEntities); + allEntityTypes = allEntityTypes + .Where(t => includeSet.Contains(t)) + .ToList(); + } + + if (excludeEntities != null && excludeEntities.Any()) + { + var excludeSet = new HashSet(excludeEntities); + allEntityTypes = allEntityTypes + .Where(t => !excludeSet.Contains(t)) + .ToList(); + } + + return allEntityTypes + .Select(entityType => entityType.Name) + .Distinct() + .OrderBy(name => name); + } +} \ No newline at end of file