From 230c6a958db5f65bf941841f3ab15dc5f45d1692 Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Mon, 3 Nov 2025 00:04:16 -0300 Subject: [PATCH] feat: upload de PDF --- README.md | 2 +- src/BCards.Web/Controllers/AdminController.cs | 281 ++++++++++++++- .../Controllers/DocumentController.cs | 64 ++++ .../Controllers/TestToolsController.cs | 4 +- .../Middleware/PlanLimitationMiddleware.cs | 30 +- src/BCards.Web/Models/IPageDisplay.cs | 13 +- src/BCards.Web/Models/LivePage.cs | 9 +- src/BCards.Web/Models/PageDocument.cs | 30 ++ src/BCards.Web/Models/PlanLimitations.cs | 30 +- src/BCards.Web/Models/PlanType.cs | 22 +- src/BCards.Web/Models/UserPage.cs | 9 +- src/BCards.Web/Program.cs | 10 +- .../Services/GridFSDocumentStorage.cs | 93 +++++ .../Services/IDocumentStorageService.cs | 9 + src/BCards.Web/Services/LivePageService.cs | 7 +- .../Services/PlanConfigurationService.cs | 136 +++---- .../ViewModels/ManagePageViewModel.cs | 28 +- src/BCards.Web/Views/Admin/ManagePage.cshtml | 335 +++++++++++++++++- src/BCards.Web/Views/Home/Pricing.cshtml | 42 ++- src/BCards.Web/Views/UserPage/Display.cshtml | 81 ++++- src/BCards.Web/appsettings.json | 218 ++++++------ 21 files changed, 1196 insertions(+), 257 deletions(-) create mode 100644 src/BCards.Web/Controllers/DocumentController.cs create mode 100644 src/BCards.Web/Models/PageDocument.cs create mode 100644 src/BCards.Web/Services/GridFSDocumentStorage.cs create mode 100644 src/BCards.Web/Services/IDocumentStorageService.cs diff --git a/README.md b/README.md index 02d2560..aaf37d1 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Um clone profissional do LinkTree desenvolvido em ASP.NET Core MVC, focado no me ### 🎯 Planos e Pricing (Estratégia Decoy) - **Básico** (R$ 12,90/mês): 5 links, temas básicos, analytics simples - **Profissional** (R$ 25,90/mês): 15 links, todos os temas, analytics avançado, domínio personalizado *(DECOY)* -- **Premium** (R$ 29,90/mês): Links ilimitados, temas customizáveis, analytics completo, múltiplos domínios +- **Premium** (R$ 29,90/mês): Links ilimitados, temas customizáveis, analytics completo, múltiplos domínios, upload de PDFs ## 🛠️ Tecnologias diff --git a/src/BCards.Web/Controllers/AdminController.cs b/src/BCards.Web/Controllers/AdminController.cs index dd6182b..11baa2a 100644 --- a/src/BCards.Web/Controllers/AdminController.cs +++ b/src/BCards.Web/Controllers/AdminController.cs @@ -5,9 +5,13 @@ using BCards.Web.ViewModels; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Configuration; using System.Security.Claims; using System.Text; using System.Text.Json; +using System.Linq; +using MongoDB.Bson; +using System.Collections.Generic; namespace BCards.Web.Controllers; @@ -23,9 +27,11 @@ public class AdminController : Controller private readonly IEmailService _emailService; private readonly ILivePageService _livePageService; private readonly IImageStorageService _imageStorage; + private readonly IDocumentStorageService _documentStorage; private readonly IPaymentService _paymentService; private readonly IDowngradeService _downgradeService; private readonly ILogger _logger; + private readonly IConfiguration _configuration; public AdminController( IAuthService authService, @@ -36,9 +42,11 @@ public class AdminController : Controller IEmailService emailService, ILivePageService livePageService, IImageStorageService imageStorage, + IDocumentStorageService documentStorage, IPaymentService paymentService, IDowngradeService downgradeService, - ILogger logger) + ILogger logger, + IConfiguration configuration) { _authService = authService; _userPageService = userPageService; @@ -48,9 +56,11 @@ public class AdminController : Controller _emailService = emailService; _livePageService = livePageService; _imageStorage = imageStorage; + _documentStorage = documentStorage; _paymentService = paymentService; _downgradeService = downgradeService; _logger = logger; + _configuration = configuration; } @@ -175,7 +185,11 @@ public class AdminController : Controller AvailableCategories = categories, AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(), MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(), - AllowProductLinks = planLimitations.AllowProductLinks + AllowProductLinks = planLimitations.AllowProductLinks, + AllowDocumentUpload = planLimitations.AllowDocumentUpload, + MaxDocumentsAllowed = planLimitations.MaxDocuments, + Documents = new List(), + DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay() }; return View(model); } @@ -214,6 +228,12 @@ public class AdminController : Controller return RedirectToAction("Login", "Auth"); userId = user.Id; + var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var parsedPlan) ? parsedPlan : PlanType.Trial; + var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString()); + model.AllowProductLinks = planLimitations.AllowProductLinks; + model.AllowDocumentUpload = planLimitations.AllowDocumentUpload; + model.MaxDocumentsAllowed = planLimitations.MaxDocuments; + model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay(); // Limpar campos de redes sociais que são apenas espaços (tratados como vazios) CleanSocialMediaFields(model); @@ -255,13 +275,13 @@ public class AdminController : Controller TempData["ImageError"] = errorMessage; // Preservar dados do form e repopular dropdowns - var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial; - var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString()); - model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); model.MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(); model.AllowProductLinks = planLimitations.AllowProductLinks; + model.AllowDocumentUpload = planLimitations.AllowDocumentUpload; + model.MaxDocumentsAllowed = planLimitations.MaxDocuments; + model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay(); // Preservar ProfileImageId existente se estava editando if (!model.IsNewPage && !string.IsNullOrEmpty(model.Id)) @@ -277,6 +297,8 @@ public class AdminController : Controller } } + var (processedDocuments, removedFileIds, newFileIds) = await BuildDocumentsAsync(model, planLimitations); + if (!ModelState.IsValid) { var sbError = new StringBuilder(); @@ -297,6 +319,10 @@ public class AdminController : Controller model.Slug = slug; model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); + model.AllowProductLinks = planLimitations.AllowProductLinks; + model.AllowDocumentUpload = planLimitations.AllowDocumentUpload; + model.MaxDocumentsAllowed = planLimitations.MaxDocuments; + model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay(); return View(model); } @@ -304,7 +330,6 @@ public class AdminController : Controller { // 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) @@ -312,6 +337,7 @@ public class AdminController : Controller 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(); + model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay(); return View(model); } @@ -327,6 +353,7 @@ public class AdminController : Controller ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra."); model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); + model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay(); return View(model); } @@ -337,6 +364,7 @@ public class AdminController : Controller ModelState.AddModelError("", $"Você excedeu o limite de {model.MaxLinksAllowed} links do seu plano atual."); model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); + model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay(); return View(model); } @@ -344,6 +372,7 @@ public class AdminController : Controller { // Create new page var userPage = await MapToUserPage(model, user.Id); + userPage.Documents = processedDocuments; _logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}"); // Set status to Creating for new pages @@ -362,6 +391,7 @@ public class AdminController : Controller ModelState.AddModelError("", "Erro ao criar página. Tente novamente."); model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); + model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay(); TempData["Error"] = $"Erro ao criar página. Tente novamente. TechMsg: {ex.Message}"; return View(model); } @@ -371,7 +401,15 @@ public class AdminController : Controller // Update existing page var existingPage = await _userPageService.GetPageByIdAsync(model.Id); if (existingPage == null || existingPage.UserId != user.Id) + { + await CleanupNewDocumentsAsync(newFileIds); return NotFound(); + } + + if (!planLimitations.AllowDocumentUpload && existingPage.Documents?.Any() == true) + { + removedFileIds.AddRange(existingPage.Documents.Select(d => d.FileId)); + } // Check if user can create pages (for users with rejected pages) var canCreatePage = await _moderationService.CanUserCreatePageAsync(user.Id); @@ -397,7 +435,7 @@ public class AdminController : Controller } } - await UpdateUserPageFromModel(existingPage, model); + await UpdateUserPageFromModel(existingPage, model, processedDocuments); // Set status to PendingModeration for updates existingPage.Status = ViewModels.PageStatus.Creating; @@ -405,6 +443,21 @@ public class AdminController : Controller await _userPageService.UpdatePageAsync(existingPage); + if (removedFileIds.Count > 0) + { + foreach (var fileId in removedFileIds) + { + try + { + await _documentStorage.DeleteDocumentAsync(fileId); + } + catch (Exception cleanupEx) + { + _logger.LogWarning(cleanupEx, "Erro ao remover documento antigo {FileId}", fileId); + } + } + } + // Token será gerado apenas quando usuário clicar "Testar Página" // Send email to user @@ -745,6 +798,8 @@ public class AdminController : Controller private async Task MapToManageViewModel(UserPage page, List categories, List themes, PlanType userPlanType) { + var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString()); + return new ManagePageViewModel { Id = page.Id, @@ -772,10 +827,23 @@ public class AdminController : Controller ProductDescription = l.ProductDescription, ProductDataCachedAt = l.ProductDataCachedAt }).ToList() ?? new List(), + Documents = page.Documents?.Select((d, index) => new ManageDocumentViewModel + { + Id = string.IsNullOrEmpty(d.Id) ? $"doc_{index}" : d.Id, + DocumentId = d.FileId, + Title = d.Title, + Description = d.Description, + FileName = d.FileName, + FileSize = d.FileSize, + UploadedAt = d.UploadedAt + }).ToList() ?? new List(), AvailableCategories = categories, AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(), MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(), - AllowProductLinks = (await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString())).AllowProductLinks + AllowProductLinks = planLimitations.AllowProductLinks, + AllowDocumentUpload = planLimitations.AllowDocumentUpload, + MaxDocumentsAllowed = planLimitations.MaxDocuments, + DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay() }; } @@ -922,7 +990,198 @@ public class AdminController : Controller return userPage; } - private async Task UpdateUserPageFromModel(UserPage page, ManagePageViewModel model) + private string GetDocumentUploadPlansDisplay() + { + var sections = _configuration.GetSection("Plans").GetChildren(); + + var friendlyNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var section in sections) + { + if (!bool.TryParse(section["AllowDocumentUpload"], out var allow) || !allow) + continue; + + var key = section.Key ?? string.Empty; + var normalizedKey = key.EndsWith("Yearly", StringComparison.OrdinalIgnoreCase) + ? key[..^6] + : key; + + if (Enum.TryParse(normalizedKey, true, out var planType)) + { + friendlyNames.Add(planType.GetDisplayName()); + } + else + { + var display = section["Name"] ?? key; + if (!string.IsNullOrWhiteSpace(display)) + { + friendlyNames.Add(display); + } + } + } + + if (friendlyNames.Count == 0) + return "planos com suporte a documentos"; + + var orderedNames = friendlyNames.OrderBy(name => name).ToList(); + + return orderedNames.Count switch + { + 1 => orderedNames[0], + 2 => string.Join(" e ", orderedNames), + _ => $"{string.Join(", ", orderedNames.Take(orderedNames.Count - 1))} e {orderedNames.Last()}" + }; + } + + private async Task<(List Documents, List RemovedFileIds, List NewlyUploadedFileIds)> BuildDocumentsAsync(ManagePageViewModel model, PlanLimitations planLimitations) + { + var documents = new List(); + var removedFileIds = new List(); + var newFileIds = new List(); + + if (!planLimitations.AllowDocumentUpload) + { + return (documents, removedFileIds, newFileIds); + } + + if (model.Documents == null || model.Documents.Count == 0) + return (documents, removedFileIds, newFileIds); + + for (int index = 0; index < model.Documents.Count; index++) + { + var docVm = model.Documents[index]; + if (docVm == null) + continue; + + var hasExisting = !string.IsNullOrEmpty(docVm.DocumentId); + + if (docVm.MarkForRemoval) + { + if (hasExisting && docVm.DocumentId != null) + removedFileIds.Add(docVm.DocumentId); + continue; + } + + if (string.IsNullOrWhiteSpace(docVm.Title)) + { + ModelState.AddModelError($"Documents[{index}].Title", "Título é obrigatório"); + continue; + } + + string fileId = docVm.DocumentId?.Trim() ?? string.Empty; + string fileName = docVm.FileName?.Trim() ?? string.Empty; + long fileSize = docVm.FileSize; + var uploadedAt = docVm.UploadedAt ?? DateTime.UtcNow; + + if (hasExisting && string.IsNullOrEmpty(docVm.Id)) + { + docVm.Id = ObjectId.GenerateNewId().ToString(); + } + + if (docVm.DocumentFile != null && docVm.DocumentFile.Length > 0) + { + if (!docVm.DocumentFile.ContentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) + { + ModelState.AddModelError($"Documents[{index}].DocumentFile", "Envie um arquivo em PDF."); + continue; + } + + if (docVm.DocumentFile.Length > 10 * 1024 * 1024) + { + ModelState.AddModelError($"Documents[{index}].DocumentFile", "Arquivo muito grande. Tamanho máximo: 10MB."); + continue; + } + + using var memoryStream = new MemoryStream(); + await docVm.DocumentFile.CopyToAsync(memoryStream); + var documentBytes = memoryStream.ToArray(); + + try + { + fileId = await _documentStorage.SaveDocumentAsync(documentBytes, docVm.DocumentFile.FileName, docVm.DocumentFile.ContentType); + fileName = docVm.DocumentFile.FileName; + fileSize = docVm.DocumentFile.Length; + uploadedAt = DateTime.UtcNow; + newFileIds.Add(fileId); + + if (hasExisting && docVm.DocumentId != null) + removedFileIds.Add(docVm.DocumentId); + } + catch (ArgumentException ex) + { + ModelState.AddModelError($"Documents[{index}].DocumentFile", ex.Message); + continue; + } + catch (Exception ex) + { + _logger.LogError(ex, "Erro ao salvar PDF do usuário"); + ModelState.AddModelError($"Documents[{index}].DocumentFile", "Erro ao salvar o PDF. Tente novamente."); + continue; + } + } + else if (!hasExisting) + { + // Nenhum arquivo associado: ignorar + continue; + } + + if (string.IsNullOrEmpty(fileId)) + { + ModelState.AddModelError($"Documents[{index}].DocumentFile", "Falha ao processar o documento."); + continue; + } + + documents.Add(new PageDocument + { + Id = string.IsNullOrEmpty(docVm.Id) || !ObjectId.TryParse(docVm.Id, out _) ? ObjectId.GenerateNewId().ToString() : docVm.Id, + FileId = fileId, + Title = docVm.Title, + Description = docVm.Description, + FileName = fileName, + FileSize = fileSize, + UploadedAt = uploadedAt + }); + + docVm.DocumentId = fileId; + docVm.FileName = fileName; + docVm.FileSize = fileSize; + docVm.UploadedAt = uploadedAt; + if (string.IsNullOrEmpty(docVm.Id) || !ObjectId.TryParse(docVm.Id, out _)) + { + docVm.Id = documents.Last().Id; + } + } + + if (planLimitations.MaxDocuments > 0 && documents.Count > planLimitations.MaxDocuments) + { + ModelState.AddModelError("Documents", $"Você pode enviar no máximo {planLimitations.MaxDocuments} documento(s) com seu plano."); + } + + return (documents, removedFileIds, newFileIds); + } + + private async Task CleanupNewDocumentsAsync(IEnumerable documentIds) + { + if (documentIds == null) + return; + + foreach (var docId in documentIds) + { + if (string.IsNullOrEmpty(docId)) + continue; + + try + { + await _documentStorage.DeleteDocumentAsync(docId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Erro ao limpar documento temporário {DocumentId}", docId); + } + } + } + + private async Task UpdateUserPageFromModel(UserPage page, ManagePageViewModel model, List documents) { page.DisplayName = model.DisplayName; page.Category = model.Category; @@ -1077,6 +1336,8 @@ public class AdminController : Controller } page.Links.AddRange(socialLinks); + + page.Documents = documents ?? new List(); } [HttpPost] @@ -1430,4 +1691,4 @@ public class AdminController : Controller } } } -} \ No newline at end of file +} diff --git a/src/BCards.Web/Controllers/DocumentController.cs b/src/BCards.Web/Controllers/DocumentController.cs new file mode 100644 index 0000000..3ffa2db --- /dev/null +++ b/src/BCards.Web/Controllers/DocumentController.cs @@ -0,0 +1,64 @@ +using BCards.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace BCards.Web.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class DocumentController : ControllerBase +{ + private readonly IDocumentStorageService _documentStorage; + private readonly ILogger _logger; + + public DocumentController(IDocumentStorageService documentStorage, ILogger logger) + { + _documentStorage = documentStorage; + _logger = logger; + } + + [HttpGet("{documentId}")] + [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)] + public async Task GetDocument(string documentId) + { + try + { + if (string.IsNullOrEmpty(documentId)) + return BadRequest("Documento inválido."); + + var documentBytes = await _documentStorage.GetDocumentAsync(documentId); + if (documentBytes == null) + return NotFound(); + + Response.Headers["Cache-Control"] = "public, max-age=31536000"; + Response.Headers["Expires"] = DateTime.UtcNow.AddYears(1).ToString("R"); + Response.Headers["ETag"] = $"\"{documentId}\""; + + return File(documentBytes, "application/pdf"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Erro ao obter documento {DocumentId}", documentId); + return NotFound(); + } + } + + [HttpDelete("{documentId}")] + public async Task DeleteDocument(string documentId) + { + if (string.IsNullOrEmpty(documentId)) + return BadRequest(); + + var deleted = await _documentStorage.DeleteDocumentAsync(documentId); + return deleted ? Ok(new { success = true }) : NotFound(); + } + + [HttpHead("{documentId}")] + public async Task DocumentExists(string documentId) + { + if (string.IsNullOrEmpty(documentId)) + return BadRequest(); + + var exists = await _documentStorage.DocumentExistsAsync(documentId); + return exists ? Ok() : NotFound(); + } +} diff --git a/src/BCards.Web/Controllers/TestToolsController.cs b/src/BCards.Web/Controllers/TestToolsController.cs index 6c67a97..ce1133a 100644 --- a/src/BCards.Web/Controllers/TestToolsController.cs +++ b/src/BCards.Web/Controllers/TestToolsController.cs @@ -149,7 +149,9 @@ public class TestToolsController : ControllerBase AllowProductLinks = source.AllowProductLinks, SpecialModeration = source.SpecialModeration, OGExtractionsUsedToday = 0, - LastExtractionDate = null + LastExtractionDate = null, + AllowDocumentUpload = source.AllowDocumentUpload, + MaxDocuments = source.MaxDocuments }; } diff --git a/src/BCards.Web/Middleware/PlanLimitationMiddleware.cs b/src/BCards.Web/Middleware/PlanLimitationMiddleware.cs index 4bad512..b4e2164 100644 --- a/src/BCards.Web/Middleware/PlanLimitationMiddleware.cs +++ b/src/BCards.Web/Middleware/PlanLimitationMiddleware.cs @@ -90,7 +90,9 @@ public class PlanLimitationMiddleware AllowCustomDomain = false, AllowMultipleDomains = false, PrioritySupport = false, - PlanType = "basic" + PlanType = "basic", + AllowDocumentUpload = false, + MaxDocuments = 0 }, "professional" => new Models.PlanLimitations { @@ -100,7 +102,9 @@ public class PlanLimitationMiddleware AllowCustomDomain = true, AllowMultipleDomains = false, PrioritySupport = false, - PlanType = "professional" + PlanType = "professional", + AllowDocumentUpload = false, + MaxDocuments = 0 }, "premium" => new Models.PlanLimitations { @@ -110,7 +114,21 @@ public class PlanLimitationMiddleware AllowCustomDomain = true, AllowMultipleDomains = true, PrioritySupport = true, - PlanType = "premium" + PlanType = "premium", + AllowDocumentUpload = true, + MaxDocuments = 5 + }, + "premiumaffiliate" => new Models.PlanLimitations + { + MaxLinks = -1, + AllowCustomThemes = true, + AllowAnalytics = true, + AllowCustomDomain = true, + AllowMultipleDomains = true, + PrioritySupport = true, + PlanType = "premiumaffiliate", + AllowDocumentUpload = true, + MaxDocuments = 10 }, _ => new Models.PlanLimitations { @@ -120,8 +138,10 @@ public class PlanLimitationMiddleware AllowCustomDomain = false, AllowMultipleDomains = false, PrioritySupport = false, - PlanType = "free" + PlanType = "free", + AllowDocumentUpload = false, + MaxDocuments = 0 } }; } -} \ No newline at end of file +} diff --git a/src/BCards.Web/Models/IPageDisplay.cs b/src/BCards.Web/Models/IPageDisplay.cs index b0f2f0d..98d28dc 100644 --- a/src/BCards.Web/Models/IPageDisplay.cs +++ b/src/BCards.Web/Models/IPageDisplay.cs @@ -12,12 +12,13 @@ string Slug { get; } string DisplayName { get; } string Bio { get; } - string? ProfileImageId { get; } - string BusinessType { get; } - PageTheme Theme { get; } - List Links { get; } - SeoSettings SeoSettings { get; } - string Language { get; } + string? ProfileImageId { get; } + string BusinessType { get; } + PageTheme Theme { get; } + List Links { get; } + List Documents { get; } + SeoSettings SeoSettings { get; } + string Language { get; } DateTime CreatedAt { get; } // Propriedades calculadas comuns diff --git a/src/BCards.Web/Models/LivePage.cs b/src/BCards.Web/Models/LivePage.cs index e8ce664..996c644 100644 --- a/src/BCards.Web/Models/LivePage.cs +++ b/src/BCards.Web/Models/LivePage.cs @@ -44,8 +44,11 @@ public class LivePage : IPageDisplay [BsonElement("theme")] public PageTheme Theme { get; set; } = new(); - [BsonElement("links")] - public List Links { get; set; } = new(); + [BsonElement("links")] + public List Links { get; set; } = new(); + + [BsonElement("documents")] + public List Documents { get; set; } = new(); [BsonElement("seoSettings")] public SeoSettings SeoSettings { get; set; } = new(); @@ -86,4 +89,4 @@ public class LivePageAnalytics [BsonElement("lastViewedAt")] public DateTime? LastViewedAt { get; set; } -} \ No newline at end of file +} diff --git a/src/BCards.Web/Models/PageDocument.cs b/src/BCards.Web/Models/PageDocument.cs new file mode 100644 index 0000000..4ee3c51 --- /dev/null +++ b/src/BCards.Web/Models/PageDocument.cs @@ -0,0 +1,30 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace BCards.Web.Models; + +public class PageDocument +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = string.Empty; + + [BsonElement("fileId")] + [BsonRepresentation(BsonType.ObjectId)] + public string FileId { get; set; } = string.Empty; + + [BsonElement("title")] + public string Title { get; set; } = string.Empty; + + [BsonElement("description")] + public string Description { get; set; } = string.Empty; + + [BsonElement("fileName")] + public string FileName { get; set; } = string.Empty; + + [BsonElement("fileSize")] + public long FileSize { get; set; } + + [BsonElement("uploadedAt")] + public DateTime UploadedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/BCards.Web/Models/PlanLimitations.cs b/src/BCards.Web/Models/PlanLimitations.cs index 6e00392..8f655ab 100644 --- a/src/BCards.Web/Models/PlanLimitations.cs +++ b/src/BCards.Web/Models/PlanLimitations.cs @@ -32,15 +32,21 @@ public class PlanLimitations [BsonElement("maxOGExtractionsPerDay")] public int MaxOGExtractionsPerDay { get; set; } = 0; - [BsonElement("allowProductLinks")] - public bool AllowProductLinks { get; set; } = false; - - [BsonElement("specialModeration")] - public bool? SpecialModeration { get; set; } = false; - - [BsonElement("ogExtractionsUsedToday")] - public int OGExtractionsUsedToday { get; set; } = 0; - - [BsonElement("lastExtractionDate")] - public DateTime? LastExtractionDate { get; set; } -} \ No newline at end of file + [BsonElement("allowProductLinks")] + public bool AllowProductLinks { get; set; } = false; + + [BsonElement("specialModeration")] + public bool? SpecialModeration { get; set; } = false; + + [BsonElement("ogExtractionsUsedToday")] + public int OGExtractionsUsedToday { get; set; } = 0; + + [BsonElement("lastExtractionDate")] + public DateTime? LastExtractionDate { get; set; } + + [BsonElement("allowDocumentUpload")] + public bool AllowDocumentUpload { get; set; } = false; + + [BsonElement("maxDocuments")] + public int MaxDocuments { get; set; } = 0; +} diff --git a/src/BCards.Web/Models/PlanType.cs b/src/BCards.Web/Models/PlanType.cs index 8e1b369..ae139fd 100644 --- a/src/BCards.Web/Models/PlanType.cs +++ b/src/BCards.Web/Models/PlanType.cs @@ -28,16 +28,16 @@ public static class PlanTypeExtensions // Este método mantém valores fallback para compatibilidade public static decimal GetPrice(this PlanType planType) { - return planType switch - { - PlanType.Trial => 0.00m, - PlanType.Basic => 5.90m, - PlanType.Professional => 12.90m, - PlanType.Premium => 19.90m, - PlanType.PremiumAffiliate => 29.90m, - _ => 0.00m - }; - } + return planType switch + { + PlanType.Trial => 0.00m, + PlanType.Basic => 12.90m, + PlanType.Professional => 25.90m, + PlanType.Premium => 29.90m, + PlanType.PremiumAffiliate => 34.90m, + _ => 0.00m + }; + } // NOTA: Limitações agora são configuradas dinamicamente via IPlanConfigurationService // Este método mantém valores fallback para compatibilidade @@ -135,4 +135,4 @@ public static class PlanTypeExtensions { return GetMaxProductLinks(planType) > 0; } -} \ No newline at end of file +} diff --git a/src/BCards.Web/Models/UserPage.cs b/src/BCards.Web/Models/UserPage.cs index 62b59fe..17bee09 100644 --- a/src/BCards.Web/Models/UserPage.cs +++ b/src/BCards.Web/Models/UserPage.cs @@ -41,8 +41,11 @@ public class UserPage : IPageDisplay [BsonElement("theme")] public PageTheme Theme { get; set; } = new(); - [BsonElement("links")] - public List Links { get; set; } = new(); + [BsonElement("links")] + public List Links { get; set; } = new(); + + [BsonElement("documents")] + public List Documents { get; set; } = new(); [BsonElement("seoSettings")] public SeoSettings SeoSettings { get; set; } = new(); @@ -113,4 +116,4 @@ public class UserPage : IPageDisplay public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId) ? $"/api/image/{ProfileImageId}" : "/images/default-avatar.svg"; -} \ No newline at end of file +} diff --git a/src/BCards.Web/Program.cs b/src/BCards.Web/Program.cs index f57d5c1..6bd165f 100644 --- a/src/BCards.Web/Program.cs +++ b/src/BCards.Web/Program.cs @@ -483,6 +483,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Support Area - Rating and Contact System builder.Services.Configure( @@ -498,20 +499,21 @@ builder.Services.AddScoped(options => { - options.MultipartBodyLengthLimit = 5 * 1024 * 1024; // 5MB + var maxUploadSize = 12 * 1024 * 1024; // 12MB + options.MultipartBodyLengthLimit = maxUploadSize; options.ValueLengthLimit = int.MaxValue; options.ValueCountLimit = int.MaxValue; options.KeyLengthLimit = int.MaxValue; options.BufferBody = true; - options.BufferBodyLengthLimit = 5 * 1024 * 1024; // 5MB - options.MultipartBodyLengthLimit = 5 * 1024 * 1024; // 5MB + options.BufferBodyLengthLimit = maxUploadSize; + options.MultipartBodyLengthLimit = maxUploadSize; options.MultipartHeadersLengthLimit = 16384; }); // Configure Kestrel server limits for larger requests builder.Services.Configure(options => { - options.Limits.MaxRequestBodySize = 5 * 1024 * 1024; // 5MB + options.Limits.MaxRequestBodySize = 12 * 1024 * 1024; // 12MB options.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(2); options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2); }); diff --git a/src/BCards.Web/Services/GridFSDocumentStorage.cs b/src/BCards.Web/Services/GridFSDocumentStorage.cs new file mode 100644 index 0000000..6ee7cc9 --- /dev/null +++ b/src/BCards.Web/Services/GridFSDocumentStorage.cs @@ -0,0 +1,93 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using MongoDB.Driver.GridFS; + +namespace BCards.Web.Services; + +public class GridFSDocumentStorage : IDocumentStorageService +{ + private const int MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + private static readonly string[] ALLOWED_TYPES = { "application/pdf" }; + + private readonly GridFSBucket _gridFS; + private readonly ILogger _logger; + + public GridFSDocumentStorage(IMongoDatabase database, ILogger logger) + { + _gridFS = new GridFSBucket(database, new GridFSBucketOptions + { + BucketName = "page_documents" + }); + _logger = logger; + } + + public async Task SaveDocumentAsync(byte[] documentBytes, string fileName, string contentType) + { + if (documentBytes == null || documentBytes.Length == 0) + throw new ArgumentException("Documento inválido."); + + if (documentBytes.Length > MAX_FILE_SIZE) + throw new ArgumentException($"Arquivo muito grande. Tamanho máximo permitido: {MAX_FILE_SIZE / (1024 * 1024)}MB."); + + if (!ALLOWED_TYPES.Contains(contentType.ToLower())) + throw new ArgumentException("Tipo de arquivo não suportado. Envie um PDF."); + + var uniqueFileName = $"document_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}.pdf"; + + var options = new GridFSUploadOptions + { + Metadata = new BsonDocument + { + { "originalFileName", fileName }, + { "contentType", contentType }, + { "uploadDate", DateTime.UtcNow }, + { "size", documentBytes.Length } + } + }; + + var fileId = await _gridFS.UploadFromBytesAsync(uniqueFileName, documentBytes, options); + _logger.LogInformation("PDF salvo no GridFS: {FileId}", fileId); + return fileId.ToString(); + } + + public async Task GetDocumentAsync(string documentId) + { + if (!ObjectId.TryParse(documentId, out var objectId)) + return null; + + try + { + return await _gridFS.DownloadAsBytesAsync(objectId); + } + catch (GridFSFileNotFoundException) + { + return null; + } + } + + public async Task DeleteDocumentAsync(string documentId) + { + if (!ObjectId.TryParse(documentId, out var objectId)) + return false; + + try + { + await _gridFS.DeleteAsync(objectId); + return true; + } + catch (GridFSFileNotFoundException) + { + return false; + } + } + + public async Task DocumentExistsAsync(string documentId) + { + if (!ObjectId.TryParse(documentId, out var objectId)) + return false; + + var filter = Builders.Filter.Eq("_id", objectId); + var fileInfo = await _gridFS.Find(filter).FirstOrDefaultAsync(); + return fileInfo != null; + } +} diff --git a/src/BCards.Web/Services/IDocumentStorageService.cs b/src/BCards.Web/Services/IDocumentStorageService.cs new file mode 100644 index 0000000..3177c87 --- /dev/null +++ b/src/BCards.Web/Services/IDocumentStorageService.cs @@ -0,0 +1,9 @@ +namespace BCards.Web.Services; + +public interface IDocumentStorageService +{ + Task SaveDocumentAsync(byte[] documentBytes, string fileName, string contentType); + Task GetDocumentAsync(string documentId); + Task DeleteDocumentAsync(string documentId); + Task DocumentExistsAsync(string documentId); +} diff --git a/src/BCards.Web/Services/LivePageService.cs b/src/BCards.Web/Services/LivePageService.cs index 877b333..d14a4af 100644 --- a/src/BCards.Web/Services/LivePageService.cs +++ b/src/BCards.Web/Services/LivePageService.cs @@ -58,8 +58,9 @@ public class LivePageService : ILivePageService ProfileImageId = userPage.ProfileImageId, BusinessType = userPage.BusinessType, Theme = userPage.Theme, - Links = userPage.Links, - SeoSettings = userPage.SeoSettings, + Links = userPage.Links, + Documents = userPage.Documents, + SeoSettings = userPage.SeoSettings, Language = userPage.Language, Analytics = new LivePageAnalytics { @@ -115,4 +116,4 @@ public class LivePageService : ILivePageService _logger.LogError(ex, "Failed to increment click for LivePage {LivePageId} link {LinkIndex}", livePageId, linkIndex); } } -} \ No newline at end of file +} diff --git a/src/BCards.Web/Services/PlanConfigurationService.cs b/src/BCards.Web/Services/PlanConfigurationService.cs index 1b73523..ca0fcf4 100644 --- a/src/BCards.Web/Services/PlanConfigurationService.cs +++ b/src/BCards.Web/Services/PlanConfigurationService.cs @@ -37,68 +37,78 @@ public class PlanConfigurationService : IPlanConfigurationService { return planType switch { - PlanType.Trial => new PlanLimitations - { - MaxLinks = 3, - AllowCustomThemes = false, - AllowAnalytics = false, - AllowCustomDomain = false, - AllowMultipleDomains = false, - PrioritySupport = false, - AllowProductLinks = false, - MaxProductLinks = 0, - PlanType = "trial" - }, - PlanType.Basic => new PlanLimitations - { - MaxLinks = GetConfigValue(PlanType.Basic, "MaxLinks", 8), - AllowCustomThemes = GetConfigValue(PlanType.Basic, "AllowPremiumThemes", false), - AllowAnalytics = GetConfigValue(PlanType.Basic, "AllowAnalytics", true), - AllowCustomDomain = true, - AllowMultipleDomains = false, - PrioritySupport = false, - AllowProductLinks = GetConfigValue(PlanType.Basic, "AllowProductLinks", false), - MaxProductLinks = 0, - PlanType = "basic" - }, - PlanType.Professional => new PlanLimitations - { - MaxLinks = GetConfigValue(PlanType.Professional, "MaxLinks", 20), - AllowCustomThemes = GetConfigValue(PlanType.Professional, "AllowPremiumThemes", false), - AllowAnalytics = GetConfigValue(PlanType.Professional, "AllowAnalytics", true), - AllowCustomDomain = true, - AllowMultipleDomains = false, - PrioritySupport = false, - AllowProductLinks = GetConfigValue(PlanType.Professional, "AllowProductLinks", false), - MaxProductLinks = 0, - PlanType = "professional" - }, - PlanType.Premium => new PlanLimitations - { - MaxLinks = GetConfigValue(PlanType.Premium, "MaxLinks", -1), - AllowCustomThemes = GetConfigValue(PlanType.Premium, "AllowPremiumThemes", true), - AllowAnalytics = GetConfigValue(PlanType.Premium, "AllowAnalytics", true), - AllowCustomDomain = true, - AllowMultipleDomains = true, - PrioritySupport = true, - AllowProductLinks = GetConfigValue(PlanType.Premium, "AllowProductLinks", false), - MaxProductLinks = 0, - PlanType = "premium" - }, - PlanType.PremiumAffiliate => new PlanLimitations - { - MaxLinks = GetConfigValue(PlanType.PremiumAffiliate, "MaxLinks", -1), - AllowCustomThemes = GetConfigValue(PlanType.PremiumAffiliate, "AllowPremiumThemes", true), - AllowAnalytics = GetConfigValue(PlanType.PremiumAffiliate, "AllowAnalytics", true), - AllowCustomDomain = true, - AllowMultipleDomains = true, - PrioritySupport = true, - AllowProductLinks = GetConfigValue(PlanType.PremiumAffiliate, "AllowProductLinks", true), - MaxProductLinks = 10, - PlanType = "premiumaffiliate" - }, - _ => new PlanLimitations { PlanType = "trial" } - }; + PlanType.Trial => new PlanLimitations + { + MaxLinks = 3, + AllowCustomThemes = false, + AllowAnalytics = false, + AllowCustomDomain = false, + AllowMultipleDomains = false, + PrioritySupport = false, + AllowProductLinks = false, + MaxProductLinks = 0, + PlanType = "trial", + AllowDocumentUpload = false, + MaxDocuments = 0 + }, + PlanType.Basic => new PlanLimitations + { + MaxLinks = GetConfigValue(PlanType.Basic, "MaxLinks", 8), + AllowCustomThemes = GetConfigValue(PlanType.Basic, "AllowPremiumThemes", false), + AllowAnalytics = GetConfigValue(PlanType.Basic, "AllowAnalytics", true), + AllowCustomDomain = true, + AllowMultipleDomains = false, + PrioritySupport = false, + AllowProductLinks = GetConfigValue(PlanType.Basic, "AllowProductLinks", false), + MaxProductLinks = 0, + PlanType = "basic", + AllowDocumentUpload = GetConfigValue(PlanType.Basic, "AllowDocumentUpload", false), + MaxDocuments = GetConfigValue(PlanType.Basic, "MaxDocuments", 0) + }, + PlanType.Professional => new PlanLimitations + { + MaxLinks = GetConfigValue(PlanType.Professional, "MaxLinks", 20), + AllowCustomThemes = GetConfigValue(PlanType.Professional, "AllowPremiumThemes", false), + AllowAnalytics = GetConfigValue(PlanType.Professional, "AllowAnalytics", true), + AllowCustomDomain = true, + AllowMultipleDomains = false, + PrioritySupport = false, + AllowProductLinks = GetConfigValue(PlanType.Professional, "AllowProductLinks", false), + MaxProductLinks = 0, + PlanType = "professional", + AllowDocumentUpload = GetConfigValue(PlanType.Professional, "AllowDocumentUpload", false), + MaxDocuments = GetConfigValue(PlanType.Professional, "MaxDocuments", 0) + }, + PlanType.Premium => new PlanLimitations + { + MaxLinks = GetConfigValue(PlanType.Premium, "MaxLinks", -1), + AllowCustomThemes = GetConfigValue(PlanType.Premium, "AllowPremiumThemes", true), + AllowAnalytics = GetConfigValue(PlanType.Premium, "AllowAnalytics", true), + AllowCustomDomain = true, + AllowMultipleDomains = true, + PrioritySupport = true, + AllowProductLinks = GetConfigValue(PlanType.Premium, "AllowProductLinks", false), + MaxProductLinks = 0, + PlanType = "premium", + AllowDocumentUpload = GetConfigValue(PlanType.Premium, "AllowDocumentUpload", true), + MaxDocuments = GetConfigValue(PlanType.Premium, "MaxDocuments", 5) + }, + PlanType.PremiumAffiliate => new PlanLimitations + { + MaxLinks = GetConfigValue(PlanType.PremiumAffiliate, "MaxLinks", -1), + AllowCustomThemes = GetConfigValue(PlanType.PremiumAffiliate, "AllowPremiumThemes", true), + AllowAnalytics = GetConfigValue(PlanType.PremiumAffiliate, "AllowAnalytics", true), + AllowCustomDomain = true, + AllowMultipleDomains = true, + PrioritySupport = true, + AllowProductLinks = GetConfigValue(PlanType.PremiumAffiliate, "AllowProductLinks", true), + MaxProductLinks = 10, + PlanType = "premiumaffiliate", + AllowDocumentUpload = GetConfigValue(PlanType.PremiumAffiliate, "AllowDocumentUpload", true), + MaxDocuments = GetConfigValue(PlanType.PremiumAffiliate, "MaxDocuments", 10) + }, + _ => new PlanLimitations { PlanType = "trial", AllowDocumentUpload = false, MaxDocuments = 0 } + }; } public string GetPriceId(PlanType planType, bool yearly = false) @@ -225,4 +235,4 @@ public class PlanConfigurationService : IPlanConfigurationService return defaultValue; } -} \ No newline at end of file +} diff --git a/src/BCards.Web/ViewModels/ManagePageViewModel.cs b/src/BCards.Web/ViewModels/ManagePageViewModel.cs index 5e4188f..b15487d 100644 --- a/src/BCards.Web/ViewModels/ManagePageViewModel.cs +++ b/src/BCards.Web/ViewModels/ManagePageViewModel.cs @@ -43,6 +43,7 @@ public class ManagePageViewModel public string KawaiUrl { get; set; } = string.Empty; public List Links { get; set; } = new(); + public List Documents { get; set; } = new(); // Profile image fields public string? ProfileImageId { get; set; } @@ -55,6 +56,9 @@ public class ManagePageViewModel // Plan limitations public int MaxLinksAllowed { get; set; } = 3; public bool AllowProductLinks { get; set; } = false; + public int MaxDocumentsAllowed { get; set; } = 0; + public bool AllowDocumentUpload { get; set; } = false; + public string DocumentUploadPlansDisplay { get; set; } = "planos com suporte a documentos"; public bool CanUseTheme(string themeName) => AvailableThemes.Any(t => t.Name.ToLower() == themeName.ToLower()); /// @@ -101,6 +105,28 @@ public class ManageLinkViewModel public DateTime? ProductDataCachedAt { get; set; } } +public class ManageDocumentViewModel +{ + // Campos opcionais - preenchidos pelo model binding ou pelo controller + public string? Id { get; set; } + public string? DocumentId { get; set; } + public string? FileName { get; set; } + + [Required(ErrorMessage = "Título é obrigatório")] + [StringLength(120, ErrorMessage = "Título deve ter no máximo 120 caracteres")] + public string Title { get; set; } = string.Empty; + + [StringLength(300, ErrorMessage = "Descrição deve ter no máximo 300 caracteres")] + public string Description { get; set; } = string.Empty; + + public long FileSize { get; set; } + public DateTime? UploadedAt { get; set; } + + public IFormFile? DocumentFile { get; set; } + public bool MarkForRemoval { get; set; } + public bool ReplaceExisting => DocumentFile != null && !string.IsNullOrEmpty(DocumentId); +} + public class DashboardViewModel { public User CurrentUser { get; set; } = new(); @@ -186,4 +212,4 @@ public class DowngradeCriteria 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 29f2690..bf0d22f 100644 --- a/src/BCards.Web/Views/Admin/ManagePage.cshtml +++ b/src/BCards.Web/Views/Admin/ManagePage.cshtml @@ -29,7 +29,7 @@
- Passo 1 de 4 + Passo 1 de 5 Informações Básicas
@@ -224,17 +224,158 @@ PrĂłximo + + + + + +
+

