Content Versioning
Guidance for implementing version control, draft/publish workflows, and audit trails for CMS content.
When to Use This Skill
-
Implementing draft/publish workflows
-
Adding version history to content types
-
Building content rollback features
-
Creating audit trails for compliance
-
Comparing content versions
Versioning Strategies
Strategy 1: Separate Draft/Published Records
public class ContentItem { public Guid Id { get; set; } public string ContentType { get; set; } = string.Empty; public ContentStatus Status { get; set; }
// Version tracking
public int Version { get; set; }
public Guid? PublishedVersionId { get; set; }
public Guid? DraftVersionId { get; set; }
// Timestamps
public DateTime CreatedUtc { get; set; }
public DateTime ModifiedUtc { get; set; }
public DateTime? PublishedUtc { get; set; }
}
public class ContentVersion { public Guid Id { get; set; } public Guid ContentItemId { get; set; } public int VersionNumber { get; set; }
// Snapshot of content at this version
public string DataJson { get; set; } = string.Empty;
// Metadata
public string CreatedBy { get; set; } = string.Empty;
public DateTime CreatedUtc { get; set; }
public string? ChangeNote { get; set; }
public bool IsPublished { get; set; }
}
public enum ContentStatus { Draft, Published, Unpublished, Archived }
Strategy 2: History Table Pattern
// Current content (always latest) public class Article { public Guid Id { get; set; } public string Title { get; set; } = string.Empty; public string Body { get; set; } = string.Empty; public int CurrentVersion { get; set; } public ContentStatus Status { get; set; } }
// Automatic history tracking public class ArticleHistory { public Guid Id { get; set; } public Guid ArticleId { get; set; } public int VersionNumber { get; set; }
// Copy of all fields at this version
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
// Audit info
public DateTime ValidFrom { get; set; }
public DateTime ValidTo { get; set; }
public string ModifiedBy { get; set; } = string.Empty;
public ChangeType ChangeType { get; set; }
}
public enum ChangeType { Created, Updated, Published, Unpublished, Deleted }
Strategy 3: Event Sourcing
public abstract class ContentEvent { public Guid Id { get; set; } public Guid ContentItemId { get; set; } public DateTime OccurredUtc { get; set; } public string UserId { get; set; } = string.Empty; public int SequenceNumber { get; set; } }
public class ContentCreatedEvent : ContentEvent { public string ContentType { get; set; } = string.Empty; public string InitialDataJson { get; set; } = string.Empty; }
public class ContentUpdatedEvent : ContentEvent { public Dictionary<string, FieldChange> Changes { get; set; } = new(); }
public class ContentPublishedEvent : ContentEvent { public int PublishedVersion { get; set; } }
public class FieldChange { public object? OldValue { get; set; } public object? NewValue { get; set; } }
Draft/Publish Workflow
Basic Implementation
public class ContentPublishingService { public async Task<ContentItem> CreateDraftAsync( string contentType, object data, string userId) { var item = new ContentItem { Id = Guid.NewGuid(), ContentType = contentType, Status = ContentStatus.Draft, Version = 1, CreatedUtc = DateTime.UtcNow, ModifiedUtc = DateTime.UtcNow };
var version = new ContentVersion
{
Id = Guid.NewGuid(),
ContentItemId = item.Id,
VersionNumber = 1,
DataJson = JsonSerializer.Serialize(data),
CreatedBy = userId,
CreatedUtc = DateTime.UtcNow,
IsPublished = false
};
item.DraftVersionId = version.Id;
await _repository.AddAsync(item);
await _versionRepository.AddAsync(version);
return item;
}
public async Task PublishAsync(Guid contentItemId, string userId)
{
var item = await _repository.GetAsync(contentItemId);
if (item == null || item.DraftVersionId == null)
throw new InvalidOperationException("No draft to publish");
var draft = await _versionRepository.GetAsync(item.DraftVersionId.Value);
// Create published version from draft
var published = new ContentVersion
{
Id = Guid.NewGuid(),
ContentItemId = item.Id,
VersionNumber = item.Version + 1,
DataJson = draft!.DataJson,
CreatedBy = userId,
CreatedUtc = DateTime.UtcNow,
IsPublished = true
};
await _versionRepository.AddAsync(published);
// Update content item
item.Version = published.VersionNumber;
item.PublishedVersionId = published.Id;
item.Status = ContentStatus.Published;
item.PublishedUtc = DateTime.UtcNow;
item.ModifiedUtc = DateTime.UtcNow;
await _repository.UpdateAsync(item);
// Raise event
await _mediator.Publish(new ContentPublishedEvent(item.Id));
}
public async Task UnpublishAsync(Guid contentItemId, string userId)
{
var item = await _repository.GetAsync(contentItemId);
if (item == null)
throw new InvalidOperationException("Content not found");
item.Status = ContentStatus.Unpublished;
item.PublishedVersionId = null;
item.ModifiedUtc = DateTime.UtcNow;
await _repository.UpdateAsync(item);
await _mediator.Publish(new ContentUnpublishedEvent(item.Id));
}
}
Simultaneous Draft and Published
public class ContentQueryService { public async Task<ContentVersion?> GetPublishedAsync(Guid contentItemId) { var item = await _repository.GetAsync(contentItemId); if (item?.PublishedVersionId == null) return null;
return await _versionRepository.GetAsync(item.PublishedVersionId.Value);
}
public async Task<ContentVersion?> GetDraftAsync(Guid contentItemId)
{
var item = await _repository.GetAsync(contentItemId);
if (item?.DraftVersionId == null)
return null;
return await _versionRepository.GetAsync(item.DraftVersionId.Value);
}
public async Task<ContentVersion?> GetLatestAsync(
Guid contentItemId,
bool preferDraft = false)
{
var item = await _repository.GetAsync(contentItemId);
if (item == null) return null;
if (preferDraft && item.DraftVersionId != null)
return await _versionRepository.GetAsync(item.DraftVersionId.Value);
if (item.PublishedVersionId != null)
return await _versionRepository.GetAsync(item.PublishedVersionId.Value);
return null;
}
}
Version History
Retrieving History
public async Task<List<ContentVersionSummary>> GetVersionHistoryAsync( Guid contentItemId, int page = 1, int pageSize = 20) { return await _context.ContentVersions .Where(v => v.ContentItemId == contentItemId) .OrderByDescending(v => v.VersionNumber) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(v => new ContentVersionSummary { Id = v.Id, VersionNumber = v.VersionNumber, CreatedBy = v.CreatedBy, CreatedUtc = v.CreatedUtc, ChangeNote = v.ChangeNote, IsPublished = v.IsPublished }) .ToListAsync(); }
Rollback
public async Task RollbackToVersionAsync( Guid contentItemId, int targetVersion, string userId) { var item = await _repository.GetAsync(contentItemId); var targetVersionRecord = await _versionRepository .GetByVersionNumberAsync(contentItemId, targetVersion);
if (item == null || targetVersionRecord == null)
throw new InvalidOperationException("Invalid rollback target");
// Create new version from old data
var rollbackVersion = new ContentVersion
{
Id = Guid.NewGuid(),
ContentItemId = item.Id,
VersionNumber = item.Version + 1,
DataJson = targetVersionRecord.DataJson,
CreatedBy = userId,
CreatedUtc = DateTime.UtcNow,
ChangeNote = $"Rolled back to version {targetVersion}",
IsPublished = false
};
await _versionRepository.AddAsync(rollbackVersion);
item.Version = rollbackVersion.VersionNumber;
item.DraftVersionId = rollbackVersion.Id;
item.ModifiedUtc = DateTime.UtcNow;
await _repository.UpdateAsync(item);
}
Version Comparison
Generating Diffs
public class VersionComparisonService { public VersionDiff Compare(ContentVersion older, ContentVersion newer) { var oldData = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>( older.DataJson); var newData = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>( newer.DataJson);
var diff = new VersionDiff
{
OlderVersion = older.VersionNumber,
NewerVersion = newer.VersionNumber
};
// Find added fields
foreach (var key in newData!.Keys.Except(oldData!.Keys))
{
diff.Changes.Add(new FieldDiff
{
FieldName = key,
ChangeType = DiffChangeType.Added,
NewValue = newData[key].ToString()
});
}
// Find removed fields
foreach (var key in oldData.Keys.Except(newData.Keys))
{
diff.Changes.Add(new FieldDiff
{
FieldName = key,
ChangeType = DiffChangeType.Removed,
OldValue = oldData[key].ToString()
});
}
// Find modified fields
foreach (var key in oldData.Keys.Intersect(newData.Keys))
{
var oldJson = oldData[key].ToString();
var newJson = newData[key].ToString();
if (oldJson != newJson)
{
diff.Changes.Add(new FieldDiff
{
FieldName = key,
ChangeType = DiffChangeType.Modified,
OldValue = oldJson,
NewValue = newJson
});
}
}
return diff;
}
}
public class VersionDiff { public int OlderVersion { get; set; } public int NewerVersion { get; set; } public List<FieldDiff> Changes { get; set; } = new(); }
public class FieldDiff { public string FieldName { get; set; } = string.Empty; public DiffChangeType ChangeType { get; set; } public string? OldValue { get; set; } public string? NewValue { get; set; } }
public enum DiffChangeType { Added, Removed, Modified }
Audit Trail
Audit Log Entry
public class ContentAuditEntry { public Guid Id { get; set; } public Guid ContentItemId { get; set; } public string ContentType { get; set; } = string.Empty; public string Action { get; set; } = string.Empty; // Created, Updated, Published, etc. public string UserId { get; set; } = string.Empty; public string UserName { get; set; } = string.Empty; public DateTime OccurredUtc { get; set; } public string? IpAddress { get; set; } public string? UserAgent { get; set; } public string? ChangeSummary { get; set; } public string? DataBefore { get; set; } public string? DataAfter { get; set; } }
Automatic Audit Logging
public class AuditInterceptor : SaveChangesInterceptor { private readonly ICurrentUserService _currentUser; private readonly IHttpContextAccessor _httpContext;
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
var context = eventData.Context;
if (context == null) return result;
var entries = context.ChangeTracker.Entries<ContentItem>()
.Where(e => e.State is EntityState.Added
or EntityState.Modified
or EntityState.Deleted);
foreach (var entry in entries)
{
var audit = new ContentAuditEntry
{
Id = Guid.NewGuid(),
ContentItemId = entry.Entity.Id,
ContentType = entry.Entity.ContentType,
Action = entry.State.ToString(),
UserId = _currentUser.UserId,
UserName = _currentUser.UserName,
OccurredUtc = DateTime.UtcNow,
IpAddress = _httpContext.HttpContext?.Connection.RemoteIpAddress?.ToString()
};
if (entry.State == EntityState.Modified)
{
audit.DataBefore = JsonSerializer.Serialize(
entry.OriginalValues.ToObject());
audit.DataAfter = JsonSerializer.Serialize(
entry.CurrentValues.ToObject());
}
context.Set<ContentAuditEntry>().Add(audit);
}
return result;
}
}
API Design
Version Endpoints
GET /api/content/{id}/versions # List version history GET /api/content/{id}/versions/{version} # Get specific version GET /api/content/{id}/versions/compare?v1=1&v2=2 # Compare versions POST /api/content/{id}/versions/{version}/restore # Rollback GET /api/content/{id}/audit # Audit trail
Related Skills
-
content-type-modeling
-
Versionable content types
-
content-workflow
-
Editorial approval workflows
-
headless-api-design
-
Version API endpoints