feat(creator): overhaul Creator flow, editor duplication, and staging setup #83

Merged
mjasin merged 15 commits from feature/stage3-book-versioning into develop 2026-06-15 17:15:43 +00:00
6 changed files with 51 additions and 45 deletions
Showing only changes of commit 8de02c32c5 - Show all commits
@@ -38,7 +38,7 @@ public class PublishBookVersionCommandHandler : ICommandHandler<PublishBookVersi
if (book == null)
{
throw new BookNotFoundException(request.BookId);
return Result.Fail(new Error($"Book with ID '{request.BookId}' was not found."));
}
var oldDraftRevision = book.CurrentDraftRevision;
@@ -41,7 +41,7 @@ public class GetBookRevisionsQueryHandler : IQueryHandler<GetBookRevisionsQuery,
if (!bookExists)
{
throw new BookNotFoundException(request.BookId);
return FluentResults.Result.Fail<List<CreatorBookRevisionDto>>(new FluentResults.Error($"Book with ID '{request.BookId}' was not found."));
}
// Fetch all revisions sorted chronologically
@@ -43,6 +43,7 @@
private IJSObjectReference? _module;
private DotNetObjectReference<MarkdownEditor>? _dotNetHelper;
private string? _lastInitializedEditorId;
private bool _disposed;
private enum SaveStatus
{
@@ -350,6 +351,7 @@
// Cancel pending timers thread-safely
CancellationTokenSource? ctsToCancel = null;
CancellationToken token;
lock (_timerLock)
{
if (_debounceCts != null)
@@ -358,6 +360,7 @@
_debounceCts = null;
}
_debounceCts = new CancellationTokenSource();
token = _debounceCts.Token; // Capture token synchronously under lock on UI thread
}
if (ctsToCancel != null)
1
@@ -376,13 +379,6 @@
// Start 5-second idle debounce timer
_ = Task.Run(async () =>
{
CancellationToken token;
lock (_timerLock)
{
if (_debounceCts == null) return;
token = _debounceCts.Token;
}
try
{
await Task.Delay(5000, token);
@@ -401,7 +397,7 @@
private async Task TriggerAutosaveAsync(string markdown, CancellationToken token)
{
if (token.IsCancellationRequested) return;
if (token.IsCancellationRequested || _disposed) return;
_status = SaveStatus.Saving;
mjasin marked this conversation as resolved
Review

🟢 Minor/Suggestion: Missing component disposal check

TriggerAutosaveAsync runs on a background thread and calls await InvokeAsync(StateHasChanged). If the component has been disposed before or during the save operation, calling StateHasChanged may result in runtime warnings/exceptions.

Suggested Fix:
Define a _disposed boolean flag, set it to true in DisposeAsync, and check it before updating state:

private bool _disposed;

// In DisposeAsync:
_disposed = true;

// In TriggerAutosaveAsync:
if (_disposed) return;
_status = SaveStatus.Saving;
await InvokeAsync(StateHasChanged);
🟢 Minor/Suggestion: Missing component disposal check `TriggerAutosaveAsync` runs on a background thread and calls `await InvokeAsync(StateHasChanged)`. If the component has been disposed before or during the save operation, calling `StateHasChanged` may result in runtime warnings/exceptions. **Suggested Fix:** Define a `_disposed` boolean flag, set it to `true` in `DisposeAsync`, and check it before updating state: ```csharp private bool _disposed; // In DisposeAsync: _disposed = true; // In TriggerAutosaveAsync: if (_disposed) return; _status = SaveStatus.Saving; await InvokeAsync(StateHasChanged); ```
await InvokeAsync(StateHasChanged);
@@ -416,6 +412,8 @@
token
);
if (_disposed) return;
if (response.IsSuccessStatusCode)
{
// Purge LocalStorage backup key on HTTP success
@@ -431,10 +429,12 @@
}
catch (Exception ex)
{
if (_disposed) return;
_status = SaveStatus.OfflineLocalBackup;
Console.WriteLine($"[MarkdownEditor] Autosave HTTP exception: {ex.Message}");
}
if (_disposed) return;
await InvokeAsync(StateHasChanged);
}
@@ -477,6 +477,7 @@
public async ValueTask DisposeAsync()
{
_disposed = true;
try
{
_cts.Cancel();
+12 -18
View File
@@ -519,18 +519,15 @@ app.MapGet("/api/creator/books/{bookId:guid}/revisions", async (Guid bookId, Cla
var tenantId = user.FindFirstValue("TenantId") ?? "global";
try
{
var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetBookRevisionsQuery(bookId, userId, tenantId));
if (result.IsSuccess) return Results.Ok(result.Value);
var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetBookRevisionsQuery(bookId, userId, tenantId));
if (result.IsSuccess) return Results.Ok(result.Value);
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
return Results.BadRequest(errorMsg);
}
catch (NexusReader.Domain.Exceptions.BookNotFoundException)
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
{
return Results.NotFound($"Book with ID '{bookId}' was not found.");
return Results.NotFound(errorMsg);
}
return Results.BadRequest(errorMsg);
}).RequireAuthorization();
app.MapPost("/api/creator/books/{bookId:guid}/publish", async (Guid bookId, [FromQuery] string version, ClaimsPrincipal user, IMediator mediator) =>
@@ -545,18 +542,15 @@ app.MapPost("/api/creator/books/{bookId:guid}/publish", async (Guid bookId, [Fro
return Results.BadRequest("Version string is required.");
}
try
{
var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand(bookId, version, userId, tenantId));
if (result.IsSuccess) return Results.Ok();
var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand(bookId, version, userId, tenantId));
if (result.IsSuccess) return Results.Ok();
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
return Results.BadRequest(errorMsg);
}
catch (NexusReader.Domain.Exceptions.BookNotFoundException)
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
{
return Results.NotFound($"Book with ID '{bookId}' was not found.");
return Results.NotFound(errorMsg);
}
return Results.BadRequest(errorMsg);
}).RequireAuthorization();
app.MapPost("/api/creator/books", async (
@@ -169,7 +169,7 @@ public class PublishBookVersionTests : IDisposable
}
[Fact]
public async Task Handle_WithMismatchedTenantId_ThrowsBookNotFoundException()
public async Task Handle_WithMismatchedTenantId_ReturnsFailure()
{
// Arrange
var bookId = Guid.NewGuid();
@@ -210,13 +210,16 @@ public class PublishBookVersionTests : IDisposable
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
// Act & Assert
var action = () => handler.Handle(command, CancellationToken.None);
await action.Should().ThrowAsync<BookNotFoundException>();
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
}
[Fact]
public async Task Handle_WithMismatchedUserId_ThrowsBookNotFoundException()
public async Task Handle_WithMismatchedUserId_ReturnsFailure()
{
// Arrange
var bookId = Guid.NewGuid();
@@ -257,13 +260,16 @@ public class PublishBookVersionTests : IDisposable
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
// Act & Assert
var action = () => handler.Handle(command, CancellationToken.None);
await action.Should().ThrowAsync<BookNotFoundException>();
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
}
[Fact]
public async Task Handle_WithNonExistentBook_ThrowsBookNotFoundException()
public async Task Handle_WithNonExistentBook_ReturnsFailure()
{
// Arrange
var command = new PublishBookVersionCommand(
@@ -275,9 +281,12 @@ public class PublishBookVersionTests : IDisposable
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
// Act & Assert
var action = () => handler.Handle(command, CancellationToken.None);
await action.Should().ThrowAsync<BookNotFoundException>();
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
}
public void Dispose()
@@ -234,7 +234,7 @@ public class CreatorDashboardTests : IDisposable
}
[Fact]
public async Task GetBookRevisions_WithMismatchedUserOrTenant_ThrowsBookNotFoundException()
public async Task GetBookRevisions_WithMismatchedUserOrTenant_ReturnsFailure()
{
// Arrange
var userId = "creator-123";
@@ -262,12 +262,14 @@ public class CreatorDashboardTests : IDisposable
// Act & Assert
var queryMismatchedTenant = new GetBookRevisionsQuery(bookId, userId, "different-tenant");
var actionTenant = () => handler.Handle(queryMismatchedTenant, CancellationToken.None);
await actionTenant.Should().ThrowAsync<BookNotFoundException>();
var resultTenant = await handler.Handle(queryMismatchedTenant, CancellationToken.None);
resultTenant.IsSuccess.Should().BeFalse();
resultTenant.Errors.Should().Contain(e => e.Message.Contains("was not found"));
var queryMismatchedUser = new GetBookRevisionsQuery(bookId, "different-user", tenantId);
var actionUser = () => handler.Handle(queryMismatchedUser, CancellationToken.None);
await actionUser.Should().ThrowAsync<BookNotFoundException>();
var resultUser = await handler.Handle(queryMismatchedUser, CancellationToken.None);
resultUser.IsSuccess.Should().BeFalse();
resultUser.Errors.Should().Contain(e => e.Message.Contains("was not found"));
}
public void Dispose()