diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a20ae60 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,228 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +BCards is a professional LinkTree clone built in ASP.NET Core MVC, targeting Brazilian and Spanish markets. It provides hierarchical URLs organized by business categories (/page/{category}/{slug}), integrated Stripe payments, and a sophisticated moderation system with preview tokens. + +## Development Commands + +### Build & Run +```bash +# Restore dependencies +dotnet restore + +# Build solution +dotnet build + +# Run development server +cd src/BCards.Web +dotnet run +# Access: https://localhost:49178 + +# Run with Docker +docker-compose up -d +# Access: http://localhost:8080 +``` + +### Testing +```bash +# Run all tests +dotnet test + +# Run tests with coverage +dotnet test --collect:"XPlat Code Coverage" + +# Run specific test +dotnet test --filter "TestClassName" +``` + +## Architecture Overview + +### Technology Stack +- **Framework**: ASP.NET Core MVC (.NET 8) +- **Database**: MongoDB 7.0 with MongoDB.Driver 2.25.0 +- **Authentication**: OAuth 2.0 (Google + Microsoft) +- **Payments**: Stripe.NET 44.7.0 +- **Frontend**: Bootstrap 5.3.2, jQuery 3.7.1, vanilla JavaScript +- **Email**: SendGrid 9.29.3 +- **Containerization**: Docker + Docker Compose + +### Core Architecture Patterns +- **MVC Pattern**: Controllers handle HTTP requests, Views render UI, Models represent data +- **Repository Pattern**: Data access abstraction via IUserPageRepository, ICategoryRepository, etc. +- **Service Layer**: Business logic encapsulation (IUserPageService, IModerationService, etc.) +- **Dependency Injection**: Built-in ASP.NET Core DI container +- **Domain-Driven Design**: Rich domain models with business logic + +### Key Business Logic + +#### Page Status System +Pages follow this lifecycle with explicit numeric enum values: +- `Creating = 6`: Development phase, requires preview tokens +- `PendingModeration = 4`: Submitted for approval, requires preview tokens +- `Rejected = 5`: Failed moderation, requires preview tokens +- `Active = 0`: Live and publicly accessible +- `Inactive = 3`: Paused by user +- `Expired = 1`: Trial expired, redirects to pricing +- `PendingPayment = 2`: Payment overdue, shows warning + +#### Moderation System +- Content approval workflow with attempts tracking +- Preview tokens with 4-hour expiration for non-Active pages +- Email notifications for status changes +- Automatic status transitions based on user actions + +#### Pricing Strategy +Three-tier system with psychological pricing (decoy effect): +- Basic (R$ 9.90/mês): 5 links, basic themes +- Professional (R$ 24.90/mês): 15 links, all themes - *DECOY* +- Premium (R$ 29.90/mês): Unlimited links, custom themes + +### Project Structure +``` +src/BCards.Web/ +├── Controllers/ # MVC Controllers (9 total) +│ ├── AdminController # User dashboard and page management +│ ├── AuthController # OAuth authentication +│ ├── HomeController # Public pages and landing +│ ├── PaymentController # Stripe integration +│ ├── ModerationController # Content approval system +│ └── UserPageController # Public page display +├── Models/ # Domain entities (12 total) +│ ├── User # Authentication and subscriptions +│ ├── UserPage # Main business card entity +│ ├── LinkItem # Individual links with analytics +│ └── Category # Business categories +├── Services/ # Business logic (20 services) +│ ├── IUserPageService # Core page operations +│ ├── IModerationService # Content approval +│ ├── IAuthService # Authentication +│ └── IPaymentService # Stripe integration +├── Repositories/ # Data access (8 repositories) +├── ViewModels/ # View-specific models +├── Middleware/ # Custom middleware (4 pieces) +│ ├── PageStatusMiddleware # Handles page access by status +│ ├── ModerationAuthMiddleware # Admin access control +│ └── PreviewTokenMiddleware # Preview token validation +└── Views/ # Razor templates with Bootstrap 5 +``` + +### Database Design (MongoDB) + +#### Core Collections +- `users`: Authentication, subscription status, Stripe customer data +- `userpages`: Business cards with status, links, themes, moderation history +- `categories`: Business categories with SEO metadata +- `subscriptions`: Stripe subscription tracking + +#### Important Indexes +- Compound: `{category: 1, slug: 1}` for page lookups +- User pages: `{userId: 1, status: 1}` for dashboard filtering +- Active pages: `{status: 1, category: 1}` for public listings + +### Key Features Implementation + +#### Preview Token System +Non-Active pages require preview tokens for access: +```csharp +// Generate fresh token (4-hour expiry) +POST /Admin/GeneratePreviewToken/{id} + +// Access page with token +GET /page/{category}/{slug}?preview={token} +``` + +#### OAuth Integration +Supports Google and Microsoft OAuth with automatic user creation and session management. + +#### Stripe Payment Flow +Complete subscription lifecycle: +1. Checkout session creation +2. Webhook handling for events +3. Subscription status updates +4. Plan limitation enforcement + +#### Dynamic Theming +CSS generation system with customizable colors, backgrounds, and layouts based on user's plan limitations. + +## Configuration + +### Required Environment Variables +```json +{ + "MongoDb": { + "ConnectionString": "mongodb://localhost:27017", + "DatabaseName": "BCardsDB" + }, + "Stripe": { + "PublishableKey": "pk_test_...", + "SecretKey": "sk_test_...", + "WebhookSecret": "whsec_..." + }, + "Authentication": { + "Google": { "ClientId": "...", "ClientSecret": "..." }, + "Microsoft": { "ClientId": "...", "ClientSecret": "..." } + } +} +``` + +### Development Setup +1. Install .NET 8 SDK, MongoDB 4.4+ +2. Configure OAuth credentials (Google Cloud Console, Azure Portal) +3. Set up Stripe account with test keys +4. Configure webhook endpoints for `/webhook/stripe` + +## Important Implementation Notes + +### Page Status Middleware +`PageStatusMiddleware` intercepts all `/page/{category}/{slug}` requests and enforces access rules based on page status. Non-Active pages require valid preview tokens. + +### Moderation Workflow +1. Pages start as `Creating` status +2. Users click "Submit for Moderation" → `PendingModeration` +3. Moderators approve/reject → `Active` or `Rejected` +4. Rejected pages can be edited and resubmitted + +### Preview Token Security +- Tokens expire after 4 hours +- Generated on-demand via AJAX calls +- Required for Creating, PendingModeration, and Rejected pages +- Validated by middleware before page access + +### Plan Limitations +Enforced throughout the application: +- Link count limits per plan +- Theme availability restrictions +- Analytics access control +- Page creation limits + +## Common Development Patterns + +### Repository Usage +```csharp +var page = await _userPageService.GetPageByIdAsync(id); +await _userPageService.UpdatePageAsync(page); +``` + +### Service Layer Pattern +Business logic resides in services, not controllers: +```csharp +public class UserPageService : IUserPageService +{ + private readonly IUserPageRepository _repository; + // Implementation with business rules +} +``` + +### Status-Based Logic +Always check page status before operations: +```csharp +if (page.Status == PageStatus.Creating || page.Status == PageStatus.Rejected) +{ + // Allow editing +} +``` + +This architecture supports a production-ready SaaS application with complex business rules, payment integration, and content moderation workflows. \ No newline at end of file diff --git a/src/BCards.Web/Controllers/AdminController.cs b/src/BCards.Web/Controllers/AdminController.cs index a204bcf..4662e7c 100644 --- a/src/BCards.Web/Controllers/AdminController.cs +++ b/src/BCards.Web/Controllers/AdminController.cs @@ -43,6 +43,8 @@ public class AdminController : Controller [Route("Dashboard")] public async Task Dashboard() { + ViewBag.IsHomePage = false; // Menu normal do dashboard + var user = await _authService.GetCurrentUserAsync(User); if (user == null) return RedirectToAction("Login", "Auth"); @@ -100,6 +102,8 @@ public class AdminController : Controller [Route("ManagePage")] public async Task ManagePage(string id = null) { + ViewBag.IsHomePage = false; + var user = await _authService.GetCurrentUserAsync(User); if (user == null) return RedirectToAction("Login", "Auth"); @@ -146,6 +150,8 @@ public class AdminController : Controller [Route("ManagePage")] public async Task ManagePage(ManagePageViewModel model) { + ViewBag.IsHomePage = false; + var user = await _authService.GetCurrentUserAsync(User); if (user == null) return RedirectToAction("Login", "Auth"); @@ -199,29 +205,20 @@ public class AdminController : Controller var userPage = await MapToUserPage(model, user.Id); _logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}"); - // Set status to PendingModeration for new pages - userPage.Status = ViewModels.PageStatus.PendingModeration; + // Set status to Creating for new pages + userPage.Status = ViewModels.PageStatus.Creating; await _userPageService.CreatePageAsync(userPage); _logger.LogInformation("Page created successfully!"); - // Generate preview token and send for moderation + // Generate preview token for development var previewToken = await _moderationService.GeneratePreviewTokenAsync(userPage.Id); var previewUrl = $"{Request.Scheme}://{Request.Host}/page/{userPage.Category}/{userPage.Slug}?preview={previewToken}"; userPage.PreviewToken = previewToken; userPage.PreviewTokenExpiry = DateTime.UtcNow.AddHours(4); await _userPageService.UpdatePageAsync(userPage); - - // Send email to user - await _emailService.SendModerationStatusAsync( - user.Email, - user.Name, - userPage.DisplayName, - "pending", - null, - previewUrl); - TempData["Success"] = "Página enviada para moderação! Você receberá um email quando for aprovada."; + TempData["Success"] = "Página criada com sucesso! Use o botão 'Enviar para Moderação' quando estiver pronta."; } catch (Exception ex) { @@ -506,6 +503,8 @@ public class AdminController : Controller [Route("Analytics")] public async Task Analytics() { + ViewBag.IsHomePage = false; + var user = await _authService.GetCurrentUserAsync(User); if (user == null) return RedirectToAction("Login", "Auth"); @@ -753,4 +752,136 @@ public class AdminController : Controller page.Links.AddRange(socialLinks); } + + [HttpPost] + [Route("SubmitForModeration/{id}")] + public async Task SubmitForModeration(string id) + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return Json(new { success = false, message = "Usuário não autenticado" }); + + var pageItem = await _userPageService.GetPageByIdAsync(id); + if (pageItem == null || pageItem.UserId != user.Id) + return Json(new { success = false, message = "Página não encontrada" }); + + // Validar status atual + if (pageItem.Status != ViewModels.PageStatus.Creating && pageItem.Status != ViewModels.PageStatus.Rejected) + return Json(new { success = false, message = "Página não pode ser enviada para moderação neste momento" }); + + // Validar se tem pelo menos 1 link ativo + var activeLinksCount = pageItem.Links?.Count(l => l.IsActive) ?? 0; + if (activeLinksCount < 1) + return Json(new { success = false, message = "Página deve ter pelo menos 1 link ativo para ser enviada" }); + + try + { + // Mudar status para PendingModeration + pageItem.Status = ViewModels.PageStatus.PendingModeration; + pageItem.ModerationAttempts++; + pageItem.UpdatedAt = DateTime.UtcNow; + + await _userPageService.UpdatePageAsync(pageItem); + + // Enviar email de notificação ao usuário + await _emailService.SendModerationStatusAsync( + user.Email, + user.Name, + pageItem.DisplayName, + "pending", + null, + $"{Request.Scheme}://{Request.Host}/page/{pageItem.Category}/{pageItem.Slug}?preview={pageItem.PreviewToken}"); + + _logger.LogInformation($"Page {pageItem.Id} submitted for moderation by user {user.Id}"); + + return Json(new { + success = true, + message = "Página enviada para moderação com sucesso! Você receberá um email quando for processada.", + newStatus = "PendingModeration" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error submitting page {id} for moderation"); + return Json(new { success = false, message = "Erro interno. Tente novamente." }); + } + } + + [HttpPost] + [Route("RefreshPreviewToken/{id}")] + public async Task RefreshPreviewToken(string id) + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return Json(new { success = false, message = "Não autorizado" }); + + var pageItem = await _userPageService.GetPageByIdAsync(id); + if (pageItem == null || pageItem.UserId != user.Id) + return Json(new { success = false, message = "Página não encontrada" }); + + // Só renovar token para páginas "Creating" + if (pageItem.Status != ViewModels.PageStatus.Creating) + return Json(new { success = false, message = "Token só pode ser renovado para páginas em desenvolvimento" }); + + try + { + // Gerar novo token com 4 horas de validade + var newToken = await _moderationService.GeneratePreviewTokenAsync(pageItem.Id); + + var previewUrl = $"{Request.Scheme}://{Request.Host}/page/{pageItem.Category}/{pageItem.Slug}?preview={newToken}"; + + return Json(new { + success = true, + previewToken = newToken, + previewUrl = previewUrl, + expiresAt = DateTime.UtcNow.AddHours(4).ToString("yyyy-MM-dd HH:mm:ss") + }); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error refreshing preview token for page {id}"); + return Json(new { success = false, message = "Erro ao renovar token" }); + } + } + + [HttpPost] + [Route("GeneratePreviewToken/{id}")] + public async Task GeneratePreviewToken(string id) + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return Json(new { success = false, message = "Usuário não autenticado" }); + + var pageItem = await _userPageService.GetPageByIdAsync(id); + if (pageItem == null || pageItem.UserId != user.Id) + return Json(new { success = false, message = "Página não encontrada" }); + + // Verificar se página pode ter preview + if (pageItem.Status != ViewModels.PageStatus.Creating && + pageItem.Status != ViewModels.PageStatus.PendingModeration && + pageItem.Status != ViewModels.PageStatus.Rejected) + { + return Json(new { success = false, message = "Preview não disponível para este status" }); + } + + try + { + // Gerar novo token com 4 horas de validade + var newToken = await _moderationService.GeneratePreviewTokenAsync(pageItem.Id); + + _logger.LogInformation($"Preview token generated for page {pageItem.Id} by user {user.Id}"); + + return Json(new { + success = true, + previewToken = newToken, + message = "Preview gerado com sucesso!", + expiresAt = DateTime.UtcNow.AddHours(4).ToString("yyyy-MM-dd HH:mm:ss") + }); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error generating preview token for page {id}"); + return Json(new { success = false, message = "Erro interno. Tente novamente." }); + } + } } \ No newline at end of file diff --git a/src/BCards.Web/Controllers/HomeController.cs b/src/BCards.Web/Controllers/HomeController.cs index 55fd817..563a3e5 100644 --- a/src/BCards.Web/Controllers/HomeController.cs +++ b/src/BCards.Web/Controllers/HomeController.cs @@ -16,6 +16,7 @@ public class HomeController : Controller public async Task Index() { + ViewBag.IsHomePage = true; // Flag para identificar home ViewBag.Categories = await _categoryService.GetAllCategoriesAsync(); ViewBag.RecentPages = await _userPageService.GetRecentPagesAsync(6); return View(); @@ -24,18 +25,21 @@ public class HomeController : Controller [Route("Privacy")] public IActionResult Privacy() { + ViewBag.IsHomePage = true; return View(); } [Route("Pricing")] public IActionResult Pricing() { + ViewBag.IsHomePage = true; return View(); } [Route("categoria/{categorySlug}")] public async Task Category(string categorySlug) { + ViewBag.IsHomePage = true; var category = await _categoryService.GetCategoryBySlugAsync(categorySlug); if (category == null) return NotFound(); diff --git a/src/BCards.Web/Middleware/PageStatusMiddleware.cs b/src/BCards.Web/Middleware/PageStatusMiddleware.cs index 6d6ca76..9295dbd 100644 --- a/src/BCards.Web/Middleware/PageStatusMiddleware.cs +++ b/src/BCards.Web/Middleware/PageStatusMiddleware.cs @@ -51,9 +51,33 @@ public class PageStatusMiddleware await context.Response.WriteAsync("Página temporariamente indisponível."); return; + case PageStatus.Creating: + case PageStatus.PendingModeration: + case PageStatus.Rejected: + // Páginas em desenvolvimento/moderação requerem preview token + var previewToken = context.Request.Query["preview"].FirstOrDefault(); + if (string.IsNullOrEmpty(previewToken) || + string.IsNullOrEmpty(page.PreviewToken) || + previewToken != page.PreviewToken || + page.PreviewTokenExpiry < DateTime.UtcNow) + { + _logger.LogInformation($"Page {category}/{slug} requires valid preview token"); + context.Response.StatusCode = 404; + await context.Response.WriteAsync("Página em desenvolvimento. Acesso restrito."); + return; + } + break; + case PageStatus.Active: // Continuar processamento normal break; + + default: + // Status desconhecido - tratar como inativo + _logger.LogWarning($"Unknown page status: {page.Status} for page {category}/{slug}"); + context.Response.StatusCode = 404; + await context.Response.WriteAsync("Página temporariamente indisponível."); + return; } } } diff --git a/src/BCards.Web/ViewModels/ManagePageViewModel.cs b/src/BCards.Web/ViewModels/ManagePageViewModel.cs index a971583..adcf082 100644 --- a/src/BCards.Web/ViewModels/ManagePageViewModel.cs +++ b/src/BCards.Web/ViewModels/ManagePageViewModel.cs @@ -125,5 +125,6 @@ public enum PageStatus PendingPayment, // Pagamento atrasado -> aviso na página Inactive, // Pausada pelo usuário PendingModeration = 4, // Aguardando moderação - Rejected = 5 // Rejeitada na moderação + Rejected = 5, // Rejeitada na moderação + Creating = 6 // Em desenvolvimento/criação } \ No newline at end of file diff --git a/src/BCards.Web/Views/Admin/Dashboard.cshtml b/src/BCards.Web/Views/Admin/Dashboard.cshtml index 8c36fa9..fd2c5aa 100644 --- a/src/BCards.Web/Views/Admin/Dashboard.cshtml +++ b/src/BCards.Web/Views/Admin/Dashboard.cshtml @@ -23,14 +23,14 @@
- @foreach (var page in Model.UserPages) + @foreach (var pageItem in Model.UserPages) {
-
+
- @(page.DisplayName) -
+ @(pageItem.DisplayName) + @Html.AntiForgeryToken()
-

@(page.Category)/@(page.Slug)

+

@(pageItem.Category)/@(pageItem.Slug)

@{ - var pageStatus = page.Status; - if (page.Status == BCards.Web.ViewModels.PageStatus.Inactive) + var pageStatus = pageItem.Status; + if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Inactive) { - if (page.LastModerationStatus.HasValue) + if (pageItem.LastModerationStatus.HasValue) { - pageStatus = page.LastModerationStatus.Value; + pageStatus = pageItem.LastModerationStatus.Value; } } } @@ -71,52 +71,102 @@ case BCards.Web.ViewModels.PageStatus.Rejected: Rejeitada break; + case BCards.Web.ViewModels.PageStatus.Creating: + + Criando + + break; }
@if (Model.CurrentPlan.AllowsAnalytics) {
-
@(page.TotalViews)
+
@(pageItem.TotalViews)
Visualizações
-
@(page.TotalClicks)
+
@(pageItem.TotalClicks)
Cliques
} -
- Editar - Ver +
+ + + Editar + + + + @if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Creating || + pageItem.Status == BCards.Web.ViewModels.PageStatus.Rejected) + { + + + + + + } + else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.PendingModeration) + { + + + + Aguardando + + } + else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Active) + { + + + Ver + + }
+@section Scripts { + +} + @if (TempData["Success"] != null) {
diff --git a/src/BCards.Web/Views/Home/Index.cshtml b/src/BCards.Web/Views/Home/Index.cshtml index 14d59fd..3ea30eb 100644 --- a/src/BCards.Web/Views/Home/Index.cshtml +++ b/src/BCards.Web/Views/Home/Index.cshtml @@ -1,9 +1,10 @@ @{ - var isPreview = ViewBag.IsPreview as bool? ?? false; + //var isPreview = ViewBag.IsPreview as bool? ?? false; ViewData["Title"] = "BCards - Crie seu LinkTree Profissional"; var categories = ViewBag.Categories as List ?? new List(); var recentPages = ViewBag.RecentPages as List ?? new List(); - Layout = isPreview ? "_Layout" : "_UserPageLayout"; + //Layout = isPreview ? "_Layout" : "_UserPageLayout"; + Layout = "_Layout"; }
@@ -26,9 +27,6 @@ } else { - - Entrar - Começar Grátis diff --git a/src/BCards.Web/Views/Home/Pricing.cshtml b/src/BCards.Web/Views/Home/Pricing.cshtml index 4f7c15f..7613391 100644 --- a/src/BCards.Web/Views/Home/Pricing.cshtml +++ b/src/BCards.Web/Views/Home/Pricing.cshtml @@ -1,7 +1,8 @@ @{ ViewData["Title"] = "Planos e Preços - BCards"; - var isPreview = ViewBag.IsPreview as bool? ?? false; - Layout = isPreview ? "_Layout" : "_UserPageLayout"; + //var isPreview = ViewBag.IsPreview as bool? ?? false; + //Layout = isPreview ? "_Layout" : "_UserPageLayout"; + Layout = "_Layout"; }
diff --git a/src/BCards.Web/Views/Shared/Components/ModerationMenu/Default.cshtml b/src/BCards.Web/Views/Shared/Components/ModerationMenu/Default.cshtml index 66ccdd3..ce02e0e 100644 --- a/src/BCards.Web/Views/Shared/Components/ModerationMenu/Default.cshtml +++ b/src/BCards.Web/Views/Shared/Components/ModerationMenu/Default.cshtml @@ -3,7 +3,10 @@ @if (Model) {