diff --git a/src/NexusReader.UI.Shared/Layout/MainLayout.razor b/src/NexusReader.UI.Shared/Layout/MainLayout.razor index ab44b8e..406afaf 100644 --- a/src/NexusReader.UI.Shared/Layout/MainLayout.razor +++ b/src/NexusReader.UI.Shared/Layout/MainLayout.razor @@ -3,12 +3,14 @@ @using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Components.Molecules @using NexusReader.UI.Shared.Components.Organisms +@using Microsoft.Extensions.Logging @inject IPlatformService PlatformService @inject IFocusModeService FocusMode @inject IQuizStateService QuizService @inject IJSRuntime JS @inject IIdentityService IdentityService @inject NavigationManager NavigationManager +@inject Microsoft.Extensions.Logging.ILogger Logger @implements IDisposable @@ -28,10 +30,11 @@
- + Asystent AI
- + - +
- + @if (!_isMobile) + { + + }
@@ -67,20 +73,23 @@ @code { private string _platformClass = "platform-desktop"; + private bool _isMobile = false; protected override void OnInitialized() { FocusMode.OnFocusModeChanged += StateHasChanged; QuizService.OnQuizUpdated += StateHasChanged; - + var context = PlatformService.GetDeviceContext(); if (context.IsSuccess) { - _platformClass = context.Value.DeviceType switch + _isMobile = context.Value.DeviceType switch { - DeviceType.Phone or DeviceType.Tablet => "platform-mobile", - _ => "platform-desktop" + DeviceType.Phone or DeviceType.Tablet => true, + _ => false }; + + _platformClass = _isMobile ? "platform-mobile" : "platform-desktop"; } } @@ -99,7 +108,10 @@ var module = await JS.InvokeAsync("import", "./_content/NexusReader.UI.Shared/js/layoutResizer.js"); await module.InvokeVoidAsync("initResizer", ".app-container", "#sidebar-resizer", "--sidebar-width"); } - catch { } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to initialize layout resizer JS module."); + } } } @@ -109,4 +121,3 @@ QuizService.OnQuizUpdated -= StateHasChanged; } } - diff --git a/src/NexusReader.Web.New/Controllers/StripeWebhookController.cs b/src/NexusReader.Web.New/Controllers/StripeWebhookController.cs deleted file mode 100644 index 353b307..0000000 --- a/src/NexusReader.Web.New/Controllers/StripeWebhookController.cs +++ /dev/null @@ -1,97 +0,0 @@ -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc; -using NexusReader.Domain.Entities; -using Stripe; - -namespace NexusReader.Web.New.Controllers; - -[Route("api/[controller]")] -[ApiController] -public class StripeWebhookController : ControllerBase -{ - private readonly UserManager _userManager; - private readonly IConfiguration _configuration; - private readonly string _webhookSecret; - - public StripeWebhookController(UserManager userManager, IConfiguration configuration) - { - _userManager = userManager; - _configuration = configuration; - _webhookSecret = _configuration["Stripe:WebhookSecret"] ?? ""; - } - - [HttpPost] - public async Task Index() - { - var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(); - - try - { - var stripeEvent = EventUtility.ConstructEvent( - json, - 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); - break; - - case EventTypes.CustomerSubscriptionUpdated: - var subscription = stripeEvent.Data.Object as Stripe.Subscription; - // Subscription update might not have email directly, would need to fetch customer - // For now, assuming email is in metadata if we set it during checkout - await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata); - break; - - case EventTypes.CustomerSubscriptionDeleted: - var deletedSubscription = stripeEvent.Data.Object as Stripe.Subscription; - await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"]); - break; - } - - return Ok(); - } - catch (StripeException e) - { - return BadRequest(e.Message); - } - } - - private async Task HandleSubscriptionSuccess(string? email, Dictionary? metadata) - { - if (string.IsNullOrEmpty(email)) return; - - var user = await _userManager.FindByEmailAsync(email); - if (user != null) - { - var plan = metadata != null && metadata.ContainsKey("Plan") ? metadata["Plan"] : "Pro"; - - user.CurrentPlan = plan; - user.AITokenLimit = plan.ToLower() switch - { - "pro" => 50000, - "enterprise" => 500000, - _ => 10000 // default for unknown or free - }; - - await _userManager.UpdateAsync(user); - } - } - - private async Task HandleSubscriptionCancellation(string? email) - { - if (string.IsNullOrEmpty(email)) return; - - var user = await _userManager.FindByEmailAsync(email); - if (user != null) - { - user.CurrentPlan = "Free"; - user.AITokenLimit = 5000; // Free tier limit - await _userManager.UpdateAsync(user); - } - } -} diff --git a/src/NexusReader.Web.New/Program.cs b/src/NexusReader.Web.New/Program.cs index 80a67c6..c644ba0 100644 --- a/src/NexusReader.Web.New/Program.cs +++ b/src/NexusReader.Web.New/Program.cs @@ -14,6 +14,7 @@ using NexusReader.Infrastructure.Identity; using Microsoft.AspNetCore.Authentication; using System.Security.Claims; using NexusReader.Infrastructure.Services; +using Stripe; AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); @@ -24,8 +25,6 @@ builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddInteractiveWebAssemblyComponents(); -builder.Services.AddControllers(); - // Enable detailed circuit errors for Server‑Side Blazor components builder.Services.AddServerSideBlazor() .AddCircuitOptions(options => @@ -49,7 +48,7 @@ builder.Services.AddHttpClient("NexusAPI", client => }); builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("NexusAPI")); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddCascadingAuthenticationState(); @@ -59,14 +58,12 @@ builder.Services.AddInfrastructure(builder.Configuration); // Authorization Policies builder.Services.AddScoped(); -builder.Services.AddAuthorization(options => -{ - options.AddPolicy("ProUser", policy => policy.RequireClaim("Plan", "Pro", "Enterprise")); - options.AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement())); -}); +builder.Services.AddAuthorizationBuilder() + .AddPolicy("ProUser", policy => policy.RequireClaim("Plan", "Pro", "Enterprise")) + .AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement())); // Billing & Stripe -builder.Services.AddScoped(); +builder.Services.AddScoped(); // Authentication builder.Services.AddAuthentication(options => @@ -128,21 +125,36 @@ using (var scope = app.Services.CreateScope()) { try { - logger.LogInformation("Próba połączenia z bazą danych (próba {Attempt}/{MaxRetries})...", i + 1, maxRetries); + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("Próba połączenia z bazą danych (próba {Attempt}/{MaxRetries})...", i + 1, maxRetries); + } + await dbContext.Database.MigrateAsync(); await DbInitializer.SeedAsync(services); - logger.LogInformation("Baza danych zainicjowana pomyślnie."); + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("Baza danych zainicjowana pomyślnie."); + } break; } catch (Npgsql.NpgsqlException ex) when (i < maxRetries - 1) { - logger.LogWarning("Błąd połączenia z bazą danych: {Message}. Ponowna próba za {Delay}ms...", ex.Message, delayMs); + 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) { - logger.LogCritical(ex, "Krytyczny błąd podczas inicjalizacji bazy danych."); + if (logger.IsEnabled(LogLevel.Critical)) + { + logger.LogCritical(ex, "Krytyczny błąd podczas inicjalizacji bazy danych."); + } throw; } } @@ -169,7 +181,6 @@ app.UseAntiforgery(); app.UseAuthentication(); app.UseAuthorization(); app.MapStaticAssets(); -app.MapControllers(); // API endpoint for WASM client to fetch EPUB content app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) => @@ -177,40 +188,115 @@ app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) => var result = await epubService.GetEpubContentAsync(index); if (result.IsSuccess) return Results.Ok(result.Value); - var errorMsg = result.Errors.FirstOrDefault()?.Message ?? "Unknown server error"; + var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"; return Results.BadRequest(errorMsg); -}); +}).RequireAuthorization(); -app.MapPost("/api/knowledge", async (KnowledgeRequest request, IKnowledgeService knowledgeService) => +var knowledgeApi = app.MapGroup("/api/knowledge").RequireAuthorization("HasAvailableTokens"); + +knowledgeApi.MapPost("/", async (KnowledgeRequest request, IKnowledgeService knowledgeService) => { var result = await knowledgeService.GetKnowledgeAsync(request.Text); if (result.IsSuccess) return Results.Ok(result.Value); - return Results.BadRequest(result.Errors.FirstOrDefault()?.Message ?? "Unknown server error"); + return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); -app.MapPost("/api/knowledge/graph", async (KnowledgeRequest request, IKnowledgeService knowledgeService) => +knowledgeApi.MapPost("/graph", async (KnowledgeRequest request, IKnowledgeService knowledgeService) => { var result = await knowledgeService.GetGraphDataAsync(request.Text); if (result.IsSuccess) return Results.Ok(result.Value); - return Results.BadRequest(result.Errors.FirstOrDefault()?.Message ?? "Unknown server error"); + return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); -app.MapPost("/api/knowledge/summary", async (KnowledgeRequest request, IKnowledgeService knowledgeService) => +knowledgeApi.MapPost("/summary", async (KnowledgeRequest request, IKnowledgeService knowledgeService) => { var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text); if (result.IsSuccess) return Results.Ok(result.Value); - return Results.BadRequest(result.Errors.FirstOrDefault()?.Message ?? "Unknown server error"); + return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); -app.MapDelete("/api/knowledge", async (IKnowledgeService knowledgeService) => +knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) => { var result = await knowledgeService.ClearCacheAsync(); if (result.IsSuccess) return Results.Ok(); - var errorMsg = result.Errors.FirstOrDefault()?.Message ?? "Unknown server error"; + var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"; return Results.BadRequest(errorMsg); }); +app.MapPost("/api/StripeWebhook", async (HttpContext context, UserManager userManager, IConfiguration configuration) => +{ + 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); + break; + + case EventTypes.CustomerSubscriptionUpdated: + var subscription = stripeEvent.Data.Object as Stripe.Subscription; + await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata, userManager); + break; + + case EventTypes.CustomerSubscriptionDeleted: + var deletedSubscription = stripeEvent.Data.Object as Stripe.Subscription; + await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"], userManager); + break; + } + + return Results.Ok(); + } + catch (StripeException e) + { + return Results.BadRequest(e.Message); + } +}); + +async Task HandleSubscriptionSuccess(string? email, Dictionary? metadata, UserManager userManager) +{ + if (string.IsNullOrEmpty(email)) return; + + var user = await userManager.FindByEmailAsync(email); + if (user != null) + { + var plan = metadata?.GetValueOrDefault("Plan") ?? "Pro"; + + user.CurrentPlan = plan; + user.AITokenLimit = plan.ToLower() switch + { + "pro" => 50000, + "enterprise" => 500000, + _ => 10000 // default for unknown or free + }; + + await userManager.UpdateAsync(user); + } +} + +async Task HandleSubscriptionCancellation(string? email, UserManager userManager) +{ + if (string.IsNullOrEmpty(email)) return; + + var user = await userManager.FindByEmailAsync(email); + if (user != null) + { + user.CurrentPlan = "Free"; + user.AITokenLimit = 5000; // Free tier limit + await userManager.UpdateAsync(user); + } +} + app.MapGroup("/identity").MapIdentityApi(); app.MapGet("/identity/login/google", (string? returnUrl) => @@ -241,6 +327,7 @@ app.MapGet("/identity/callback/google", async ( var email = info.Principal.FindFirstValue(ClaimTypes.Email); if (email != null) { + // TODO: REV-5 - Consider redirecting to Terms of Service / Onboarding before final provisioning var user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true }; var createResult = await userManager.CreateAsync(user); if (createResult.Succeeded) @@ -259,31 +346,23 @@ app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager u.Ebooks) - .Include(u => u.QuizResults) - .FirstOrDefaultAsync(u => u.Id == userId); + var profile = await dbContext.Users + .Where(u => u.Id == userId) + .Select(u => new + { + u.Email, + u.AITokenLimit, + u.AITokensUsed, + u.CurrentPlan, + u.TenantId, + AverageQuizScore = u.QuizResults.Any() ? (int?)u.QuizResults.Average(q => q.Percentage) ?? 0 : 0, + LastReadBookTitle = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => e.Title).FirstOrDefault() ?? "None" + }) + .FirstOrDefaultAsync(); - if (nexusUser == null) return Results.NotFound(); + if (profile == null) return Results.NotFound(); - var avgScore = nexusUser.QuizResults.Any() - ? (int)nexusUser.QuizResults.Average(q => q.Percentage) - : 0; - - var lastReadBook = nexusUser.Ebooks - .OrderByDescending(e => e.LastReadDate) - .FirstOrDefault()?.Title ?? "None"; - - return Results.Ok(new - { - nexusUser.Email, - nexusUser.AITokenLimit, - nexusUser.AITokensUsed, - nexusUser.CurrentPlan, - nexusUser.TenantId, - AverageQuizScore = avgScore, - LastReadBookTitle = lastReadBook - }); + return Results.Ok(profile); }).RequireAuthorization(); app.MapRazorComponents()