Marketplace

content-versioning

Use when implementing draft/publish workflows, version history, content rollback, or audit trails. Covers versioning strategies, snapshot storage, diff generation, and version comparison APIs for headless CMS.

allowed_tools: Read, Glob, Grep, Task, Skill

$ 安裝

git clone https://github.com/melodic-software/claude-code-plugins /tmp/claude-code-plugins && cp -r /tmp/claude-code-plugins/plugins/content-management-system/skills/content-versioning ~/.claude/skills/claude-code-plugins

// tip: Run this command in your terminal to install the skill


name: content-versioning description: Use when implementing draft/publish workflows, version history, content rollback, or audit trails. Covers versioning strategies, snapshot storage, diff generation, and version comparison APIs for headless CMS. allowed-tools: Read, Glob, Grep, Task, Skill

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

Repository

melodic-software
melodic-software
Author
melodic-software/claude-code-plugins/plugins/content-management-system/skills/content-versioning
3
Stars
0
Forks
Updated4d ago
Added1w ago