refactor: remove Stripe webhook controller, optimize MainLayout rendering, and update DI registration in Program.cs

This commit is contained in:
2026-05-01 20:12:36 +02:00
parent 47bffd629f
commit 93d8dfde7e
3 changed files with 147 additions and 154 deletions
@@ -3,12 +3,14 @@
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Components.Molecules @using NexusReader.UI.Shared.Components.Molecules
@using NexusReader.UI.Shared.Components.Organisms @using NexusReader.UI.Shared.Components.Organisms
@using Microsoft.Extensions.Logging
@inject IPlatformService PlatformService @inject IPlatformService PlatformService
@inject IFocusModeService FocusMode @inject IFocusModeService FocusMode
@inject IQuizStateService QuizService @inject IQuizStateService QuizService
@inject IJSRuntime JS @inject IJSRuntime JS
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject Microsoft.Extensions.Logging.ILogger<MainLayout> Logger
@implements IDisposable @implements IDisposable
<AuthorizeView> <AuthorizeView>
@@ -28,7 +30,8 @@
<div class="intelligence-content"> <div class="intelligence-content">
<div class="intelligence-header"> <div class="intelligence-header">
<div class="ai-title"> <div class="ai-title">
<NexusIcon Name="robot" Size="20" Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" /> <NexusIcon Name="robot" Size="20"
Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" />
<span>Asystent AI</span> <span>Asystent AI</span>
</div> </div>
@@ -41,7 +44,10 @@
</div> </div>
<div class="intelligence-scroll-area"> <div class="intelligence-scroll-area">
<KnowledgeGraph /> @if (!_isMobile)
{
<KnowledgeGraph />
}
<KnowledgeCheck /> <KnowledgeCheck />
</div> </div>
</div> </div>
@@ -67,6 +73,7 @@
@code { @code {
private string _platformClass = "platform-desktop"; private string _platformClass = "platform-desktop";
private bool _isMobile = false;
protected override void OnInitialized() protected override void OnInitialized()
{ {
@@ -76,11 +83,13 @@
var context = PlatformService.GetDeviceContext(); var context = PlatformService.GetDeviceContext();
if (context.IsSuccess) if (context.IsSuccess)
{ {
_platformClass = context.Value.DeviceType switch _isMobile = context.Value.DeviceType switch
{ {
DeviceType.Phone or DeviceType.Tablet => "platform-mobile", DeviceType.Phone or DeviceType.Tablet => true,
_ => "platform-desktop" _ => false
}; };
_platformClass = _isMobile ? "platform-mobile" : "platform-desktop";
} }
} }
@@ -99,7 +108,10 @@
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/layoutResizer.js"); var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/layoutResizer.js");
await module.InvokeVoidAsync("initResizer", ".app-container", "#sidebar-resizer", "--sidebar-width"); 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; QuizService.OnQuizUpdated -= StateHasChanged;
} }
} }
@@ -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<NexusUser> _userManager;
private readonly IConfiguration _configuration;
private readonly string _webhookSecret;
public StripeWebhookController(UserManager<NexusUser> userManager, IConfiguration configuration)
{
_userManager = userManager;
_configuration = configuration;
_webhookSecret = _configuration["Stripe:WebhookSecret"] ?? "";
}
[HttpPost]
public async Task<IActionResult> 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<string, string>? 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);
}
}
}
+126 -47
View File
@@ -14,6 +14,7 @@ using NexusReader.Infrastructure.Identity;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using System.Security.Claims; using System.Security.Claims;
using NexusReader.Infrastructure.Services; using NexusReader.Infrastructure.Services;
using Stripe;
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
@@ -24,8 +25,6 @@ builder.Services.AddRazorComponents()
.AddInteractiveServerComponents() .AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents(); .AddInteractiveWebAssemblyComponents();
builder.Services.AddControllers();
// Enable detailed circuit errors for ServerSide Blazor components // Enable detailed circuit errors for ServerSide Blazor components
builder.Services.AddServerSideBlazor() builder.Services.AddServerSideBlazor()
.AddCircuitOptions(options => .AddCircuitOptions(options =>
@@ -49,7 +48,7 @@ builder.Services.AddHttpClient("NexusAPI", client =>
}); });
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI")); builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
builder.Services.AddScoped<IIdentityService, IdentityService>(); builder.Services.AddScoped<IIdentityService, NexusReader.UI.Shared.Services.IdentityService>();
builder.Services.AddScoped<NexusAuthenticationStateProvider>(); builder.Services.AddScoped<NexusAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<NexusAuthenticationStateProvider>()); builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<NexusAuthenticationStateProvider>());
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
@@ -59,14 +58,12 @@ builder.Services.AddInfrastructure(builder.Configuration);
// Authorization Policies // Authorization Policies
builder.Services.AddScoped<IAuthorizationHandler, TokenLimitHandler>(); builder.Services.AddScoped<IAuthorizationHandler, TokenLimitHandler>();
builder.Services.AddAuthorization(options => builder.Services.AddAuthorizationBuilder()
{ .AddPolicy("ProUser", policy => policy.RequireClaim("Plan", "Pro", "Enterprise"))
options.AddPolicy("ProUser", policy => policy.RequireClaim("Plan", "Pro", "Enterprise")); .AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement()));
options.AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement()));
});
// Billing & Stripe // Billing & Stripe
builder.Services.AddScoped<IBillingService, BillingService>(); builder.Services.AddScoped<IBillingService, NexusReader.Infrastructure.Services.BillingService>();
// Authentication // Authentication
builder.Services.AddAuthentication(options => builder.Services.AddAuthentication(options =>
@@ -128,21 +125,36 @@ using (var scope = app.Services.CreateScope())
{ {
try 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 dbContext.Database.MigrateAsync();
await DbInitializer.SeedAsync(services); await DbInitializer.SeedAsync(services);
logger.LogInformation("Baza danych zainicjowana pomyślnie.");
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation("Baza danych zainicjowana pomyślnie.");
}
break; break;
} }
catch (Npgsql.NpgsqlException ex) when (i < maxRetries - 1) 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); await Task.Delay(delayMs);
delayMs *= 2; // Exponential backoff delayMs *= 2; // Exponential backoff
} }
catch (Exception ex) 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; throw;
} }
} }
@@ -169,7 +181,6 @@ app.UseAntiforgery();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapStaticAssets(); app.MapStaticAssets();
app.MapControllers();
// API endpoint for WASM client to fetch EPUB content // API endpoint for WASM client to fetch EPUB content
app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) => 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); var result = await epubService.GetEpubContentAsync(index);
if (result.IsSuccess) return Results.Ok(result.Value); 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); 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); var result = await knowledgeService.GetKnowledgeAsync(request.Text);
if (result.IsSuccess) return Results.Ok(result.Value); 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); var result = await knowledgeService.GetGraphDataAsync(request.Text);
if (result.IsSuccess) return Results.Ok(result.Value); 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); var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text);
if (result.IsSuccess) return Results.Ok(result.Value); 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(); var result = await knowledgeService.ClearCacheAsync();
if (result.IsSuccess) return Results.Ok(); 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); return Results.BadRequest(errorMsg);
}); });
app.MapPost("/api/StripeWebhook", async (HttpContext context, UserManager<NexusUser> 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<string, string>? metadata, UserManager<NexusUser> 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<NexusUser> 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<NexusUser>(); app.MapGroup("/identity").MapIdentityApi<NexusUser>();
app.MapGet("/identity/login/google", (string? returnUrl) => app.MapGet("/identity/login/google", (string? returnUrl) =>
@@ -241,6 +327,7 @@ app.MapGet("/identity/callback/google", async (
var email = info.Principal.FindFirstValue(ClaimTypes.Email); var email = info.Principal.FindFirstValue(ClaimTypes.Email);
if (email != null) 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 user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true };
var createResult = await userManager.CreateAsync(user); var createResult = await userManager.CreateAsync(user);
if (createResult.Succeeded) if (createResult.Succeeded)
@@ -259,31 +346,23 @@ app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUs
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null) return Results.Unauthorized(); if (userId == null) return Results.Unauthorized();
var nexusUser = await dbContext.Users var profile = await dbContext.Users
.Include(u => u.Ebooks) .Where(u => u.Id == userId)
.Include(u => u.QuizResults) .Select(u => new
.FirstOrDefaultAsync(u => u.Id == userId); {
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() return Results.Ok(profile);
? (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
});
}).RequireAuthorization(); }).RequireAuthorization();
app.MapRazorComponents<App>() app.MapRazorComponents<App>()