From 5d70ba797afb5c7f8f9551ea598005a50d816bc8 Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Sun, 5 Oct 2025 21:57:50 -0300 Subject: [PATCH 1/2] feat: ajustes para downgrade --- BCards.sln | 5 + .../BCards.IntegrationTests.csproj | 1 + src/BCards.Web/BCards.Web.csproj | 33 +-- src/BCards.Web/Controllers/AdminController.cs | 89 +++++++ src/BCards.Web/Controllers/AuthController.cs | 7 +- .../Controllers/TestToolsController.cs | 170 +++++++++++++ src/BCards.Web/Program.cs | 10 + src/BCards.Web/Services/DowngradeService.cs | 225 ++++++++++++++++++ src/BCards.Web/Services/IDowngradeService.cs | 32 +++ src/BCards.Web/TestSupport/TestAuthHandler.cs | 2 + .../ViewModels/ManagePageViewModel.cs | 42 +++- src/BCards.Web/Views/Admin/ManagePage.cshtml | 1 + 12 files changed, 600 insertions(+), 17 deletions(-) create mode 100644 src/BCards.Web/Controllers/TestToolsController.cs create mode 100644 src/BCards.Web/Services/DowngradeService.cs create mode 100644 src/BCards.Web/Services/IDowngradeService.cs diff --git a/BCards.sln b/BCards.sln index bec54d6..af5c4e7 100644 --- a/BCards.sln +++ b/BCards.sln @@ -28,16 +28,21 @@ Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU + Testing|Any CPU = Testing|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU + {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Testing|Any CPU.ActiveCfg = Testing|Any CPU + {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Testing|Any CPU.Build.0 = Testing|Any CPU {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.Build.0 = Release|Any CPU + {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Testing|Any CPU.ActiveCfg = Testing|Any CPU + {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Testing|Any CPU.Build.0 = Testing|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj b/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj index 52be975..706e425 100644 --- a/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj +++ b/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj @@ -6,6 +6,7 @@ enable false true + Debug;Release;Testing diff --git a/src/BCards.Web/BCards.Web.csproj b/src/BCards.Web/BCards.Web.csproj index 802d00d..67d0eeb 100644 --- a/src/BCards.Web/BCards.Web.csproj +++ b/src/BCards.Web/BCards.Web.csproj @@ -5,7 +5,8 @@ enable enable false - linux-x64;linux-arm64 + linux-x64;linux-arm64 + Debug;Release;Testing @@ -19,21 +20,25 @@ - - - - - - - - - - - - + + + + + + + + + + + + - + + $(DefineConstants);TESTING + + + diff --git a/src/BCards.Web/Controllers/AdminController.cs b/src/BCards.Web/Controllers/AdminController.cs index c03bd18..1889d66 100644 --- a/src/BCards.Web/Controllers/AdminController.cs +++ b/src/BCards.Web/Controllers/AdminController.cs @@ -24,6 +24,7 @@ public class AdminController : Controller private readonly ILivePageService _livePageService; private readonly IImageStorageService _imageStorage; private readonly IPaymentService _paymentService; + private readonly IDowngradeService _downgradeService; private readonly ILogger _logger; public AdminController( @@ -36,6 +37,7 @@ public class AdminController : Controller ILivePageService livePageService, IImageStorageService imageStorage, IPaymentService paymentService, + IDowngradeService downgradeService, ILogger logger) { _authService = authService; @@ -47,6 +49,7 @@ public class AdminController : Controller _livePageService = livePageService; _imageStorage = imageStorage; _paymentService = paymentService; + _downgradeService = downgradeService; _logger = logger; } @@ -299,6 +302,19 @@ public class AdminController : Controller if (model.IsNewPage) { + // CRITICAL: Check if user can create new page (validate MaxPages limit) + var existingPages = await _userPageService.GetUserPagesAsync(user.Id); + var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial; + var maxPages = userPlanType.GetMaxPages(); + + if (existingPages.Count >= maxPages) + { + TempData["Error"] = $"Você já atingiu o limite de {maxPages} página(s) do seu plano atual. Faça upgrade para criar mais páginas."; + model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); + model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); + return View(model); + } + // Generate slug if not provided if (string.IsNullOrEmpty(model.Slug)) { @@ -1126,6 +1142,18 @@ public class AdminController : Controller } } + // Método auxiliar para critérios de downgrade (usado na API) + private DowngradeCriteria GetDowngradeCriteria(PlanType plan) + { + return new DowngradeCriteria + { + MaxPages = plan.GetMaxPages(), + MaxLinksPerPage = plan.GetMaxLinksPerPage(), + SelectionCriteria = "Páginas mais antigas têm prioridade", + LinksCriteria = "Páginas com muitos links são automaticamente suspensas" + }; + } + private void CleanSocialMediaFields(ManagePageViewModel model) { // Tratar espaço em branco como campo vazio para redes sociais @@ -1142,6 +1170,67 @@ public class AdminController : Controller model.TwitterUrl = string.Empty; } + // Endpoint para validar impacto de downgrade + [HttpPost] + [Route("ValidateDowngrade")] + public async Task ValidateDowngrade(string targetPlan) + { + try + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return Json(new { error = "Usuário não encontrado" }); + + if (!Enum.TryParse(targetPlan, true, out var newPlan)) + return Json(new { error = "Plano inválido" }); + + var analysis = await _downgradeService.AnalyzeDowngradeImpact(user.Id, newPlan); + + // CASO CRÍTICO: Nenhuma página atende os critérios + if (analysis.IsCritical) + { + return Json(new + { + canDowngrade = false, + critical = true, + title = "⚠️ Downgrade não recomendado", + message = "Nenhuma de suas páginas atende aos limites do novo plano. Todas seriam suspensas.", + details = analysis.Issues, + suggestion = "Considere editar suas páginas para reduzir o número de links antes do downgrade.", + criteria = GetDowngradeCriteria(newPlan) + }); + } + + // CASO NORMAL: Algumas páginas serão afetadas + return Json(new + { + canDowngrade = true, + critical = false, + title = "Confirmação de Downgrade", + summary = analysis.Summary, + eligiblePages = analysis.EligiblePages.Select(p => new + { + name = p.DisplayName, + linkCount = p.LinkCount, + reason = "✅ Dentro dos limites" + }), + suspendedPages = analysis.AffectedPages.Select(p => new + { + name = p.DisplayName, + linkCount = p.LinkCount, + reason = p.SuspensionReason + }), + criteria = GetDowngradeCriteria(newPlan) + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Erro ao validar downgrade para usuário {User}", User.Identity?.Name); + return Json(new { error = "Erro interno do servidor" }); + } + } + + // 🔥 OTIMIZAÇÃO: Endpoint para manter a sessão do usuário ativa [HttpPost] [Route("KeepAlive")] diff --git a/src/BCards.Web/Controllers/AuthController.cs b/src/BCards.Web/Controllers/AuthController.cs index 1b1307b..d1f57f7 100644 --- a/src/BCards.Web/Controllers/AuthController.cs +++ b/src/BCards.Web/Controllers/AuthController.cs @@ -6,7 +6,9 @@ using Microsoft.AspNetCore.Authentication.MicrosoftAccount; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.Security.Claims; +#if TESTING using BCards.Web.TestSupport; +#endif namespace BCards.Web.Controllers; @@ -148,8 +150,10 @@ public class AuthController : Controller return Challenge(properties, MicrosoftAccountDefaults.AuthenticationScheme); } +#if TESTING [HttpPost] [Route("LoginWithTest")] + public IActionResult LoginWithTest(string? returnUrl = null) { if (!_env.IsEnvironment("Testing")) @@ -170,6 +174,7 @@ public class AuthController : Controller var properties = new AuthenticationProperties { RedirectUri = redirectUrlString }; return Challenge(properties, TestAuthConstants.AuthenticationScheme); } +#endif [HttpGet] [Route("GoogleCallback")] @@ -251,4 +256,4 @@ public class AuthController : Controller return RedirectToAction("Dashboard", "Admin"); } -} \ No newline at end of file +} diff --git a/src/BCards.Web/Controllers/TestToolsController.cs b/src/BCards.Web/Controllers/TestToolsController.cs new file mode 100644 index 0000000..6c67a97 --- /dev/null +++ b/src/BCards.Web/Controllers/TestToolsController.cs @@ -0,0 +1,170 @@ +#if TESTING +using BCards.Web.Models; +using BCards.Web.Repositories; +using BCards.Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace BCards.Web.Controllers; + +[Authorize] +[ApiController] +[Route("testing/tools")] +public class TestToolsController : ControllerBase +{ + private readonly IUserRepository _userRepository; + private readonly ISubscriptionRepository _subscriptionRepository; + private readonly IUserPageService _userPageService; + private readonly ILivePageService _livePageService; + private readonly IPlanConfigurationService _planConfigurationService; + private readonly IWebHostEnvironment _environment; + + public TestToolsController( + IUserRepository userRepository, + ISubscriptionRepository subscriptionRepository, + IUserPageService userPageService, + ILivePageService livePageService, + IPlanConfigurationService planConfigurationService, + IWebHostEnvironment environment) + { + _userRepository = userRepository; + _subscriptionRepository = subscriptionRepository; + _userPageService = userPageService; + _livePageService = livePageService; + _planConfigurationService = planConfigurationService; + _environment = environment; + } + + [HttpPost("plan")] + public async Task SetPlan([FromBody] SetPlanRequest request) + { + if (!_environment.IsEnvironment("Testing")) + { + return NotFound(); + } + + if (!Enum.TryParse(request.Plan, true, out var planType)) + { + return BadRequest(new { error = $"Plano desconhecido: {request.Plan}" }); + } + + var email = string.IsNullOrWhiteSpace(request.Email) + ? TestUserDefaults.Email + : request.Email!.Trim(); + + var user = await _userRepository.GetByEmailAsync(email); + if (user == null) + { + return NotFound(new { error = $"Usuário de teste não encontrado para o e-mail {email}" }); + } + + var planLimits = _planConfigurationService.GetPlanLimitations(planType); + var normalizedPlan = planLimits.PlanType ?? planType.ToString().ToLowerInvariant(); + + user.CurrentPlan = normalizedPlan; + user.SubscriptionStatus = "active"; + user.UpdatedAt = DateTime.UtcNow; + await _userRepository.UpdateAsync(user); + + var subscription = await _subscriptionRepository.GetByUserIdAsync(user.Id); + if (subscription == null) + { + subscription = new Subscription + { + UserId = user.Id, + StripeSubscriptionId = $"test-{Guid.NewGuid():N}", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + } + + subscription.PlanType = normalizedPlan; + subscription.Status = "active"; + subscription.MaxLinks = planLimits.MaxLinks; + subscription.AllowAnalytics = planLimits.AllowAnalytics; + subscription.AllowCustomThemes = planLimits.AllowCustomThemes; + subscription.AllowCustomDomain = planLimits.AllowCustomDomain; + subscription.AllowMultipleDomains = planLimits.AllowMultipleDomains; + subscription.PrioritySupport = planLimits.PrioritySupport; + subscription.CurrentPeriodStart = DateTime.UtcNow.Date; + subscription.CurrentPeriodEnd = DateTime.UtcNow.Date.AddMonths(1); + subscription.CancelAtPeriodEnd = false; + subscription.UpdatedAt = DateTime.UtcNow; + + if (string.IsNullOrEmpty(subscription.Id)) + { + await _subscriptionRepository.CreateAsync(subscription); + } + else + { + await _subscriptionRepository.UpdateAsync(subscription); + } + + var pages = await _userPageService.GetUserPagesAsync(user.Id); + var originalPagesCount = pages.Count; + var removed = 0; + if (request.ResetPages) + { + foreach (var page in pages) + { + await _livePageService.DeleteByOriginalPageIdAsync(page.Id); + await _userPageService.DeletePageAsync(page.Id); + removed++; + } + + pages = new List(); + } + else + { + foreach (var page in pages) + { + page.PlanLimitations = ClonePlanLimitations(planLimits); + page.UpdatedAt = DateTime.UtcNow; + await _userPageService.UpdatePageAsync(page); + } + } + + return Ok(new + { + email, + plan = normalizedPlan, + pagesAffected = request.ResetPages ? originalPagesCount : pages.Count, + pagesRemoved = removed + }); + } + + private static PlanLimitations ClonePlanLimitations(PlanLimitations source) + { + return new PlanLimitations + { + MaxLinks = source.MaxLinks, + AllowCustomThemes = source.AllowCustomThemes, + AllowAnalytics = source.AllowAnalytics, + AllowCustomDomain = source.AllowCustomDomain, + AllowMultipleDomains = source.AllowMultipleDomains, + PrioritySupport = source.PrioritySupport, + PlanType = source.PlanType, + MaxProductLinks = source.MaxProductLinks, + MaxOGExtractionsPerDay = source.MaxOGExtractionsPerDay, + AllowProductLinks = source.AllowProductLinks, + SpecialModeration = source.SpecialModeration, + OGExtractionsUsedToday = 0, + LastExtractionDate = null + }; + } + + private static class TestUserDefaults + { + public const string Email = "test.user@example.com"; + } + + public class SetPlanRequest + { + public string? Email { get; set; } + + public string Plan { get; set; } = "trial"; + + public bool ResetPages { get; set; } + } +} +#endif diff --git a/src/BCards.Web/Program.cs b/src/BCards.Web/Program.cs index 6c06e93..9ea0f6c 100644 --- a/src/BCards.Web/Program.cs +++ b/src/BCards.Web/Program.cs @@ -18,7 +18,9 @@ using Serilog; using Serilog.Events; using Microsoft.Extensions.Diagnostics.HealthChecks; using Serilog.Sinks.OpenSearch; +#if TESTING using BCards.Web.TestSupport; +#endif using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.DataProtection; @@ -421,6 +423,7 @@ authBuilder.AddGoogle(options => }; }); +#if TESTING // Conditionally set the DefaultChallengeScheme and register the Test scheme if (builder.Environment.IsEnvironment("Testing")) { @@ -437,6 +440,12 @@ else options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme; }); } +#else +authBuilder.Services.Configure(options => +{ + options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme; +}); +#endif // Localization builder.Services.AddLocalization(options => options.ResourcesPath = "Resources"); @@ -471,6 +480,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/BCards.Web/Services/DowngradeService.cs b/src/BCards.Web/Services/DowngradeService.cs new file mode 100644 index 0000000..8e8385f --- /dev/null +++ b/src/BCards.Web/Services/DowngradeService.cs @@ -0,0 +1,225 @@ +using BCards.Web.Models; +using BCards.Web.ViewModels; + +namespace BCards.Web.Services; + +public class DowngradeService : IDowngradeService +{ + private readonly IUserPageService _userPageService; + private readonly ILogger _logger; + + // Ordem de prioridade dos planos (menor = melhor) + private static readonly Dictionary PlanPriority = new() + { + { PlanType.Trial, 0 }, + { PlanType.Basic, 1 }, + { PlanType.Professional, 2 }, + { PlanType.Premium, 3 }, + { PlanType.PremiumAffiliate, 4 } + }; + + public DowngradeService(IUserPageService userPageService, ILogger logger) + { + _userPageService = userPageService; + _logger = logger; + } + + public async Task AnalyzeDowngradeImpact(string userId, PlanType newPlan) + { + var pages = await _userPageService.GetUserPagesAsync(userId); + var activePagesOnly = pages.Where(p => p.Status == PageStatus.Active).ToList(); + + var newMaxPages = newPlan.GetMaxPages(); + var newMaxLinks = newPlan.GetMaxLinksPerPage(); + + var eligiblePages = activePagesOnly + .Where(p => newMaxLinks == -1 || (p.Links?.Count ?? 0) <= newMaxLinks) + .OrderBy(p => p.CreatedAt) // Mais antigas primeiro + .Take(newMaxPages) + .Select(p => new PageInfo + { + Id = p.Id, + DisplayName = p.DisplayName, + LinkCount = p.Links?.Count ?? 0, + CreatedAt = p.CreatedAt + }) + .ToList(); + + var affectedPages = activePagesOnly + .Where(p => !eligiblePages.Any(ep => ep.Id == p.Id)) + .Select(p => new PageInfo + { + Id = p.Id, + DisplayName = p.DisplayName, + LinkCount = p.Links?.Count ?? 0, + CreatedAt = p.CreatedAt, + SuspensionReason = GetSuspensionReason(p, newPlan) + }) + .ToList(); + + var issues = new List(); + + var pagesWithTooManyLinks = activePagesOnly + .Where(p => newMaxLinks != -1 && (p.Links?.Count ?? 0) > newMaxLinks) + .Count(); + + if (pagesWithTooManyLinks > 0) + issues.Add($"{pagesWithTooManyLinks} páginas têm muitos links"); + + if (activePagesOnly.Count > newMaxPages) + issues.Add($"Total de páginas ({activePagesOnly.Count}) excede limite ({newMaxPages})"); + + return new DowngradeAnalysis + { + EligiblePages = eligiblePages, + AffectedPages = affectedPages, + Issues = issues + }; + } + + public async Task ProcessAutomaticDowngrade(string userId, PlanType newPlan) + { + try + { + var analysis = await AnalyzeDowngradeImpact(userId, newPlan); + + // PROTEÇÃO: Se nenhuma página for elegível, cancelar downgrade + if (analysis.IsCritical) + { + throw new InvalidOperationException( + "Downgrade cancelado: nenhuma página atende aos critérios do novo plano. " + + "Edite suas páginas para reduzir links ou escolha um plano superior." + ); + } + + // Processar normalmente - suspender páginas afetadas + foreach (var pageInfo in analysis.AffectedPages) + { + var page = await _userPageService.GetPageByIdAsync(pageInfo.Id); + if (page != null) + { + page.Status = PageStatus.SuspendedByPlanLimit; + var detailedReason = GetDetailedSuspensionReason(page, newPlan); + await _userPageService.UpdatePageAsync(page); + + _logger.LogInformation( + "Página {PageName} (ID: {PageId}) suspensa por downgrade para {PlanType}. Motivo: {Reason}", + page.DisplayName, page.Id, newPlan, detailedReason + ); + } + } + + var result = new DowngradeResult + { + Success = true, + KeptActive = analysis.EligiblePages.Count, + Suspended = analysis.AffectedPages.Count, + Message = $"Downgrade concluído. {analysis.EligiblePages.Count} páginas ativas, {analysis.AffectedPages.Count} suspensas.", + Details = analysis.AffectedPages.Select(p => $"{p.DisplayName}: {p.SuspensionReason}").ToList() + }; + + _logger.LogInformation( + "Downgrade automático concluído para usuário {UserId}: {KeptActive} mantidas, {Suspended} suspensas", + userId, result.KeptActive, result.Suspended + ); + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Erro no processamento automático de downgrade para usuário {UserId}", userId); + return new DowngradeResult + { + Success = false, + Message = $"Erro no processamento: {ex.Message}", + Details = new List { ex.Message } + }; + } + } + + public async Task ProcessAutomaticUpgrade(string userId, PlanType newPlan) + { + try + { + var pages = await _userPageService.GetUserPagesAsync(userId); + var suspendedPages = pages.Where(p => p.Status == PageStatus.SuspendedByPlanLimit).ToList(); + + var newMaxPages = newPlan.GetMaxPages(); + var newMaxLinks = newPlan.GetMaxLinksPerPage(); + + var reactivated = 0; + + // Reativar páginas que agora cabem no novo plano + foreach (var page in suspendedPages.OrderBy(p => p.CreatedAt).Take(newMaxPages)) + { + // Verificar se a página atende aos critérios de links + if (newMaxLinks == -1 || (page.Links?.Count ?? 0) <= newMaxLinks) + { + page.Status = PageStatus.Active; + await _userPageService.UpdatePageAsync(page); + reactivated++; + + _logger.LogInformation( + "Página {PageName} (ID: {PageId}) reativada por upgrade para {PlanType}", + page.DisplayName, page.Id, newPlan + ); + } + } + + return new DowngradeResult + { + Success = true, + KeptActive = reactivated, + Suspended = 0, + Message = $"Upgrade concluído. {reactivated} páginas reativadas.", + Details = suspendedPages.Take(reactivated).Select(p => $"{p.DisplayName}: Reativada").ToList() + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Erro no processamento automático de upgrade para usuário {UserId}", userId); + return new DowngradeResult + { + Success = false, + Message = $"Erro no processamento: {ex.Message}", + Details = new List { ex.Message } + }; + } + } + + public bool IsDowngrade(PlanType oldPlan, PlanType newPlan) + { + return PlanPriority.TryGetValue(oldPlan, out var oldPriority) && + PlanPriority.TryGetValue(newPlan, out var newPriority) && + newPriority < oldPriority; + } + + public bool IsUpgrade(PlanType oldPlan, PlanType newPlan) + { + return PlanPriority.TryGetValue(oldPlan, out var oldPriority) && + PlanPriority.TryGetValue(newPlan, out var newPriority) && + newPriority > oldPriority; + } + + private string GetSuspensionReason(UserPage page, PlanType newPlan) + { + var maxLinks = newPlan.GetMaxLinksPerPage(); + var currentLinks = page.Links?.Count ?? 0; + + if (maxLinks != -1 && currentLinks > maxLinks) + return $"❌ Tem {currentLinks} links (limite: {maxLinks})"; + + return "❌ Excesso de páginas (critério: mais recentes são suspensas)"; + } + + private string GetDetailedSuspensionReason(UserPage page, PlanType newPlan) + { + var maxLinks = newPlan.GetMaxLinksPerPage(); + var currentLinks = page.Links?.Count ?? 0; + + if (maxLinks != -1 && currentLinks > maxLinks) + return $"Página suspensa: possui {currentLinks} links, mas o plano {newPlan.GetDisplayName()} permite apenas {maxLinks} links por página."; + + return $"Página suspensa: plano {newPlan.GetDisplayName()} permite apenas {newPlan.GetMaxPages()} páginas ativas. Páginas mais recentes foram suspensas automaticamente."; + } +} \ No newline at end of file diff --git a/src/BCards.Web/Services/IDowngradeService.cs b/src/BCards.Web/Services/IDowngradeService.cs new file mode 100644 index 0000000..dc5ee35 --- /dev/null +++ b/src/BCards.Web/Services/IDowngradeService.cs @@ -0,0 +1,32 @@ +using BCards.Web.Models; +using BCards.Web.ViewModels; + +namespace BCards.Web.Services; + +public interface IDowngradeService +{ + /// + /// Analisa o impacto de um downgrade para um usuário + /// + Task AnalyzeDowngradeImpact(string userId, PlanType newPlan); + + /// + /// Processa automaticamente um downgrade, suspendendo páginas necessárias + /// + Task ProcessAutomaticDowngrade(string userId, PlanType newPlan); + + /// + /// Reativa páginas suspensas por limite quando há upgrade + /// + Task ProcessAutomaticUpgrade(string userId, PlanType newPlan); + + /// + /// Detecta se uma mudança de plano é um downgrade + /// + bool IsDowngrade(PlanType oldPlan, PlanType newPlan); + + /// + /// Detecta se uma mudança de plano é um upgrade + /// + bool IsUpgrade(PlanType oldPlan, PlanType newPlan); +} \ No newline at end of file diff --git a/src/BCards.Web/TestSupport/TestAuthHandler.cs b/src/BCards.Web/TestSupport/TestAuthHandler.cs index 18975f6..ee4adde 100644 --- a/src/BCards.Web/TestSupport/TestAuthHandler.cs +++ b/src/BCards.Web/TestSupport/TestAuthHandler.cs @@ -1,4 +1,5 @@ +#if TESTING using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; using System.Security.Claims; @@ -82,3 +83,4 @@ namespace BCards.Web.TestSupport } } } +#endif diff --git a/src/BCards.Web/ViewModels/ManagePageViewModel.cs b/src/BCards.Web/ViewModels/ManagePageViewModel.cs index 25c00ac..1cb29c0 100644 --- a/src/BCards.Web/ViewModels/ManagePageViewModel.cs +++ b/src/BCards.Web/ViewModels/ManagePageViewModel.cs @@ -134,10 +134,48 @@ public enum PageStatus { Active, // Funcionando normalmente Expired, // Trial vencido -> 301 redirect - PendingPayment, // Pagamento atrasado -> aviso na página + PendingPayment, // Pagamento atrasado -> aviso na página Inactive, // Pausada pelo usuário PendingModeration = 4, // Aguardando moderação Rejected = 5, // Rejeitada na moderação Creating = 6, // Em desenvolvimento/criação - Approved = 7 // Aprovada + Approved = 7, // Aprovada + SuspendedByPlanLimit = 8 // Suspensa por limite de plano (downgrade) +} + +// Modelos para análise de downgrade +public class DowngradeAnalysis +{ + public List EligiblePages { get; set; } = new(); + public List AffectedPages { get; set; } = new(); + public List Issues { get; set; } = new(); + public bool IsCritical => EligiblePages.Count == 0; + public string Summary => $"{EligiblePages.Count} mantidas, {AffectedPages.Count} suspensas"; +} + +public class PageInfo +{ + public string Id { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public int LinkCount { get; set; } + public DateTime CreatedAt { get; set; } + public string SuspensionReason { get; set; } = string.Empty; +} + +public class DowngradeResult +{ + public bool Success { get; set; } + public int KeptActive { get; set; } + public int Suspended { get; set; } + public string Message { get; set; } = string.Empty; + public List Details { get; set; } = new(); +} + +public class DowngradeCriteria +{ + public int MaxPages { get; set; } + public int MaxLinksPerPage { get; set; } + public string MaxLinksDisplay => MaxLinksPerPage == -1 ? "Ilimitado" : MaxLinksPerPage.ToString(); + public string SelectionCriteria { get; set; } = "Páginas mais antigas têm prioridade"; + public string LinksCriteria { get; set; } = "Páginas com muitos links são automaticamente suspensas"; } \ No newline at end of file diff --git a/src/BCards.Web/Views/Admin/ManagePage.cshtml b/src/BCards.Web/Views/Admin/ManagePage.cshtml index 49af073..dade940 100644 --- a/src/BCards.Web/Views/Admin/ManagePage.cshtml +++ b/src/BCards.Web/Views/Admin/ManagePage.cshtml @@ -280,6 +280,7 @@ +
From c32b4ef0344b50c3a2720e07f8215591c2affb74 Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Sat, 25 Oct 2025 22:35:27 -0300 Subject: [PATCH 2/2] feat: novas redes sociais e qrcodes. --- .claude/settings.local.json | 5 +- src/BCards.Web/Controllers/AdminController.cs | 166 +++++++++++- .../ViewModels/CreatePageViewModel.cs | 8 + .../ViewModels/ManagePageViewModel.cs | 8 + src/BCards.Web/Views/Admin/ManagePage.cshtml | 134 +++++++++- src/BCards.Web/Views/UserPage/Display.cshtml | 244 +++++++++++++++++- 6 files changed, 551 insertions(+), 14 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 49a4a0f..95948cd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -27,8 +27,9 @@ "Bash(./clean-build.sh:*)", "Bash(git add:*)", "Bash(scp:*)", - "Bash(ssh:*)" + "Bash(ssh:*)", + "Bash(cat:*)" ] }, "enableAllProjectMcpServers": false -} \ No newline at end of file +} diff --git a/src/BCards.Web/Controllers/AdminController.cs b/src/BCards.Web/Controllers/AdminController.cs index 1889d66..dd6182b 100644 --- a/src/BCards.Web/Controllers/AdminController.cs +++ b/src/BCards.Web/Controllers/AdminController.cs @@ -551,6 +551,54 @@ public class AdminController : Controller }); } + if (!string.IsNullOrEmpty(model.TiktokUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "TikTok", + Url = model.TiktokUrl, + Icon = "fab fa-tiktok", + IsActive = true, + Order = userPage.Links.Count + socialLinks.Count + }); + } + + if (!string.IsNullOrEmpty(model.PinterestUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "Pinterest", + Url = model.PinterestUrl, + Icon = "fab fa-pinterest", + IsActive = true, + Order = userPage.Links.Count + socialLinks.Count + }); + } + + if (!string.IsNullOrEmpty(model.DiscordUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "Discord", + Url = model.DiscordUrl, + Icon = "fab fa-discord", + IsActive = true, + Order = userPage.Links.Count + socialLinks.Count + }); + } + + if (!string.IsNullOrEmpty(model.KawaiUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "Kawai", + Url = model.KawaiUrl, + Icon = "fas fa-heart", + IsActive = true, + Order = userPage.Links.Count + socialLinks.Count + }); + } + userPage.Links.AddRange(socialLinks); await _userPageService.CreatePageAsync(userPage); @@ -822,6 +870,54 @@ public class AdminController : Controller }); } + if (!string.IsNullOrEmpty(model.TiktokUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "TikTok", + Url = model.TiktokUrl, + Icon = "fab fa-tiktok", + IsActive = true, + Order = currentOrder++ + }); + } + + if (!string.IsNullOrEmpty(model.PinterestUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "Pinterest", + Url = model.PinterestUrl, + Icon = "fab fa-pinterest", + IsActive = true, + Order = currentOrder++ + }); + } + + if (!string.IsNullOrEmpty(model.DiscordUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "Discord", + Url = model.DiscordUrl, + Icon = "fab fa-discord", + IsActive = true, + Order = currentOrder++ + }); + } + + if (!string.IsNullOrEmpty(model.KawaiUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "Kawai", + Url = model.KawaiUrl, + Icon = "fas fa-heart", + IsActive = true, + Order = currentOrder++ + }); + } + userPage.Links.AddRange(socialLinks); return userPage; } @@ -932,6 +1028,54 @@ public class AdminController : Controller }); } + if (!string.IsNullOrEmpty(model.TiktokUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "TikTok", + Url = model.TiktokUrl, + Icon = "fab fa-tiktok", + IsActive = true, + Order = currentOrder++ + }); + } + + if (!string.IsNullOrEmpty(model.PinterestUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "Pinterest", + Url = model.PinterestUrl, + Icon = "fab fa-pinterest", + IsActive = true, + Order = currentOrder++ + }); + } + + if (!string.IsNullOrEmpty(model.DiscordUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "Discord", + Url = model.DiscordUrl, + Icon = "fab fa-discord", + IsActive = true, + Order = currentOrder++ + }); + } + + if (!string.IsNullOrEmpty(model.KawaiUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "Kawai", + Url = model.KawaiUrl, + Icon = "fas fa-heart", + IsActive = true, + Order = currentOrder++ + }); + } + page.Links.AddRange(socialLinks); } @@ -1159,15 +1303,27 @@ public class AdminController : Controller // Tratar espaço em branco como campo vazio para redes sociais if (string.IsNullOrWhiteSpace(model.WhatsAppNumber) || model.WhatsAppNumber.Trim().Length <= 1) model.WhatsAppNumber = string.Empty; - + if (string.IsNullOrWhiteSpace(model.FacebookUrl) || model.FacebookUrl.Trim().Length <= 1) model.FacebookUrl = string.Empty; - + if (string.IsNullOrWhiteSpace(model.InstagramUrl) || model.InstagramUrl.Trim().Length <= 1) model.InstagramUrl = string.Empty; - + if (string.IsNullOrWhiteSpace(model.TwitterUrl) || model.TwitterUrl.Trim().Length <= 1) model.TwitterUrl = string.Empty; + + if (string.IsNullOrWhiteSpace(model.TiktokUrl) || model.TiktokUrl.Trim().Length <= 1) + model.TiktokUrl = string.Empty; + + if (string.IsNullOrWhiteSpace(model.PinterestUrl) || model.PinterestUrl.Trim().Length <= 1) + model.PinterestUrl = string.Empty; + + if (string.IsNullOrWhiteSpace(model.DiscordUrl) || model.DiscordUrl.Trim().Length <= 1) + model.DiscordUrl = string.Empty; + + if (string.IsNullOrWhiteSpace(model.KawaiUrl) || model.KawaiUrl.Trim().Length <= 1) + model.KawaiUrl = string.Empty; } // Endpoint para validar impacto de downgrade @@ -1246,6 +1402,10 @@ public class AdminController : Controller modelState.Remove(x => x.FacebookUrl); modelState.Remove(x => x.TwitterUrl); modelState.Remove(x => x.WhatsAppNumber); + modelState.Remove(x => x.TiktokUrl); + modelState.Remove(x => x.PinterestUrl); + modelState.Remove(x => x.DiscordUrl); + modelState.Remove(x => x.KawaiUrl); // Remover validação de 'Description' para links do tipo 'Normal' if (model.Links != null) diff --git a/src/BCards.Web/ViewModels/CreatePageViewModel.cs b/src/BCards.Web/ViewModels/CreatePageViewModel.cs index 968c767..183a273 100644 --- a/src/BCards.Web/ViewModels/CreatePageViewModel.cs +++ b/src/BCards.Web/ViewModels/CreatePageViewModel.cs @@ -28,6 +28,14 @@ public class CreatePageViewModel public string InstagramUrl { get; set; } = string.Empty; + public string TiktokUrl { get; set; } = string.Empty; + + public string PinterestUrl { get; set; } = string.Empty; + + public string DiscordUrl { get; set; } = string.Empty; + + public string KawaiUrl { get; set; } = string.Empty; + public List Links { get; set; } = new(); public string Slug { get; set; } = string.Empty; diff --git a/src/BCards.Web/ViewModels/ManagePageViewModel.cs b/src/BCards.Web/ViewModels/ManagePageViewModel.cs index 1cb29c0..5e4188f 100644 --- a/src/BCards.Web/ViewModels/ManagePageViewModel.cs +++ b/src/BCards.Web/ViewModels/ManagePageViewModel.cs @@ -34,6 +34,14 @@ public class ManagePageViewModel public string InstagramUrl { get; set; } = string.Empty; + public string TiktokUrl { get; set; } = string.Empty; + + public string PinterestUrl { get; set; } = string.Empty; + + public string DiscordUrl { get; set; } = string.Empty; + + public string KawaiUrl { get; set; } = string.Empty; + public List Links { get; set; } = new(); // Profile image fields diff --git a/src/BCards.Web/Views/Admin/ManagePage.cshtml b/src/BCards.Web/Views/Admin/ManagePage.cshtml index dade940..29f2690 100644 --- a/src/BCards.Web/Views/Admin/ManagePage.cshtml +++ b/src/BCards.Web/Views/Admin/ManagePage.cshtml @@ -249,7 +249,11 @@ "facebook", "whatsapp", "twitter", - "instagram" + "instagram", + "tiktok", + "pinterest", + "discord", + "kawai" }; var match = myList.FirstOrDefault(stringToCheck => !string.IsNullOrEmpty(Model.Links[i].Icon) && @@ -382,10 +386,18 @@ var twitter = Model.Links.Where(x => !string.IsNullOrEmpty(x.Icon) && x.Icon.Contains("twitter")).FirstOrDefault(); var whatsapp = Model.Links.Where(x => !string.IsNullOrEmpty(x.Icon) && x.Icon.Contains("whatsapp")).FirstOrDefault(); var instagram = Model.Links.Where(x => !string.IsNullOrEmpty(x.Icon) && x.Icon.Contains("instagram")).FirstOrDefault(); + var tiktok = Model.Links.Where(x => !string.IsNullOrEmpty(x.Icon) && x.Icon.Contains("tiktok")).FirstOrDefault(); + var pinterest = Model.Links.Where(x => !string.IsNullOrEmpty(x.Icon) && x.Icon.Contains("pinterest")).FirstOrDefault(); + var discord = Model.Links.Where(x => !string.IsNullOrEmpty(x.Icon) && x.Icon.Contains("discord")).FirstOrDefault(); + var kawai = Model.Links.Where(x => !string.IsNullOrEmpty(x.Icon) && x.Icon.Contains("kawai")).FirstOrDefault(); var facebookUrl = facebook !=null ? facebook.Url.Replace("https://facebook.com/","").Replace("https://www.facebook.com/","").Replace("https://fb.com/","") : ""; var twitterUrl = twitter !=null ? twitter.Url.Replace("https://x.com/","").Replace("https://twitter.com/","").Replace("https://www.twitter.com/","") : ""; var whatsappUrl = whatsapp !=null ? whatsapp.Url.Replace("https://wa.me/","").Replace("whatsapp://","") : ""; var instagramUrl = instagram !=null ? instagram.Url.Replace("https://instagram.com/","").Replace("https://www.instagram.com/","") : ""; + var tiktokUrl = tiktok !=null ? tiktok.Url.Replace("https://tiktok.com/@@","").Replace("https://www.tiktok.com/@@","").Replace("https://vm.tiktok.com/","") : ""; + var pinterestUrl = pinterest !=null ? pinterest.Url.Replace("https://pinterest.com/","").Replace("https://www.pinterest.com/","").Replace("https://pin.it/","") : ""; + var discordUrl = discord !=null ? discord.Url.Replace("https://discord.gg/","").Replace("https://discord.com/invite/","") : ""; + var kawaiUrl = kawai !=null ? kawai.Url.Replace("https://kawai.com/","").Replace("https://www.kawai.com/","") : ""; }
@@ -490,7 +502,93 @@
- + +
+
+ +
+
+ + +
+ + + +
+ + +
+
+ + +
+ + + +
+
+ +
+ +
+
+ + +
+ + + +
+ + +
+
+ + +
+ + + +
+
+
+
+ +
+ + +
+
+

Escaneie para compartilhar esta página

+ +
+
+