using NexusReader.Web.Components; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Components; using NexusReader.Application; using NexusReader.Infrastructure; using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Queries.User; using NexusReader.Application.Commands.Library; using NexusReader.Application.Queries.Library; using NexusReader.Application.Queries.Concepts; using MediatR; using NexusReader.Web.Client.Services; using NexusReader.UI.Shared.Services; using NexusReader.Domain.Entities; using NexusReader.Data.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Authorization; using NexusReader.Infrastructure.Identity; using Microsoft.AspNetCore.Authentication; using System.Security.Claims; using NexusReader.Infrastructure.Services; using Stripe; using Microsoft.Extensions.AI; using NexusReader.Application.Abstractions.Persistence; using NexusReader.Application.Abstractions.Messaging; using Hangfire; AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddInteractiveWebAssemblyComponents(); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.TypeInfoResolverChain.Insert(0, NexusReader.Application.Common.AppJsonContext.Default); }); // Enable detailed circuit errors for Server‑Side Blazor components builder.Services.AddServerSideBlazor() .AddCircuitOptions(options => { options.DetailedErrors = true; }); builder.Services.AddSignalR(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Feature settings (avoiding direct raw IConfiguration injection in client pages) var featureSettings = builder.Configuration.GetSection("Features").Get() ?? new FeatureSettings(); builder.Services.AddSingleton(featureSettings); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHttpClient("NexusAPI", (sp, client) => { var configuration = sp.GetRequiredService(); var apiBaseUrl = configuration["ApiBaseUrl"]; if (!string.IsNullOrEmpty(apiBaseUrl)) { client.BaseAddress = new Uri(apiBaseUrl); } else { // For local development/Interactive Server, we use the current base address var nav = sp.GetRequiredService(); client.BaseAddress = new Uri(nav.BaseUri); } }); builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("NexusAPI")); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddCascadingAuthenticationState(); builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies( NexusReader.Application.DependencyInjection.Assembly, NexusReader.Infrastructure.DependencyInjection.Assembly )); // Authorization Policies builder.Services.AddScoped(); builder.Services.AddAuthorizationBuilder() .SetDefaultPolicy(new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder( IdentityConstants.ApplicationScheme, IdentityConstants.BearerScheme) .RequireAuthenticatedUser() .Build()) .AddPolicy("ProUser", policy => policy.RequireClaim("Plan", SubscriptionPlan.ProName, SubscriptionPlan.EnterpriseName)) .AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement())); // Billing & Stripe builder.Services.AddScoped(); // Authentication builder.Services.AddAuthentication(options => { options.DefaultScheme = IdentityConstants.ApplicationScheme; options.DefaultSignInScheme = IdentityConstants.ExternalScheme; }) .AddGoogle(options => { options.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? "placeholder-id"; options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? "placeholder-secret"; }); builder.Services.AddIdentityApiEndpoints() .AddRoles() .AddEntityFrameworkStores() .AddClaimsPrincipalFactory(); builder.Services.ConfigureApplicationCookie(options => { options.LoginPath = "/account/login"; options.LogoutPath = "/account/logout"; options.AccessDeniedPath = "/account/access-denied"; options.Cookie.Name = "NexusReader.Auth"; options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Lax; options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; options.ExpireTimeSpan = TimeSpan.FromDays(30); options.SlidingExpiration = true; options.Events.OnRedirectToLogin = context => { var isApiRequest = context.Request.Path.StartsWithSegments("/api") || context.Request.Path.StartsWithSegments("/identity") || context.Request.Headers["Accept"].ToString().Contains("application/json"); if (isApiRequest) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; } else { context.Response.Redirect(context.RedirectUri); } return Task.CompletedTask; }; }); builder.Services.Configure(options => { // Password settings options.Password.RequireDigit = true; options.Password.RequireLowercase = true; options.Password.RequireNonAlphanumeric = true; options.Password.RequireUppercase = true; options.Password.RequiredLength = 8; options.Password.RequiredUniqueChars = 1; // Lockout settings options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.AllowedForNewUsers = true; // User settings options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+"; options.User.RequireUniqueEmail = true; }); var app = builder.Build(); app.UseHangfireDashboard(); // Startup Validation using (var scope = app.Services.CreateScope()) { var marker = scope.ServiceProvider.GetService(); if (marker == null) { throw new InvalidOperationException("CRITICAL: Infrastructure layer was not registered. Ensure AddInfrastructure() is called in Program.cs."); } } // Ensure Database is initialized and seeded using (var scope = app.Services.CreateScope()) { var services = scope.ServiceProvider; var logger = services.GetRequiredService>(); var dbContextFactory = services.GetRequiredService>(); using var dbContext = await dbContextFactory.CreateDbContextAsync(); int maxRetries = 5; int delayMs = 2000; for (int i = 0; i < maxRetries; i++) { try { if (logger.IsEnabled(LogLevel.Information)) { logger.LogInformation("Próba połączenia z bazą danych (próba {Attempt}/{MaxRetries})...", i + 1, maxRetries); } await DbInitializer.SeedAsync(services); await TriggerBackgroundProcessingForUnindexedBooksAsync(services); if (logger.IsEnabled(LogLevel.Information)) { logger.LogInformation("Baza danych zainicjowana pomyślnie."); } break; } catch (Npgsql.NpgsqlException ex) when (i < maxRetries - 1) { if (logger.IsEnabled(LogLevel.Warning)) { logger.LogWarning(ex, "Błąd połączenia z bazą danych. Ponowna próba za {Delay}ms...", delayMs); } await Task.Delay(delayMs); delayMs *= 2; // Exponential backoff } catch (Exception ex) { if (logger.IsEnabled(LogLevel.Critical)) { logger.LogCritical(ex, "Krytyczny błąd podczas inicjalizacji bazy danych."); } throw; } } } // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseWebAssemblyDebugging(); } else { app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseHsts(); } app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); if (!app.Environment.IsDevelopment()) { app.UseHttpsRedirection(); } app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); // Feature flags: block registration & password reset in restricted environments (e.g. Test) var allowRegistration = app.Configuration.GetValue("Features:AllowRegistration") ?? true; var allowPasswordReset = app.Configuration.GetValue("Features:AllowPasswordReset") ?? true; if (!allowRegistration || !allowPasswordReset) { app.Use(async (context, next) => { var path = context.Request.Path.Value?.ToLowerInvariant(); if (!allowRegistration && path is "/identity/register" or "/account/register") { context.Response.StatusCode = StatusCodes.Status403Forbidden; await context.Response.WriteAsync("Registration is disabled in this environment."); return; } if (!allowPasswordReset && path is "/identity/forgotpassword" or "/account/forgot-password") { context.Response.StatusCode = StatusCodes.Status403Forbidden; await context.Response.WriteAsync("Password reset is disabled in this environment."); return; } await next(); }); } app.MapStaticAssets(); app.MapHub("/synchub"); // API endpoint for WASM client to fetch EPUB content app.MapGet("/api/epub/{ebookId:guid}/{index:int}", async (Guid ebookId, int index, IEpubReader epubService, ClaimsPrincipal user) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); var result = await epubService.GetEpubContentAsync(ebookId, index, userId); 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); }).RequireAuthorization(); // API endpoint for WASM client/browser to fetch EPUB static resources (images, etc.) app.MapGet("/api/epub/{ebookId:guid}/resource", async (Guid ebookId, string path, IEpubReader epubService, ClaimsPrincipal user, HttpContext httpContext, CancellationToken cancellationToken) => { if (string.IsNullOrEmpty(path)) { return Results.BadRequest("Path parameter is required."); } var decodedPath = Uri.UnescapeDataString(path); if (decodedPath.Contains("..") || decodedPath.Contains(":") || decodedPath.StartsWith("/") || decodedPath.StartsWith("\\")) { return Results.BadRequest("Invalid resource path."); } var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); var result = await epubService.GetEpubResourceAsync(ebookId, decodedPath, userId, cancellationToken); if (result.IsSuccess) { // Serve with client-side caching to avoid redundant roundtrips on chapter navigation httpContext.Response.Headers.CacheControl = "public, max-age=86400"; var ext = Path.GetExtension(decodedPath).ToLowerInvariant(); var contentType = ext switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".svg" => "image/svg+xml", ".webp" => "image/webp", ".css" => "text/css", ".otf" => "font/otf", ".ttf" => "font/ttf", ".woff" => "font/woff", ".woff2" => "font/woff2", _ => "application/octet-stream" }; return Results.File(result.Value, contentType); } var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Resource not found"; return Results.NotFound(errorMsg); }).RequireAuthorization(); var knowledgeApi = app.MapGroup("/api/knowledge") .RequireAuthorization("HasAvailableTokens") .DisableAntiforgery(); knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => { var tenantId = user.FindFirstValue("TenantId") ?? "global"; var result = await knowledgeService.GetKnowledgeAsync(request.Text, tenantId, request.EbookId); if (result.IsSuccess) return Results.Ok(result.Value); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); knowledgeApi.MapPost("/graph", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => { var tenantId = user.FindFirstValue("TenantId") ?? "global"; var result = await knowledgeService.GetGraphDataAsync(request.Text, tenantId, request.EbookId); if (result.IsSuccess) return Results.Ok(result.Value); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); knowledgeApi.MapPost("/summary", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => { var tenantId = user.FindFirstValue("TenantId") ?? "global"; var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text, tenantId, request.EbookId); if (result.IsSuccess) return Results.Ok(result.Value); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); knowledgeApi.MapPost("/map", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => { var tenantId = user.FindFirstValue("TenantId") ?? "global"; var result = await knowledgeService.GetKnowledgeMapAsync(request.Text, tenantId, request.EbookId); if (result.IsSuccess) return Results.Ok(result.Value); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); knowledgeApi.MapPost("/verify-groundedness", async (GroundednessRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => { var tenantId = user.FindFirstValue("TenantId") ?? "global"; var result = await knowledgeService.VerifyGroundednessAsync(request.Answer, request.Context, tenantId); if (result.IsSuccess) return Results.Ok(result.Value); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); knowledgeApi.MapPost("/search", async (SemanticSearchRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => { var tenantId = user.FindFirstValue("TenantId") ?? "global"; var result = await knowledgeService.SearchLibrarySemanticallyAsync(request.QueryText, tenantId, request.Limit); if (result.IsSuccess) return Results.Ok(result.Value); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); knowledgeApi.MapPost("/ask", async (AskQuestionRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => { var tenantId = user.FindFirstValue("TenantId") ?? "global"; var result = await knowledgeService.AskQuestionAsync(request.Question, tenantId, request.EbookId, request.Limit); if (result.IsSuccess) return Results.Ok(result.Value); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) => { var result = await knowledgeService.ClearCacheAsync(); if (result.IsSuccess) return Results.Ok(); var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"; return Results.BadRequest(errorMsg); }); app.MapPost("/api/intelligence", async ( [FromBody] NexusReader.Application.Queries.Intelligence.GetGlobalIntelligenceRequest request, ClaimsPrincipal user, IMediator mediator) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(userId)) return Results.Unauthorized(); var tenantId = user.FindFirstValue("TenantId") ?? "global"; var result = await mediator.Send(new NexusReader.Application.Queries.Intelligence.GetGlobalIntelligenceQuery(request.QueryText, userId, tenantId)); if (result.IsSuccess) return Results.Ok(result.Value); var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Failed to execute global intelligence query"; return Results.BadRequest(errorMsg); }).RequireAuthorization(); app.MapGet("/api/recommendations", async ( ClaimsPrincipal user, IMediator mediator) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(userId)) return Results.Unauthorized(); var result = await mediator.Send(new NexusReader.Application.Queries.Recommendations.GetContextualRecommendationsQuery(userId)); if (result.IsSuccess) return Results.Ok(result.Value); var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Failed to fetch contextual recommendations"; return Results.BadRequest(errorMsg); }).RequireAuthorization(); app.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request, ClaimsPrincipal user, IMediator mediator) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(userId)) return Results.Unauthorized(); var epubData = Convert.FromBase64String(request.EpubDataBase64); byte[]? coverData = !string.IsNullOrEmpty(request.CoverImageBase64) ? Convert.FromBase64String(request.CoverImageBase64) : null; var tenantId = user.FindFirst("TenantId")?.Value ?? "global"; var command = new IngestEbookCommand( request.Title, request.AuthorName, coverData, epubData, request.Description, userId, tenantId ); var result = await mediator.Send(command); if (result.IsSuccess) return Results.Ok(new { Id = result.Value }); return Results.BadRequest(result.Errors.FirstOrDefault()?.Message ?? "Ingestion failed"); }).RequireAuthorization().DisableAntiforgery(); app.MapGet("/api/library/books", async (ClaimsPrincipal user, IMediator mediator) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(userId)) return Results.Unauthorized(); var result = await mediator.Send(new GetMyEbooksQuery(userId)); 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); }).RequireAuthorization(); app.MapPost("/api/library/purchase", async ( ClaimsPrincipal user, [FromBody] PurchaseBookRequest request, IDbContextFactory dbContextFactory) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(userId)) return Results.Unauthorized(); using var dbContext = await dbContextFactory.CreateDbContextAsync(); // Find or create author var authorName = "Nexus Architect"; var author = await dbContext.Authors.FirstOrDefaultAsync(a => a.Name == authorName); if (author == null) { author = new Author { Name = authorName }; dbContext.Authors.Add(author); await dbContext.SaveChangesAsync(); } // Check if the book already exists for the user var bookExists = await dbContext.Ebooks.AnyAsync(e => e.UserId == userId && e.Title == request.Title); if (!bookExists) { var newBook = new Ebook { Title = request.Title, AuthorId = author.Id, UserId = userId, FilePath = "wwwroot/assets/book.epub", AddedDate = DateTime.UtcNow, Progress = 0, Description = "Zaawansowany kurs budowania skalowalnych SaaS z Native AOT, CQRS, MediatR, FluentResults i izolowanym systemem stylów Blazor CSS.", IsReadyForReading = true }; dbContext.Ebooks.Add(newBook); await dbContext.SaveChangesAsync(); } return Results.Ok(); }).RequireAuthorization(); app.MapGet("/api/book/{bookId:guid}/concepts-map", async ( Guid bookId, ClaimsPrincipal user, IMediator mediator) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); var tenantId = user.FindFirstValue("TenantId") ?? "global"; if (string.IsNullOrEmpty(userId)) { return Results.Unauthorized(); } var result = await mediator.Send(new GetBookConceptsMapQuery(bookId, userId, tenantId)); if (result.IsFailed) { return Results.BadRequest(result.Errors.FirstOrDefault()?.Message ?? "Failed to fetch concepts map."); } return Results.Ok(result.Value); }).RequireAuthorization(); app.MapPost("/api/StripeWebhook", async ( HttpContext context, UserManager userManager, IConfiguration configuration, IDbContextFactory dbContextFactory) => { using var dbContext = await dbContextFactory.CreateDbContextAsync(); var json = await new StreamReader(context.Request.Body).ReadToEndAsync(); var webhookSecret = configuration["Stripe:WebhookSecret"] ?? ""; try { var stripeEvent = EventUtility.ConstructEvent( json, context.Request.Headers["Stripe-Signature"], webhookSecret ); switch (stripeEvent.Type) { case EventTypes.CheckoutSessionCompleted: var session = stripeEvent.Data.Object as Stripe.Checkout.Session; await HandleSubscriptionSuccess(session?.CustomerEmail, session?.Metadata, userManager, dbContext); break; case EventTypes.CustomerSubscriptionUpdated: var subscription = stripeEvent.Data.Object as Stripe.Subscription; await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata, userManager, dbContext); break; case EventTypes.CustomerSubscriptionDeleted: var deletedSubscription = stripeEvent.Data.Object as Stripe.Subscription; await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"], userManager, dbContext); break; } return Results.Ok(); } catch (StripeException e) { return Results.BadRequest(e.Message); } }).DisableAntiforgery(); async Task HandleSubscriptionSuccess( string? email, Dictionary? metadata, UserManager userManager, AppDbContext dbContext) { if (string.IsNullOrEmpty(email)) return; var user = await userManager.FindByEmailAsync(email); if (user != null) { var planName = metadata?.GetValueOrDefault("Plan") ?? SubscriptionPlan.ProName; var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == planName); if (plan != null) { user.SubscriptionPlanId = plan.Id; user.AITokenLimit = plan.AITokenLimit; } await userManager.UpdateAsync(user); } } async Task HandleSubscriptionCancellation( string? email, UserManager userManager, AppDbContext dbContext) { if (string.IsNullOrEmpty(email)) return; var user = await userManager.FindByEmailAsync(email); if (user != null) { var freePlan = await dbContext.SubscriptionPlans.FindAsync(SubscriptionPlan.FreeId); user.SubscriptionPlanId = SubscriptionPlan.FreeId; user.AITokenLimit = freePlan?.AITokenLimit ?? 5000; await userManager.UpdateAsync(user); } } app.MapGroup("/identity").MapIdentityApi(); app.MapGet("/identity/login/google", (string? returnUrl) => { var properties = new AuthenticationProperties { RedirectUri = "/identity/callback/google", Items = { { "returnUrl", string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl } } }; return Results.Challenge(properties, new[] { "Google" }); }); app.MapGet("/identity/callback/google", async ( HttpContext context, SignInManager signInManager, UserManager userManager, ILogger logger) => { var info = await signInManager.GetExternalLoginInfoAsync(); if (info == null) { logger.LogWarning("External login info from Google is null."); return Results.Redirect("/account/login?error=ExternalLoginFailed"); } var result = await signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false); if (result.Succeeded) { logger.LogInformation("User logged in via Google: {Email}", info.Principal.FindFirstValue(ClaimTypes.Email)); return Results.Redirect("/"); } if (result.IsLockedOut) { logger.LogWarning("User account locked out during Google login: {Email}", info.Principal.FindFirstValue(ClaimTypes.Email)); return Results.Redirect("/account/login?error=LockedOut"); } // New user provisioning (blocked when registration is disabled) if (!allowRegistration) { logger.LogWarning("Google provisioning blocked: registration is disabled in this environment."); return Results.Redirect("/account/login?error=RegistrationDisabled"); } var email = info.Principal.FindFirstValue(ClaimTypes.Email); if (email != null) { var user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true }; var createResult = await userManager.CreateAsync(user); if (createResult.Succeeded) { await userManager.AddLoginAsync(user, info); await signInManager.SignInAsync(user, isPersistent: false); logger.LogInformation("New user provisioned via Google: {Email}", email); return Results.Redirect("/"); } // Log specific errors foreach (var error in createResult.Errors) { logger.LogError("Google provisioning failed for {Email}: {Code} - {Description}", email, error.Code, error.Description); } if (createResult.Errors.Any(e => e.Code == "DuplicateEmail" || e.Code == "DuplicateUserName")) { return Results.Redirect("/account/login?error=UserAlreadyExists"); } } logger.LogError("Google provisioning failed - unknown reason for email {Email}", email); return Results.Redirect("/account/login?error=ProvisioningFailed"); }); app.MapPost("/account/login-form", async ( [FromForm] string email, [FromForm] string password, [FromForm] bool rememberMe, [FromForm] string? returnUrl, SignInManager signInManager, ILogger logger) => { var result = await signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: true); if (result.Succeeded) { logger.LogInformation("User logged in: {Email}", email); return Results.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl); } var error = result.IsLockedOut ? "LockedOut" : "InvalidCredentials"; return Results.Redirect($"/account/login?error={error}"); }).DisableAntiforgery(); // Simplified for now, in production use proper antiforgery app.MapGet("/account/logout-form", async ( SignInManager signInManager, ILogger logger) => { await signInManager.SignOutAsync(); logger.LogInformation("User logged out via form."); return Results.Redirect("/account/login"); }); app.MapGet("/identity/profile", async (ClaimsPrincipal user, IMediator mediator) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (userId == null) return Results.Unauthorized(); var result = await mediator.Send(new GetUserProfileQuery(userId)); if (result.IsFailed) return Results.NotFound(result.Errors.FirstOrDefault()?.Message); return Results.Ok(result.Value); }).RequireAuthorization(); app.MapRazorComponents() .AddInteractiveServerRenderMode() .AddInteractiveWebAssemblyRenderMode() .AddAdditionalAssemblies(typeof(NexusReader.UI.Shared.Services.IKnowledgeGraphService).Assembly); app.Run(); async Task TriggerBackgroundProcessingForUnindexedBooksAsync(IServiceProvider services) { var logger = services.GetRequiredService>(); try { var dbContextFactory = services.GetRequiredService>(); using var dbContext = await dbContextFactory.CreateDbContextAsync(); var unindexedEbooks = await dbContext.Ebooks .Where(e => !e.IsReadyForReading) .ToListAsync(); if (unindexedEbooks.Any()) { logger.LogInformation("[Startup] Found {Count} un-indexed ebooks. Triggering background processing...", unindexedEbooks.Count); foreach (var ebook in unindexedEbooks) { logger.LogInformation("[Startup] Queuing background processing for ebook: '{Title}' ({Id})", ebook.Title, ebook.Id); _ = Task.Run(async () => { try { using var scope = services.CreateScope(); var scopedMediator = scope.ServiceProvider.GetRequiredService(); await scopedMediator.Send(new ProcessEbookCommand(ebook.Id, ebook.UserId, ebook.TenantId)); } catch (Exception ex) { using var scope = services.CreateScope(); var scopedLogger = scope.ServiceProvider.GetRequiredService>(); scopedLogger.LogError(ex, "Failed to run background processing for ebook {EbookId} on startup", ebook.Id); } }); } } } catch (Exception ex) { logger.LogError(ex, "Error checking or triggering background processing for unindexed books on startup."); } } public record KnowledgeRequest(string Text, Guid? EbookId = null); public record GroundednessRequest(string Answer, string Context); public record SemanticSearchRequest(string QueryText, int Limit = 5); public record AskQuestionRequest(string Question, Guid? EbookId = null, int Limit = 5); public record PurchaseBookRequest(string Title);