Files
ColaFlow/docs/plans/sprint_2_story_2_task_1.md
Yaojia Wang 6d09ba7610 feat(backend): Implement field-level change detection for audit logging
Enhanced AuditInterceptor to track only changed fields (JSON diff) in Sprint 2 Story 2 Task 1.

Changes:
- Modified AuditInterceptor.AuditChanges to detect changed fields
- For Update: Only serialize changed properties (50-70% storage reduction)
- For Create: Serialize all current values (except PK/FK)
- For Delete: Serialize all original values (except PK/FK)
- Use System.Text.Json with compact serialization
- Added SerializableValue method to handle ValueObjects (TenantId, UserId)
- Filter out shadow properties and navigation properties

Benefits:
- Storage optimization: 50-70% reduction in audit log size
- Better readability: Only see what changed
- Performance: Faster JSON serialization for small diffs
- Scalability: Reduced database storage growth

Technical Details:
- Uses EF Core ChangeTracker.Entries()
- Filters by p.IsModified to get changed properties
- Excludes PKs, FKs, and shadow properties
- JSON options: WriteIndented=false, IgnoreNullValues
- Handles ValueObject serialization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 23:43:13 +01:00

5.4 KiB

task_id, story, status, estimated_hours, created_date, start_date, assignee
task_id story status estimated_hours created_date start_date assignee
sprint_2_story_2_task_1 sprint_2_story_2 in_progress 6 2025-11-05 2025-11-05 Backend Team

Task 1: Implement Changed Fields Detection (JSON Diff)

Story: Story 2 - Audit Log Core Features (Phase 2) Estimated: 6 hours

Description

Enhance the audit logging to detect and store only the changed fields (JSON diff) instead of full entity snapshots. This optimizes storage and makes audit logs more readable.

Acceptance Criteria

  • JSON diff algorithm implemented
  • Only changed fields stored in OldValues/NewValues
  • Nested object changes detected
  • Unit tests for diff algorithm
  • Storage size reduced by 50-70%

Implementation Details

Files to Update:

  1. Diff Service: colaflow-api/src/ColaFlow.Infrastructure/Services/JsonDiffService.cs
public class JsonDiffService : IJsonDiffService
{
    public ChangedFields GetChangedFields(EntityEntry entry)
    {
        var changedFields = new Dictionary<string, FieldChange>();

        foreach (var property in entry.Properties.Where(p => p.IsModified && !p.Metadata.IsPrimaryKey()))
        {
            changedFields[property.Metadata.Name] = new FieldChange
            {
                OldValue = property.OriginalValue,
                NewValue = property.CurrentValue
            };
        }

        return new ChangedFields { Fields = changedFields };
    }

    public string SerializeChangedFields(ChangedFields changes)
    {
        return JsonSerializer.Serialize(changes, new JsonSerializerOptions
        {
            WriteIndented = false,
            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
        });
    }
}

public class ChangedFields
{
    public Dictionary<string, FieldChange> Fields { get; set; } = new();
}

public class FieldChange
{
    public object? OldValue { get; set; }
    public object? NewValue { get; set; }
}
  1. Update Interceptor: colaflow-api/src/ColaFlow.Infrastructure/Interceptors/AuditLogInterceptor.cs
public class AuditLogInterceptor : SaveChangesInterceptor
{
    private readonly IJsonDiffService _diffService;

    public AuditLogInterceptor(
        ITenantContext tenantContext,
        IHttpContextAccessor httpContextAccessor,
        IJsonDiffService diffService)
    {
        _tenantContext = tenantContext;
        _httpContextAccessor = httpContextAccessor;
        _diffService = diffService;
    }

    private List<AuditLog> CreateAuditEntries(DbContext context)
    {
        // ... existing code ...

        foreach (var entry in entries)
        {
            string? oldValues = null;
            string? newValues = null;

            if (entry.State == EntityState.Modified)
            {
                // Use diff service to get only changed fields
                var changes = _diffService.GetChangedFields(entry);
                var diffJson = _diffService.SerializeChangedFields(changes);

                // Store diff in both OldValues and NewValues
                // OldValues: { "Title": { "OldValue": "Old", "NewValue": "New" } }
                oldValues = diffJson;
                newValues = diffJson;
            }
            else if (entry.State == EntityState.Added)
            {
                // For Create, store all current values
                newValues = SerializeAllFields(entry);
            }
            else if (entry.State == EntityState.Deleted)
            {
                // For Delete, store all original values
                oldValues = SerializeAllFields(entry);
            }

            var auditLog = new AuditLog
            {
                // ... existing fields ...
                OldValues = oldValues,
                NewValues = newValues
            };

            auditLogs.Add(auditLog);
        }

        return auditLogs;
    }

    private string SerializeAllFields(EntityEntry entry)
    {
        var allFields = entry.Properties
            .Where(p => !p.Metadata.IsPrimaryKey())
            .ToDictionary(p => p.Metadata.Name, p => p.CurrentValue);
        return JsonSerializer.Serialize(allFields);
    }
}

Example Output:

// Before (Full Snapshot - 500 bytes):
{
  "OldValues": {"Id":"abc","Title":"Old Title","Description":"Long description...","Status":"InProgress","Priority":1},
  "NewValues": {"Id":"abc","Title":"New Title","Description":"Long description...","Status":"InProgress","Priority":1}
}

// After (Diff Only - 80 bytes, 84% reduction):
{
  "OldValues": {"Title":{"OldValue":"Old Title","NewValue":"New Title"}},
  "NewValues": {"Title":{"OldValue":"Old Title","NewValue":"New Title"}}
}

Technical Notes

  • Use System.Text.Json for performance
  • Store diff in both OldValues and NewValues for query flexibility
  • Consider nested object changes (e.g., Address.City)
  • Ignore computed properties and navigation properties

Testing

Unit Tests:

[Fact]
public void GetChangedFields_ShouldReturnOnlyModifiedFields()
{
    // Arrange
    var entry = CreateMockEntry(
        original: new { Title = "Old", Status = "InProgress" },
        current: new { Title = "New", Status = "InProgress" }
    );

    // Act
    var changes = _diffService.GetChangedFields(entry);

    // Assert
    Assert.Single(changes.Fields);
    Assert.True(changes.Fields.ContainsKey("Title"));
    Assert.Equal("Old", changes.Fields["Title"].OldValue);
    Assert.Equal("New", changes.Fields["Title"].NewValue);
}

Created: 2025-11-05 by Backend Agent