Skip to content

feat: Versioning of blog posts#517

Merged
linkdotnet merged 1 commit intomasterfrom
versioning-bps
Apr 24, 2026
Merged

feat: Versioning of blog posts#517
linkdotnet merged 1 commit intomasterfrom
versioning-bps

Conversation

@linkdotnet
Copy link
Copy Markdown
Owner

Closes #483

Copilot AI review requested due to automatic review settings April 24, 2026 07:58
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements blog post versioning so updates create immutable snapshots, enables restoring prior versions in the admin editor, and adds a UI to view diffs between a selected version and the current post.

Changes:

  • Introduces BlogPostVersion domain entity + EF Core mapping/migration to persist version history.
  • Adds IBlogPostVersionService/BlogPostVersionService to snapshot on save and restore prior versions.
  • Extends the admin editor to show version history, diff dialog, and restore actions (DiffPlex-powered).

Reviewed changes

Copilot reviewed 18 out of 19 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPostTests.cs Registers a version service fake for editor component tests.
tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostVersionTests.cs Adds unit tests validating snapshot field coverage and exclusions.
tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs Updates integration tests for versioned saves + toast message changes.
tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs Adds version service stub wiring for create page tests.
tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/BlogPostVersionServiceTests.cs Adds integration coverage for save/restore/history behaviors.
src/LinkDotNet.Blog.Web/ServiceExtensions.cs Registers IBlogPostVersionService in DI.
src/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj Adds DiffPlex dependency reference.
src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/UpdateBlogPostPage.razor Uses version service when saving; wires restore callback into editor component.
src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Services/IBlogPostVersionService.cs Defines the versioning service contract.
src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Services/BlogPostVersionService.cs Implements snapshot-on-save, restore, and version history queries.
src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/VersionDiffDialog.razor Adds a modal to display field and content diffs between version/current.
src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor Adds version history UI, diff, and restore interactions.
src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostVersionConfiguration.cs Adds EF mapping for BlogPostVersion.
src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs Adds DbSet<BlogPostVersion> + applies mapping.
src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs Updates snapshot for the new entity/table.
src/LinkDotNet.Blog.Infrastructure/Migrations/20260424073229_AddBlogPostVersioning.cs Adds migration creating the BlogPostVersions table + unique index.
src/LinkDotNet.Blog.Infrastructure/Migrations/20260424073229_AddBlogPostVersioning.Designer.cs Migration designer for the new versioning table.
src/LinkDotNet.Blog.Domain/BlogPostVersion.cs Introduces the version snapshot entity + CreateSnapshot.
Directory.Packages.props Adds centralized DiffPlex version.
Files not reviewed (1)
  • src/LinkDotNet.Blog.Infrastructure/Migrations/20260424073229_AddBlogPostVersioning.Designer.cs: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +232 to +323