+ +

+
+
+ @if (Model.AllowDocumentUpload) + { +

Anexe PDFs com apresentações, catálogos ou materiais exclusivos para quem acessar sua página Premium.

+ + @if (Model.MaxDocumentsAllowed > 0) + { +
+ + Você pode anexar até @Model.MaxDocumentsAllowed documento(s) no seu plano atual. +
+ } + +
+ @if (Model.Documents != null && Model.Documents.Count > 0) + { + for (var i = 0; i < Model.Documents.Count; i++) + { +
+
+
+
Documento @(i + 1)
+ @if (Model.Documents[i].UploadedAt.HasValue) + { + Atualizado em @Model.Documents[i].UploadedAt.Value.ToLocalTime().ToString("dd/MM/yyyy HH:mm") + } +
+
+ @if (!string.IsNullOrEmpty(Model.Documents[i].DocumentId)) + { + + Ver PDF + + } + +
+
+ +
+ + + +
+ +
+ + + +
+ +
+ + @if (!string.IsNullOrEmpty(Model.Documents[i].DocumentId)) + { +
+
+
+ @Model.Documents[i].FileName +
@((Model.Documents[i].FileSize / 1024.0).ToString("0.#")) KB
+
+ PDF +
+
+ + Envie outro PDF para substituir o arquivo atual (máx. 10MB). + } + else + { + + Envie um arquivo PDF (máx. 10MB). + } + +
+ + + + + + + +
+ } + + + } + else + { +
+ Nenhum documento adicionado ainda. +
+ } +
+ +
+ Os documentos sĂŁo exibidos em ordem de cadastro. Utilize tĂ­tulos claros para facilitar o acesso. + +
+ } + else + { +
+ +
+ Upload de PDFs disponĂ­vel apenas nos planos @Model.DocumentUploadPlansDisplay. + +
+
+ } + +
+ + +
- +