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.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<MainLayout> Logger
@implements IDisposable
<AuthorizeView>
@@ -28,7 +30,8 @@
<div class="intelligence-content">
<div class="intelligence-header">
<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>
</div>
@@ -41,7 +44,10 @@
</div>
<div class="intelligence-scroll-area">
@if (!_isMobile)
{
<KnowledgeGraph />
}
<KnowledgeCheck />
</div>
</div>
@@ -67,6 +73,7 @@
@code {
private string _platformClass = "platform-desktop";
private bool _isMobile = false;
protected override void OnInitialized()
{
@@ -76,11 +83,13 @@
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<IJSObjectReference>("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;
}
}
@@ -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);
}
}
}
+124 -45
View File
@@ -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 ServerSide Blazor components
builder.Services.AddServerSideBlazor()
.AddCircuitOptions(options =>
@@ -49,7 +48,7 @@ builder.Services.AddHttpClient("NexusAPI", client =>
});
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<AuthenticationStateProvider>(sp => sp.GetRequiredService<NexusAuthenticationStateProvider>());
builder.Services.AddCascadingAuthenticationState();
@@ -59,14 +58,12 @@ builder.Services.AddInfrastructure(builder.Configuration);
// Authorization Policies
builder.Services.AddScoped<IAuthorizationHandler, TokenLimitHandler>();
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<IBillingService, BillingService>();
builder.Services.AddScoped<IBillingService, NexusReader.Infrastructure.Services.BillingService>();
// Authentication
builder.Services.AddAuthentication(options =>
@@ -127,22 +124,37 @@ using (var scope = app.Services.CreateScope())
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 dbContext.Database.MigrateAsync();
await DbInitializer.SeedAsync(services);
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)
{
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<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.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<NexusUs
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null) return Results.Unauthorized();
var nexusUser = await dbContext.Users
.Include(u => u.Ebooks)
.Include(u => u.QuizResults)
.FirstOrDefaultAsync(u => u.Id == userId);
if (nexusUser == 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
var profile = await dbContext.Users
.Where(u => u.Id == userId)
.Select(u => new
{
nexusUser.Email,
nexusUser.AITokenLimit,
nexusUser.AITokensUsed,
nexusUser.CurrentPlan,
nexusUser.TenantId,
AverageQuizScore = avgScore,
LastReadBookTitle = lastReadBook
});
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 (profile == null) return Results.NotFound();
return Results.Ok(profile);
}).RequireAuthorization();
app.MapRazorComponents<App>()