private static IReadOnlyList<DiffDisplayLine> BuildContentDiff(string oldText, string newText)
{
var rawLines = InlineDiffBuilder.Diff(oldText, newText).Lines;
var visible = new bool[rawLines.Count];

for (var i = 0; i < rawLines.Count; i++)
{
if (rawLines[i].Type == ChangeType.Unchanged)
{
continue;
}

var from = Math.Max(0, i - ContextLines);
var to = Math.Min(rawLines.Count - 1, i + ContextLines);
for (var j = from; j <= to; j++)
{
visible[j] = true;
}
}

var result = new List<DiffDisplayLine>(rawLines.Count);
var oldLine = 0;
var newLine = 0;
var pendingCollapse = 0;

for (var i = 0; i < rawLines.Count; i++)
{
var piece = rawLines[i];

if (!visible[i] && piece.Type == ChangeType.Unchanged)
{
oldLine++;
newLine++;
pendingCollapse++;
continue;
}

if (pendingCollapse > 0)
{
result.Add(new DiffDisplayLine("", "", " ", "", "", IsCollapse: true, CollapseCount: pendingCollapse));
pendingCollapse = 0;
}

string oldNum, newNum, prefix, cssClass;
switch (piece.Type)
{
case ChangeType.Deleted:
oldLine++;
oldNum = oldLine.ToString();
newNum = "";
prefix = "-";
cssClass = "bg-danger-subtle";
break;
case ChangeType.Inserted:
newLine++;
oldNum = "";
newNum = newLine.ToString();
prefix = "+";
cssClass = "bg-success-subtle";
break;
case ChangeType.Modified:
oldLine++;
newLine++;
oldNum = oldLine.ToString();
newNum = newLine.ToString();
prefix = "~";
cssClass = "bg-warning-subtle";
break;
default:
if (piece.Type == ChangeType.Unchanged)
{
oldLine++;
newLine++;
}

oldNum = piece.Type == ChangeType.Unchanged ? oldLine.ToString() : "";
newNum = piece.Type == ChangeType.Unchanged ? newLine.ToString() : "";
prefix = " ";
cssClass = "";
break;
}

result.Add(new DiffDisplayLine(oldNum, newNum, prefix, piece.Text ?? "", cssClass));
}

if (pendingCollapse > 0)
{
result.Add(new DiffDisplayLine("", "", " ", "", "", IsCollapse: true, CollapseCount: pendingCollapse));
}

return result;
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildContentDiff always emits a final "collapse" line when there are no changes (all lines are Unchanged and visible[] stays false). That means contentLines.Count > 0 even when the content is identical, so the UI won't show the intended "Content is identical." message. Consider detecting the "no changes" case (e.g., if there are no non-Unchanged pieces) and returning an empty list.

Copilot uses AI. Check for mistakes.
Comment thread Directory.Packages.props
Comment on lines 5 to 8
<ItemGroup Label="Code Analyzers">
<PackageVersion Include="DiffPlex" Version="1.9.0" />
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
<GlobalPackageReference Include="SonarAnalyzer.CSharp" Version="10.23.0.137933" PrivateAssets="All" IncludeAssets="Runtime;Build;Native;contentFiles;Analyzers" />
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DiffPlex is a runtime dependency used by the Web project, but its centrally-managed version was added under the "Code Analyzers" item group. Please move it under the "Web" group to keep package organization consistent with the rest of this file.

Copilot uses AI. Check for mistakes.
Comment on lines +25 to +34
public async ValueTask SaveNewVersionAsync(BlogPost currentBlogPost, BlogPost updatedBlogPost)
{
ArgumentNullException.ThrowIfNull(currentBlogPost);
ArgumentNullException.ThrowIfNull(updatedBlogPost);

await StoreSnapshotAsync(currentBlogPost);

currentBlogPost.Update(updatedBlogPost);
await blogPostRepository.StoreAsync(currentBlogPost);
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SaveNewVersionAsync stores the snapshot and updates the BlogPost in two separate operations/DbContexts without any transaction. If the blog post update fails after the snapshot is inserted (or vice versa), the version history can become inconsistent. Consider performing snapshot + blog post update in a single BlogDbContext transaction (or otherwise ensuring atomicity).

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +70
// Reconstruct a transient BlogPost from the target version fields.
// ScheduledPublishDate is intentionally not restored.
var restored = BlogPost.Create(
targetVersion.Title,
targetVersion.ShortDescription,
targetVersion.Content,
targetVersion.PreviewImageUrl,
targetVersion.IsPublished,
targetVersion.UpdatedDate,
scheduledPublishDate: null,
targetVersion.Tags,
targetVersion.PreviewImageUrlFallback,
targetVersion.AuthorName);

currentBlogPost.Update(restored);
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The service doc/comment says ScheduledPublishDate is "intentionally not restored", but the current implementation sets scheduledPublishDate: null and then calls currentBlogPost.Update(restored), which will actively clear any existing ScheduledPublishDate on the blog post. If the intent is to ignore the scheduled date from the version, preserve currentBlogPost.ScheduledPublishDate instead of forcing it to null.

Copilot uses AI. Check for mistakes.
Comment on lines +78 to +85
var maxVersion = await db.BlogPostVersions
.Where(v => v.BlogPostId == blogPost.Id)
.Select(v => (int?)v.VersionNumber)
.MaxAsync() ?? 0;

var snapshot = BlogPostVersion.CreateSnapshot(blogPost, maxVersion + 1);
await db.BlogPostVersions.AddAsync(snapshot);
await db.SaveChangesAsync();
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StoreSnapshotAsync derives the next VersionNumber via MaxAsync() + 1. With concurrent saves/restores for the same BlogPostId, two requests can compute the same max and then hit the unique index (BlogPostId, VersionNumber) causing a DbUpdateException. Consider adding concurrency handling (transaction with appropriate isolation, retry on unique constraint violation, or generating version numbers in the database).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 19 changed files in this pull request and generated 3 comments.

Files not reviewed (1)
  • src/LinkDotNet.Blog.Infrastructure/Migrations/20260424073229_AddBlogPostVersioning.Designer.cs: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +30 to +33
await StoreSnapshotAsync(currentBlogPost);

currentBlogPost.Update(updatedBlogPost);
await blogPostRepository.StoreAsync(currentBlogPost);
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per issue requirements, ScheduledPublishDate should not be considered/versioned when updating a post. Currently SaveNewVersionAsync calls currentBlogPost.Update(updatedBlogPost), and BlogPost.Update copies ScheduledPublishDate, which means schedule-only changes will both (a) create a new version that doesn't contain scheduling info and (b) overwrite the live scheduled date. Consider preserving currentBlogPost.ScheduledPublishDate during updates (and/or skipping snapshot creation when only non-versioned fields like schedule changed).

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +60
public async ValueTask RestoreVersionAsync(BlogPost currentBlogPost, BlogPostVersion targetVersion)
{
ArgumentNullException.ThrowIfNull(currentBlogPost);
ArgumentNullException.ThrowIfNull(targetVersion);

// Snapshot the current state before overwriting it
await StoreSnapshotAsync(currentBlogPost);

// Reconstruct a transient BlogPost from the target version fields.
// ScheduledPublishDate is not versioned, so we preserve whatever schedule the
// current live post has — unless the version being restored is published (a
// published post cannot carry a scheduled date per the domain invariant).
var scheduledPublishDate = targetVersion.IsPublished ? null : currentBlogPost.ScheduledPublishDate;
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RestoreVersionAsync accepts any BlogPostVersion instance, but doesn't validate that targetVersion.BlogPostId matches currentBlogPost.Id. Accidentally passing a version from a different post would overwrite the current post with unrelated content. Add a guard clause (and ideally a clear exception) to ensure the version belongs to the blog post being restored.

Copilot uses AI. Check for mistakes.
<div class="list-group-item d-flex justify-content-between align-items-center py-2 px-3">
<div class="me-2" style="min-width: 0;">
<div class="fw-semibold small">v@(v.VersionNumber)</div>
<div class="text-muted" style="font-size: 0.72rem;">@v.CreatedAt.ToString("MMM dd, yyyy HH:mm")</div>
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BlogPostVersion.CreatedAt is set using DateTime.UtcNow, but the version history list renders CreatedAt without converting to local time (unlike VersionDiffDialog, which calls ToLocalTime()). This will display UTC timestamps to users. Consider consistently applying ToLocalTime() (or clearly labeling the timezone) in the list view.

Suggested change
<div class="text-muted" style="font-size: 0.72rem;">@v.CreatedAt.ToString("MMM dd, yyyy HH:mm")</div>
<div class="text-muted" style="font-size: 0.72rem;">@v.CreatedAt.ToLocalTime().ToString("MMM dd, yyyy HH:mm")</div>

Copilot uses AI. Check for mistakes.
@linkdotnet linkdotnet merged commit 599cd35 into master Apr 24, 2026
3 checks passed
@linkdotnet linkdotnet deleted the versioning-bps branch April 24, 2026 08:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Versioning of Blog Posts

2 participants