refactor: remove Stripe webhook controller, optimize MainLayout rendering, and update DI registration in Program.cs
This commit is contained in:
@@ -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">
|
||||
<KnowledgeGraph />
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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 =>
|
||||
@@ -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<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);
|
||||
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<App>()
|
||||
|
||||
Reference in New Issue
Block a user