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."));
mjasin marked this conversation as resolved Outdated
Outdated
Review

🟡 Design/Architecture: Violation of Result Pattern

Throwing BookNotFoundException inside the handler violates the project's strict architecture rule: Result Pattern: Zero exceptions for flow control. All handlers return Result<T> via FluentResult.

Suggested Fix:
Instead of throwing, return Result.Fail(...) containing an error message or a dedicated error class:

if (book == null)
{
    return Result.Fail(new Error($"Book with ID '{request.BookId}' was not found."));
}

Remember to update the MapPost endpoint in Program.cs and the tests as well.

🟡 Design/Architecture: Violation of Result Pattern Throwing `BookNotFoundException` inside the handler violates the project's strict architecture rule: `Result Pattern: Zero exceptions for flow control. All handlers return Result<T> via FluentResult.` **Suggested Fix:** Instead of throwing, return `Result.Fail(...)` containing an error message or a dedicated error class: ```csharp if (book == null) { return Result.Fail(new Error($"Book with ID '{request.BookId}' was not found.")); } ``` Remember to update the MapPost endpoint in `Program.cs` and the tests as well.
}
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."));
mjasin marked this conversation as resolved Outdated
Outdated
Review

🟡 Design/Architecture: Violation of Result Pattern

Throwing BookNotFoundException inside the query handler violates the project's Result Pattern rule.

Suggested Fix:
Instead of throwing, return Result.Fail(...):

if (!bookExists)
{
    return Result.Fail<List<CreatorBookRevisionDto>>(new Error($"Book with ID '{request.BookId}' was not found."));
}
🟡 Design/Architecture: Violation of Result Pattern Throwing `BookNotFoundException` inside the query handler violates the project's Result Pattern rule. **Suggested Fix:** Instead of throwing, return `Result.Fail(...)`: ```csharp if (!bookExists) { return Result.Fail<List<CreatorBookRevisionDto>>(new 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);
mjasin marked this conversation as resolved Outdated
Outdated
Review

🟡 Design/Architecture: Update test for Result Pattern alignment

Once the handler is refactored to return Result.Fail instead of throwing BookNotFoundException, update this test to assert failure on the returned result rather than expecting an exception.

Suggested Fix:

var result = await handler.Handle(command, CancellationToken.None);
result.IsSuccess.Should().BeFalse();
🟡 Design/Architecture: Update test for Result Pattern alignment Once the handler is refactored to return `Result.Fail` instead of throwing `BookNotFoundException`, update this test to assert failure on the returned result rather than expecting an exception. **Suggested Fix:** ```csharp var result = await handler.Handle(command, CancellationToken.None); result.IsSuccess.Should().BeFalse(); ```
// 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();
mjasin marked this conversation as resolved Outdated
Outdated
Review

🟡 Design/Architecture: Update test for Result Pattern alignment

Update this test to assert failure on the returned result rather than expecting an exception once the query handler is refactored.

Suggested Fix:

var result = await handler.Handle(queryMismatchedTenant, CancellationToken.None);
result.IsSuccess.Should().BeFalse();
🟡 Design/Architecture: Update test for Result Pattern alignment Update this test to assert failure on the returned result rather than expecting an exception once the query handler is refactored. **Suggested Fix:** ```csharp var result = await handler.Handle(queryMismatchedTenant, CancellationToken.None); result.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()