14 Commits

Author SHA1 Message Date
mjasin b74ba4ba54 fix(creator): resolve editor duplication and theme synchronization issues 2026-06-15 19:06:31 +02:00
mjasin 4aa3b4b421 feat: support stopping only the web container in run-stage.sh and update GEMINI.md rules 2026-06-14 19:05:49 +02:00
mjasin 431d815f55 docs: document --nexus-only switch in GEMINI.md 2026-06-14 19:02:51 +02:00
mjasin 08905a248d feat(stage): add --nexus-only switch to run-stage.sh and execute premium creator edit layout polish 2026-06-14 19:01:41 +02:00
mjasin a738a28eb4 feat(health): add custom DB, Qdrant, and Neo4j health check services and secure Qdrant in staging 2026-06-14 15:15:58 +02:00
mjasin d2410e9793 chore(stage): fix bash arithmetic syntax in run-stage.sh 2026-06-14 15:09:58 +02:00
mjasin bb861e469f chore(stage): increase startup wait timeout to prevent false-positive warnings 2026-06-14 15:02:27 +02:00
mjasin ecabe01be0 fix(docker): install libgssapi-krb5-2 package to resolve missing GSSAPI/Kerberos library error 2026-06-14 15:01:34 +02:00
mjasin 00ebee8628 feat(creator): retire old workspace and polish CreatorEdit route & dashboard navigation 2026-06-14 11:42:34 +02:00
mjasin 042dad0774 feat(creator): Add polished single-chapter editor workspace page under /creator/edit/{BookId}/{ChapterId}
- Implementedfullscreen wrapper layout to eliminate browser scrollbar bugs
- Styled matte finish sidebar with active chapter indicators
- Standardized header alignment with clean monospace ID badge
- Integrated footer panel for state save indicators and premium action triggers
2026-06-14 11:25:12 +02:00
mjasin 893fed4d60 style(dashboard): Add top margin to ContextualRecommendationsWidget to match page layout spacing 2026-06-14 11:13:41 +02:00
mjasin 8856fb1614 feat(creator): Refactor Creator flow, implement book creation pipeline & versioning, and setup Docker staging
- Relocate dashboard routing to /creator and editor workspace to /creator/edit/{BookId}
- Implement CreateBookCommand and handler with transactional default chapter seeding
- Implement PublishBookVersionCommand and GetCreatorDashboardDataQuery
- Build CreatorDashboard modal and UI components with customized dark input styles
- Add run-stage.sh script to automate staging environment setup, database migrations, and health checks
- Update developer workflow rules in GEMINI.md
2026-06-14 10:58:37 +02:00
mjasin 978485e8ff feat: implement debounced autosave with strict LocalStorage garbage collection (Stage 2 Task B) 2026-06-11 20:33:59 +02:00
mjasin 155bfa9aa0 feat: implement secure image upload pipeline and backend XSS guard (Stage 2 Task A) 2026-06-11 20:32:05 +02:00
6 changed files with 47 additions and 53 deletions
@@ -38,7 +38,7 @@ public class PublishBookVersionCommandHandler : ICommandHandler<PublishBookVersi
if (book == null) if (book == null)
{ {
return Result.Fail(new Error($"Book with ID '{request.BookId}' was not found.")); throw new BookNotFoundException(request.BookId);
} }
var oldDraftRevision = book.CurrentDraftRevision; var oldDraftRevision = book.CurrentDraftRevision;
@@ -41,7 +41,7 @@ public class GetBookRevisionsQueryHandler : IQueryHandler<GetBookRevisionsQuery,
if (!bookExists) if (!bookExists)
{ {
return FluentResults.Result.Fail<List<CreatorBookRevisionDto>>(new FluentResults.Error($"Book with ID '{request.BookId}' was not found.")); throw new BookNotFoundException(request.BookId);
} }
// Fetch all revisions sorted chronologically // Fetch all revisions sorted chronologically
@@ -43,7 +43,6 @@
private IJSObjectReference? _module; private IJSObjectReference? _module;
private DotNetObjectReference<MarkdownEditor>? _dotNetHelper; private DotNetObjectReference<MarkdownEditor>? _dotNetHelper;
private string? _lastInitializedEditorId; private string? _lastInitializedEditorId;
private bool _disposed;
private enum SaveStatus private enum SaveStatus
{ {
@@ -351,7 +350,6 @@
// Cancel pending timers thread-safely // Cancel pending timers thread-safely
CancellationTokenSource? ctsToCancel = null; CancellationTokenSource? ctsToCancel = null;
CancellationToken token;
lock (_timerLock) lock (_timerLock)
{ {
if (_debounceCts != null) if (_debounceCts != null)
@@ -360,7 +358,6 @@
_debounceCts = null; _debounceCts = null;
} }
_debounceCts = new CancellationTokenSource(); _debounceCts = new CancellationTokenSource();
token = _debounceCts.Token; // Capture token synchronously under lock on UI thread
} }
if (ctsToCancel != null) if (ctsToCancel != null)
@@ -379,6 +376,13 @@
// Start 5-second idle debounce timer // Start 5-second idle debounce timer
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
CancellationToken token;
lock (_timerLock)
{
if (_debounceCts == null) return;
token = _debounceCts.Token;
}
try try
{ {
await Task.Delay(5000, token); await Task.Delay(5000, token);
@@ -397,7 +401,7 @@
private async Task TriggerAutosaveAsync(string markdown, CancellationToken token) private async Task TriggerAutosaveAsync(string markdown, CancellationToken token)
{ {
if (token.IsCancellationRequested || _disposed) return; if (token.IsCancellationRequested) return;
_status = SaveStatus.Saving; _status = SaveStatus.Saving;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
@@ -412,8 +416,6 @@
token token
); );
if (_disposed) return;
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
// Purge LocalStorage backup key on HTTP success // Purge LocalStorage backup key on HTTP success
@@ -429,12 +431,10 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
if (_disposed) return;
_status = SaveStatus.OfflineLocalBackup; _status = SaveStatus.OfflineLocalBackup;
Console.WriteLine($"[MarkdownEditor] Autosave HTTP exception: {ex.Message}"); Console.WriteLine($"[MarkdownEditor] Autosave HTTP exception: {ex.Message}");
} }
if (_disposed) return;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -477,7 +477,6 @@
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
_disposed = true;
try try
{ {
_cts.Cancel(); _cts.Cancel();
+20 -14
View File
@@ -519,15 +519,18 @@ app.MapGet("/api/creator/books/{bookId:guid}/revisions", async (Guid bookId, Cla
var tenantId = user.FindFirstValue("TenantId") ?? "global"; var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetBookRevisionsQuery(bookId, userId, tenantId)); try
if (result.IsSuccess) return Results.Ok(result.Value);
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
{ {
return Results.NotFound(errorMsg); 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)
{
return Results.NotFound($"Book with ID '{bookId}' was not found.");
} }
return Results.BadRequest(errorMsg);
}).RequireAuthorization(); }).RequireAuthorization();
app.MapPost("/api/creator/books/{bookId:guid}/publish", async (Guid bookId, [FromQuery] string version, ClaimsPrincipal user, IMediator mediator) => app.MapPost("/api/creator/books/{bookId:guid}/publish", async (Guid bookId, [FromQuery] string version, ClaimsPrincipal user, IMediator mediator) =>
@@ -542,15 +545,18 @@ app.MapPost("/api/creator/books/{bookId:guid}/publish", async (Guid bookId, [Fro
return Results.BadRequest("Version string is required."); return Results.BadRequest("Version string is required.");
} }
var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand(bookId, version, userId, tenantId)); try
if (result.IsSuccess) return Results.Ok();
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
{ {
return Results.NotFound(errorMsg); 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)
{
return Results.NotFound($"Book with ID '{bookId}' was not found.");
} }
return Results.BadRequest(errorMsg);
}).RequireAuthorization(); }).RequireAuthorization();
app.MapPost("/api/creator/books", async ( app.MapPost("/api/creator/books", async (
@@ -169,7 +169,7 @@ public class PublishBookVersionTests : IDisposable
} }
[Fact] [Fact]
public async Task Handle_WithMismatchedTenantId_ReturnsFailure() public async Task Handle_WithMismatchedTenantId_ThrowsBookNotFoundException()
{ {
// Arrange // Arrange
var bookId = Guid.NewGuid(); var bookId = Guid.NewGuid();
@@ -210,16 +210,13 @@ public class PublishBookVersionTests : IDisposable
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object); var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
// Act // Act & Assert
var result = await handler.Handle(command, CancellationToken.None); var action = () => handler.Handle(command, CancellationToken.None);
await action.Should().ThrowAsync<BookNotFoundException>();
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
} }
[Fact] [Fact]
public async Task Handle_WithMismatchedUserId_ReturnsFailure() public async Task Handle_WithMismatchedUserId_ThrowsBookNotFoundException()
{ {
// Arrange // Arrange
var bookId = Guid.NewGuid(); var bookId = Guid.NewGuid();
@@ -260,16 +257,13 @@ public class PublishBookVersionTests : IDisposable
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object); var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
// Act // Act & Assert
var result = await handler.Handle(command, CancellationToken.None); var action = () => handler.Handle(command, CancellationToken.None);
await action.Should().ThrowAsync<BookNotFoundException>();
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
} }
[Fact] [Fact]
public async Task Handle_WithNonExistentBook_ReturnsFailure() public async Task Handle_WithNonExistentBook_ThrowsBookNotFoundException()
{ {
// Arrange // Arrange
var command = new PublishBookVersionCommand( var command = new PublishBookVersionCommand(
@@ -281,12 +275,9 @@ public class PublishBookVersionTests : IDisposable
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object); var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
// Act // Act & Assert
var result = await handler.Handle(command, CancellationToken.None); var action = () => handler.Handle(command, CancellationToken.None);
await action.Should().ThrowAsync<BookNotFoundException>();
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
} }
public void Dispose() public void Dispose()
@@ -234,7 +234,7 @@ public class CreatorDashboardTests : IDisposable
} }
[Fact] [Fact]
public async Task GetBookRevisions_WithMismatchedUserOrTenant_ReturnsFailure() public async Task GetBookRevisions_WithMismatchedUserOrTenant_ThrowsBookNotFoundException()
{ {
// Arrange // Arrange
var userId = "creator-123"; var userId = "creator-123";
@@ -262,14 +262,12 @@ public class CreatorDashboardTests : IDisposable
// Act & Assert // Act & Assert
var queryMismatchedTenant = new GetBookRevisionsQuery(bookId, userId, "different-tenant"); var queryMismatchedTenant = new GetBookRevisionsQuery(bookId, userId, "different-tenant");
var resultTenant = await handler.Handle(queryMismatchedTenant, CancellationToken.None); var actionTenant = () => handler.Handle(queryMismatchedTenant, CancellationToken.None);
resultTenant.IsSuccess.Should().BeFalse(); await actionTenant.Should().ThrowAsync<BookNotFoundException>();
resultTenant.Errors.Should().Contain(e => e.Message.Contains("was not found"));
var queryMismatchedUser = new GetBookRevisionsQuery(bookId, "different-user", tenantId); var queryMismatchedUser = new GetBookRevisionsQuery(bookId, "different-user", tenantId);
var resultUser = await handler.Handle(queryMismatchedUser, CancellationToken.None); var actionUser = () => handler.Handle(queryMismatchedUser, CancellationToken.None);
resultUser.IsSuccess.Should().BeFalse(); await actionUser.Should().ThrowAsync<BookNotFoundException>();
resultUser.Errors.Should().Contain(e => e.Message.Contains("was not found"));
} }
public void Dispose() public void Dispose()