diff --git a/DiunaBI.API/Controllers/LayersController.cs b/DiunaBI.API/Controllers/LayersController.cs index 5b2e154..f35fdde 100644 --- a/DiunaBI.API/Controllers/LayersController.cs +++ b/DiunaBI.API/Controllers/LayersController.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Text; +using System.Text.Json; using Google.Apis.Sheets.v4; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -781,6 +782,9 @@ public class LayersController : Controller _db.Records.Add(record); + // Capture history + CaptureRecordHistory(record, RecordChangeType.Created, Guid.Parse(userId)); + // Update layer modified info layer.ModifiedAt = DateTime.UtcNow; layer.ModifiedById = Guid.Parse(userId); @@ -851,10 +855,17 @@ public class LayersController : Controller return BadRequest("Desc1 is required"); } + // Capture old values before updating + var oldCode = record.Code; + var oldDesc1 = record.Desc1; + record.Desc1 = recordDto.Desc1; record.ModifiedAt = DateTime.UtcNow; record.ModifiedById = Guid.Parse(userId); + // Capture history + CaptureRecordHistory(record, RecordChangeType.Updated, Guid.Parse(userId), oldCode, oldDesc1); + // Update layer modified info layer.ModifiedAt = DateTime.UtcNow; layer.ModifiedById = Guid.Parse(userId); @@ -915,6 +926,9 @@ public class LayersController : Controller return NotFound("Record not found"); } + // Capture history before deleting + CaptureRecordHistory(record, RecordChangeType.Deleted, Guid.Parse(userId)); + _db.Records.Remove(record); // Update layer modified info @@ -933,4 +947,165 @@ public class LayersController : Controller return BadRequest(e.ToString()); } } + + [HttpGet] + [Route("{layerId:guid}/records/{recordId:guid}/history")] + public IActionResult GetRecordHistory(Guid layerId, Guid recordId) + { + try + { + var history = _db.RecordHistory + .Include(h => h.ChangedBy) + .Where(h => h.RecordId == recordId && h.LayerId == layerId) + .OrderByDescending(h => h.ChangedAt) + .AsNoTracking() + .Select(h => new RecordHistoryDto + { + Id = h.Id, + RecordId = h.RecordId, + LayerId = h.LayerId, + ChangedAt = h.ChangedAt, + ChangedById = h.ChangedById, + ChangedByName = h.ChangedBy != null ? h.ChangedBy.UserName ?? h.ChangedBy.Email : "Unknown", + ChangeType = h.ChangeType.ToString(), + Code = h.Code, + Desc1 = h.Desc1, + ChangedFields = h.ChangedFields, + ChangesSummary = h.ChangesSummary, + FormattedChange = FormatHistoryChange(h) + }) + .ToList(); + + _logger.LogDebug("GetRecordHistory: Retrieved {Count} history entries for record {RecordId}", history.Count, recordId); + + return Ok(history); + } + catch (Exception e) + { + _logger.LogError(e, "GetRecordHistory: Error retrieving history for record {RecordId}", recordId); + return BadRequest(e.ToString()); + } + } + + [HttpGet] + [Route("{layerId:guid}/records/deleted")] + public IActionResult GetDeletedRecords(Guid layerId) + { + try + { + // Get the most recent "Deleted" history entry for each unique RecordId in this layer + var deletedRecords = _db.RecordHistory + .Include(h => h.ChangedBy) + .Where(h => h.LayerId == layerId && h.ChangeType == RecordChangeType.Deleted) + .GroupBy(h => h.RecordId) + .Select(g => g.OrderByDescending(h => h.ChangedAt).FirstOrDefault()) + .Where(h => h != null) + .Select(h => new DeletedRecordDto + { + RecordId = h!.RecordId, + Code = h.Code, + Desc1 = h.Desc1, + DeletedAt = h.ChangedAt, + DeletedById = h.ChangedById, + DeletedByName = h.ChangedBy != null ? h.ChangedBy.UserName ?? string.Empty : string.Empty + }) + .OrderByDescending(d => d.DeletedAt) + .ToList(); + + _logger.LogDebug("GetDeletedRecords: Retrieved {Count} deleted records for layer {LayerId}", deletedRecords.Count, layerId); + return Ok(deletedRecords); + } + catch (Exception e) + { + _logger.LogError(e, "GetDeletedRecords: Error retrieving deleted records for layer {LayerId}", layerId); + return BadRequest(e.ToString()); + } + } + + // Helper method to capture record history + private void CaptureRecordHistory(Record record, RecordChangeType changeType, Guid userId, string? oldCode = null, string? oldDesc1 = null) + { + var changedFields = new List(); + var changesSummary = new Dictionary>(); + + if (changeType == RecordChangeType.Updated) + { + if (oldCode != record.Code) + { + changedFields.Add("Code"); + changesSummary["Code"] = new Dictionary + { + ["old"] = oldCode, + ["new"] = record.Code + }; + } + + if (oldDesc1 != record.Desc1) + { + changedFields.Add("Desc1"); + changesSummary["Desc1"] = new Dictionary + { + ["old"] = oldDesc1, + ["new"] = record.Desc1 + }; + } + } + + var history = new RecordHistory + { + Id = Guid.NewGuid(), + RecordId = record.Id, + LayerId = record.LayerId, + ChangedAt = DateTime.UtcNow, + ChangedById = userId, + ChangeType = changeType, + Code = record.Code, + Desc1 = record.Desc1, + ChangedFields = changedFields.Any() ? string.Join(", ", changedFields) : null, + ChangesSummary = changesSummary.Any() ? JsonSerializer.Serialize(changesSummary) : null + }; + + _db.RecordHistory.Add(history); + _logger.LogInformation("CaptureRecordHistory: Captured {ChangeType} for record {RecordId}", changeType, record.Id); + } + + // Helper method to format history change for display + private static string FormatHistoryChange(RecordHistory h) + { + if (h.ChangeType == RecordChangeType.Created) + { + return $"Created record with Code: \"{h.Code}\", Description: \"{h.Desc1}\""; + } + + if (h.ChangeType == RecordChangeType.Deleted) + { + return $"Deleted record Code: \"{h.Code}\", Description: \"{h.Desc1}\""; + } + + // Updated + if (!string.IsNullOrEmpty(h.ChangesSummary)) + { + try + { + var changes = JsonSerializer.Deserialize>>(h.ChangesSummary); + if (changes != null) + { + var parts = new List(); + foreach (var (field, values) in changes) + { + var oldVal = values.GetValueOrDefault("old") ?? "empty"; + var newVal = values.GetValueOrDefault("new") ?? "empty"; + parts.Add($"{field}: \"{oldVal}\" → \"{newVal}\""); + } + return $"Updated: {string.Join(", ", parts)}"; + } + } + catch + { + // Fall back to simple message + } + } + + return $"Updated {h.ChangedFields ?? "record"}"; + } } \ No newline at end of file diff --git a/DiunaBI.Application/DTOModels/DeletedRecordDto.cs b/DiunaBI.Application/DTOModels/DeletedRecordDto.cs new file mode 100644 index 0000000..49121cc --- /dev/null +++ b/DiunaBI.Application/DTOModels/DeletedRecordDto.cs @@ -0,0 +1,11 @@ +namespace DiunaBI.Application.DTOModels; + +public class DeletedRecordDto +{ + public Guid RecordId { get; set; } + public string Code { get; set; } = string.Empty; + public string? Desc1 { get; set; } + public DateTime DeletedAt { get; set; } + public Guid DeletedById { get; set; } + public string DeletedByName { get; set; } = string.Empty; +} diff --git a/DiunaBI.Application/DTOModels/RecordHistoryDto.cs b/DiunaBI.Application/DTOModels/RecordHistoryDto.cs new file mode 100644 index 0000000..92b4026 --- /dev/null +++ b/DiunaBI.Application/DTOModels/RecordHistoryDto.cs @@ -0,0 +1,27 @@ +namespace DiunaBI.Application.DTOModels; + +public class RecordHistoryDto +{ + public Guid Id { get; set; } + public Guid RecordId { get; set; } + public Guid LayerId { get; set; } + + // When and who + public DateTime ChangedAt { get; set; } + public Guid ChangedById { get; set; } + public string ChangedByName { get; set; } = string.Empty; + + // Type of change + public string ChangeType { get; set; } = string.Empty; // "Created", "Updated", "Deleted" + + // Snapshot values + public string Code { get; set; } = string.Empty; + public string? Desc1 { get; set; } + + // What changed + public string? ChangedFields { get; set; } // "Code, Desc1" + public string? ChangesSummary { get; set; } // JSON: {"Code": {"old": "A", "new": "B"}} + + // Formatted display text + public string FormattedChange { get; set; } = string.Empty; +} diff --git a/DiunaBI.Domain/Entities/RecordHistory.cs b/DiunaBI.Domain/Entities/RecordHistory.cs new file mode 100644 index 0000000..44c655f --- /dev/null +++ b/DiunaBI.Domain/Entities/RecordHistory.cs @@ -0,0 +1,37 @@ +using System; + +namespace DiunaBI.Domain.Entities; + +public enum RecordChangeType +{ + Created = 1, + Updated = 2, + Deleted = 3 +} + +public class RecordHistory +{ + public Guid Id { get; set; } + + // Reference to the original record + public Guid RecordId { get; set; } + public Guid LayerId { get; set; } + + // When and who + public DateTime ChangedAt { get; set; } + public Guid ChangedById { get; set; } + public User? ChangedBy { get; set; } + + // Type of change + public RecordChangeType ChangeType { get; set; } + + // Snapshot of record state at this point + public string Code { get; set; } = string.Empty; + public string? Desc1 { get; set; } + + // Comma-separated list of fields that changed (e.g., "Code,Desc1") + public string? ChangedFields { get; set; } + + // JSON object with detailed changes: {"Code": {"old": "A", "new": "B"}} + public string? ChangesSummary { get; set; } +} diff --git a/DiunaBI.Infrastructure/Data/AppDbContext.cs b/DiunaBI.Infrastructure/Data/AppDbContext.cs index fa2ad99..4110933 100644 --- a/DiunaBI.Infrastructure/Data/AppDbContext.cs +++ b/DiunaBI.Infrastructure/Data/AppDbContext.cs @@ -8,6 +8,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op public DbSet Users { get; init; } public DbSet Layers { get; init; } public DbSet Records { get; init; } + public DbSet RecordHistory { get; init; } public DbSet ProcessSources { get; init; } public DbSet DataInbox { get; init; } public DbSet QueueJobs { get; init; } @@ -75,6 +76,30 @@ public class AppDbContext(DbContextOptions options) : DbContext(op .HasForeignKey(x => x.LayerId) .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().Property(x => x.RecordId).IsRequired(); + modelBuilder.Entity().Property(x => x.LayerId).IsRequired(); + modelBuilder.Entity().Property(x => x.ChangedAt).IsRequired(); + modelBuilder.Entity().Property(x => x.ChangedById).IsRequired(); + modelBuilder.Entity().Property(x => x.ChangeType).IsRequired().HasConversion(); + modelBuilder.Entity().Property(x => x.Code).IsRequired().HasMaxLength(50); + modelBuilder.Entity().Property(x => x.Desc1).HasMaxLength(10000); + modelBuilder.Entity().Property(x => x.ChangedFields).HasMaxLength(200); + modelBuilder.Entity().Property(x => x.ChangesSummary).HasMaxLength(4000); + + // Indexes for efficient history queries + modelBuilder.Entity() + .HasIndex(x => new { x.RecordId, x.ChangedAt }); + + modelBuilder.Entity() + .HasIndex(x => new { x.LayerId, x.ChangedAt }); + + modelBuilder.Entity() + .HasOne(x => x.ChangedBy) + .WithMany() + .HasForeignKey(x => x.ChangedById) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity().HasKey(x => new { x.LayerId, x.SourceId }); modelBuilder.Entity().Property(x => x.LayerId).IsRequired(); modelBuilder.Entity().Property(x => x.SourceId).IsRequired(); diff --git a/DiunaBI.Infrastructure/Migrations/20251201165810_RecordHistory.Designer.cs b/DiunaBI.Infrastructure/Migrations/20251201165810_RecordHistory.Designer.cs new file mode 100644 index 0000000..f39c7c3 --- /dev/null +++ b/DiunaBI.Infrastructure/Migrations/20251201165810_RecordHistory.Designer.cs @@ -0,0 +1,490 @@ +// +using System; +using DiunaBI.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DiunaBI.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251201165810_RecordHistory")] + partial class RecordHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("DiunaBI.Domain.Entities.DataInbox", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("Data") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("DataInbox"); + }); + + modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier"); + + b.Property("IsCancelled") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("ModifiedById") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("ModifiedById"); + + b.ToTable("Layers"); + }); + + modelBuilder.Entity("DiunaBI.Domain.Entities.ProcessSource", b => + { + b.Property("LayerId") + .HasColumnType("uniqueidentifier"); + + b.Property("SourceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LayerId", "SourceId"); + + b.HasIndex("SourceId"); + + b.ToTable("ProcessSources"); + }); + + modelBuilder.Entity("DiunaBI.Domain.Entities.QueueJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier"); + + b.Property("JobType") + .HasColumnType("int"); + + b.Property("LastAttemptAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("LayerId") + .HasColumnType("uniqueidentifier"); + + b.Property("LayerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("ModifiedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ModifiedById") + .HasColumnType("uniqueidentifier"); + + b.Property("PluginName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("QueueJobs"); + }); + + modelBuilder.Entity("DiunaBI.Domain.Entities.Record", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier"); + + b.Property("Desc1") + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LayerId") + .HasColumnType("uniqueidentifier"); + + b.Property("ModifiedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("ModifiedById") + .HasColumnType("uniqueidentifier"); + + b.Property("Value1") + .HasColumnType("float"); + + b.Property("Value10") + .HasColumnType("float"); + + b.Property("Value11") + .HasColumnType("float"); + + b.Property("Value12") + .HasColumnType("float"); + + b.Property("Value13") + .HasColumnType("float"); + + b.Property("Value14") + .HasColumnType("float"); + + b.Property("Value15") + .HasColumnType("float"); + + b.Property("Value16") + .HasColumnType("float"); + + b.Property("Value17") + .HasColumnType("float"); + + b.Property("Value18") + .HasColumnType("float"); + + b.Property("Value19") + .HasColumnType("float"); + + b.Property("Value2") + .HasColumnType("float"); + + b.Property("Value20") + .HasColumnType("float"); + + b.Property("Value21") + .HasColumnType("float"); + + b.Property("Value22") + .HasColumnType("float"); + + b.Property("Value23") + .HasColumnType("float"); + + b.Property("Value24") + .HasColumnType("float"); + + b.Property("Value25") + .HasColumnType("float"); + + b.Property("Value26") + .HasColumnType("float"); + + b.Property("Value27") + .HasColumnType("float"); + + b.Property("Value28") + .HasColumnType("float"); + + b.Property("Value29") + .HasColumnType("float"); + + b.Property("Value3") + .HasColumnType("float"); + + b.Property("Value30") + .HasColumnType("float"); + + b.Property("Value31") + .HasColumnType("float"); + + b.Property("Value32") + .HasColumnType("float"); + + b.Property("Value4") + .HasColumnType("float"); + + b.Property("Value5") + .HasColumnType("float"); + + b.Property("Value6") + .HasColumnType("float"); + + b.Property("Value7") + .HasColumnType("float"); + + b.Property("Value8") + .HasColumnType("float"); + + b.Property("Value9") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("LayerId"); + + b.HasIndex("ModifiedById"); + + b.ToTable("Records"); + }); + + modelBuilder.Entity("DiunaBI.Domain.Entities.RecordHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ChangeType") + .HasColumnType("int"); + + b.Property("ChangedAt") + .HasColumnType("datetime2"); + + b.Property("ChangedById") + .HasColumnType("uniqueidentifier"); + + b.Property("ChangedFields") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ChangesSummary") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Desc1") + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("LayerId") + .HasColumnType("uniqueidentifier"); + + b.Property("RecordId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ChangedById"); + + b.HasIndex("LayerId", "ChangedAt"); + + b.HasIndex("RecordId", "ChangedAt"); + + b.ToTable("RecordHistory"); + }); + + modelBuilder.Entity("DiunaBI.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("Email") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UserName") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b => + { + b.HasOne("DiunaBI.Domain.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("DiunaBI.Domain.Entities.User", "ModifiedBy") + .WithMany() + .HasForeignKey("ModifiedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedBy"); + + b.Navigation("ModifiedBy"); + }); + + modelBuilder.Entity("DiunaBI.Domain.Entities.ProcessSource", b => + { + b.HasOne("DiunaBI.Domain.Entities.Layer", null) + .WithMany() + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DiunaBI.Domain.Entities.Layer", "Source") + .WithMany() + .HasForeignKey("SourceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("DiunaBI.Domain.Entities.Record", b => + { + b.HasOne("DiunaBI.Domain.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("DiunaBI.Domain.Entities.Layer", null) + .WithMany("Records") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DiunaBI.Domain.Entities.User", "ModifiedBy") + .WithMany() + .HasForeignKey("ModifiedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedBy"); + + b.Navigation("ModifiedBy"); + }); + + modelBuilder.Entity("DiunaBI.Domain.Entities.RecordHistory", b => + { + b.HasOne("DiunaBI.Domain.Entities.User", "ChangedBy") + .WithMany() + .HasForeignKey("ChangedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ChangedBy"); + }); + + modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b => + { + b.Navigation("Records"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DiunaBI.Infrastructure/Migrations/20251201165810_RecordHistory.cs b/DiunaBI.Infrastructure/Migrations/20251201165810_RecordHistory.cs new file mode 100644 index 0000000..ad6b2b9 --- /dev/null +++ b/DiunaBI.Infrastructure/Migrations/20251201165810_RecordHistory.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DiunaBI.Infrastructure.Migrations +{ + /// + public partial class RecordHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RecordHistory", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + RecordId = table.Column(type: "uniqueidentifier", nullable: false), + LayerId = table.Column(type: "uniqueidentifier", nullable: false), + ChangedAt = table.Column(type: "datetime2", nullable: false), + ChangedById = table.Column(type: "uniqueidentifier", nullable: false), + ChangeType = table.Column(type: "int", nullable: false), + Code = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + Desc1 = table.Column(type: "nvarchar(max)", maxLength: 10000, nullable: true), + ChangedFields = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + ChangesSummary = table.Column(type: "nvarchar(4000)", maxLength: 4000, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RecordHistory", x => x.Id); + table.ForeignKey( + name: "FK_RecordHistory_Users_ChangedById", + column: x => x.ChangedById, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_RecordHistory_ChangedById", + table: "RecordHistory", + column: "ChangedById"); + + migrationBuilder.CreateIndex( + name: "IX_RecordHistory_LayerId_ChangedAt", + table: "RecordHistory", + columns: new[] { "LayerId", "ChangedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_RecordHistory_RecordId_ChangedAt", + table: "RecordHistory", + columns: new[] { "RecordId", "ChangedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RecordHistory"); + } + } +} diff --git a/DiunaBI.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/DiunaBI.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 08c9ecc..9b50f25 100644 --- a/DiunaBI.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/DiunaBI.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -332,6 +332,55 @@ namespace DiunaBI.Infrastructure.Migrations b.ToTable("Records"); }); + modelBuilder.Entity("DiunaBI.Domain.Entities.RecordHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ChangeType") + .HasColumnType("int"); + + b.Property("ChangedAt") + .HasColumnType("datetime2"); + + b.Property("ChangedById") + .HasColumnType("uniqueidentifier"); + + b.Property("ChangedFields") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ChangesSummary") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Desc1") + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("LayerId") + .HasColumnType("uniqueidentifier"); + + b.Property("RecordId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ChangedById"); + + b.HasIndex("LayerId", "ChangedAt"); + + b.HasIndex("RecordId", "ChangedAt"); + + b.ToTable("RecordHistory"); + }); + modelBuilder.Entity("DiunaBI.Domain.Entities.User", b => { b.Property("Id") @@ -417,6 +466,17 @@ namespace DiunaBI.Infrastructure.Migrations b.Navigation("ModifiedBy"); }); + modelBuilder.Entity("DiunaBI.Domain.Entities.RecordHistory", b => + { + b.HasOne("DiunaBI.Domain.Entities.User", "ChangedBy") + .WithMany() + .HasForeignKey("ChangedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ChangedBy"); + }); + modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b => { b.Navigation("Records"); diff --git a/DiunaBI.UI.Shared/Pages/LayerDetailPage.razor b/DiunaBI.UI.Shared/Pages/LayerDetailPage.razor index b1177c2..fe6043d 100644 --- a/DiunaBI.UI.Shared/Pages/LayerDetailPage.razor +++ b/DiunaBI.UI.Shared/Pages/LayerDetailPage.razor @@ -72,6 +72,9 @@ + + + } } + + + + @if (isLoadingHistory) + { + + } + else if (selectedRecordForHistory != null || selectedDeletedRecordForHistory != null) + { + + + History for Record: + @if (selectedDeletedRecordForHistory != null) + { + @selectedDeletedRecordForHistory.Code (Deleted) + } + else + { + @selectedRecordForHistory?.Code + } + + + Back to list + + + + @if (!recordHistory.Any()) + { + No history available for this record. + } + else + { + + @foreach (var history in recordHistory) + { + + + + @history.ChangedAt.ToString("g") + + + + + + @history.ChangeType by @history.ChangedByName + + + @history.FormattedChange + + + + + } + + } + } + else + { + Active Records + Select a record to view its history: + + + + Code + Description + Modified + Modified By + + + @context.Code + @context.Desc1 + @context.ModifiedAt.ToString("g") + @GetModifiedByUsername(context.ModifiedById) + + + + + + Deleted Records + Select a deleted record to view its history: + + @if (deletedRecords.Any()) + { + + + Code + Description + Deleted + Deleted By + + + + @context.Code + + @context.Desc1 + @context.DeletedAt.ToString("g") + @context.DeletedByName + + + } + else + { + No deleted records found. + } + } + + } diff --git a/DiunaBI.UI.Shared/Pages/LayerDetailPage.razor.cs b/DiunaBI.UI.Shared/Pages/LayerDetailPage.razor.cs index 99f908a..b079785 100644 --- a/DiunaBI.UI.Shared/Pages/LayerDetailPage.razor.cs +++ b/DiunaBI.UI.Shared/Pages/LayerDetailPage.razor.cs @@ -27,6 +27,15 @@ public partial class LayerDetailPage : ComponentBase private RecordDto newRecord = new(); private bool isEditable => layer?.Type == LayerType.Dictionary || layer?.Type == LayerType.Administration; + // History tab state + private bool isLoadingHistory = false; + private bool isHistoryTabInitialized = false; + private RecordDto? selectedRecordForHistory = null; + private DeletedRecordDto? selectedDeletedRecordForHistory = null; + private List recordHistory = new(); + private List deletedRecords = new(); + private Dictionary userCache = new(); + protected override async Task OnInitializedAsync() { await LoadLayer(); @@ -37,6 +46,16 @@ public partial class LayerDetailPage : ComponentBase await LoadLayer(); } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!isHistoryTabInitialized && !isLoadingHistory && + selectedRecordForHistory == null && selectedDeletedRecordForHistory == null && + deletedRecords.Count == 0) + { + await LoadDeletedRecordsAsync(); + } + } + private async Task LoadLayer() { isLoading = true; @@ -51,6 +70,7 @@ public partial class LayerDetailPage : ComponentBase records = layer.Records; CalculateDisplayedColumns(); CalculateValueSum(); + BuildUserCache(); } } catch (Exception ex) @@ -278,4 +298,114 @@ public partial class LayerDetailPage : ComponentBase Snackbar.Add("Error adding record", Severity.Error); } } + + // History tab methods + private async Task LoadDeletedRecordsAsync() + { + if (isHistoryTabInitialized || layer == null) return; + + isHistoryTabInitialized = true; + + try + { + Console.WriteLine($"Loading deleted records for layer {layer.Id}"); + deletedRecords = await LayerService.GetDeletedRecordsAsync(layer.Id); + Console.WriteLine($"Loaded {deletedRecords.Count} deleted records"); + StateHasChanged(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error loading deleted records: {ex.Message}"); + Console.Error.WriteLine($"Stack trace: {ex.StackTrace}"); + deletedRecords = new List(); + } + } + + private async Task OnRecordClickForHistory(TableRowClickEventArgs args) + { + if (args.Item == null || layer == null) return; + + selectedRecordForHistory = args.Item; + isLoadingHistory = true; + StateHasChanged(); + + try + { + recordHistory = await LayerService.GetRecordHistoryAsync(layer.Id, args.Item.Id); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error loading record history: {ex.Message}"); + Snackbar.Add("Error loading record history", Severity.Error); + recordHistory = new List(); + } + finally + { + isLoadingHistory = false; + StateHasChanged(); + } + } + + private void ClearHistorySelection() + { + selectedRecordForHistory = null; + selectedDeletedRecordForHistory = null; + recordHistory.Clear(); + isHistoryTabInitialized = false; // Reset so deleted records reload when returning to list + } + + private async Task OnDeletedRecordClickForHistory(TableRowClickEventArgs args) + { + if (args.Item == null || layer == null) return; + + selectedDeletedRecordForHistory = args.Item; + selectedRecordForHistory = null; + isLoadingHistory = true; + StateHasChanged(); + + try + { + recordHistory = await LayerService.GetRecordHistoryAsync(layer.Id, args.Item.RecordId); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error loading deleted record history: {ex.Message}"); + Snackbar.Add("Error loading record history", Severity.Error); + recordHistory = new List(); + } + finally + { + isLoadingHistory = false; + StateHasChanged(); + } + } + + private Color GetHistoryColor(string changeType) + { + return changeType switch + { + "Created" => Color.Success, + "Updated" => Color.Info, + "Deleted" => Color.Error, + _ => Color.Default + }; + } + + private void BuildUserCache() + { + userCache.Clear(); + + if (layer == null) return; + + // Add layer-level users to cache + if (layer.CreatedBy != null) + userCache.TryAdd(layer.CreatedBy.Id, layer.CreatedBy.Username ?? string.Empty); + if (layer.ModifiedBy != null) + userCache.TryAdd(layer.ModifiedBy.Id, layer.ModifiedBy.Username ?? string.Empty); + } + + private string GetModifiedByUsername(Guid userId) + { + return userCache.TryGetValue(userId, out var username) ? username : string.Empty; + } } diff --git a/DiunaBI.UI.Shared/Services/LayerService.cs b/DiunaBI.UI.Shared/Services/LayerService.cs index 4cbf2dd..3493e09 100644 --- a/DiunaBI.UI.Shared/Services/LayerService.cs +++ b/DiunaBI.UI.Shared/Services/LayerService.cs @@ -90,4 +90,32 @@ public class LayerService var response = await _httpClient.DeleteAsync($"Layers/{layerId}/records/{recordId}"); return response.IsSuccessStatusCode; } + + public async Task> GetRecordHistoryAsync(Guid layerId, Guid recordId) + { + var response = await _httpClient.GetAsync($"Layers/{layerId}/records/{recordId}/history"); + + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(); + Console.Error.WriteLine($"GetRecordHistoryAsync failed: {response.StatusCode} - {error}"); + return new List(); + } + + return await response.Content.ReadFromJsonAsync>() ?? new List(); + } + + public async Task> GetDeletedRecordsAsync(Guid layerId) + { + var response = await _httpClient.GetAsync($"Layers/{layerId}/records/deleted"); + + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(); + Console.Error.WriteLine($"GetDeletedRecordsAsync failed: {response.StatusCode} - {error}"); + return new List(); + } + + return await response.Content.ReadFromJsonAsync>() ?? new List(); + } } \ No newline at end of file