feat/live-preview #1
33
src/BCards.Web/Attributes/ModeratorAuthorizeAttribute.cs
Normal file
33
src/BCards.Web/Attributes/ModeratorAuthorizeAttribute.cs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
using BCards.Web.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace BCards.Web.Attributes
|
||||||
|
{
|
||||||
|
public class ModeratorAuthorizeAttribute : Attribute, IAuthorizationFilter
|
||||||
|
{
|
||||||
|
public void OnAuthorization(AuthorizationFilterContext context)
|
||||||
|
{
|
||||||
|
var user = context.HttpContext.User;
|
||||||
|
|
||||||
|
if (!user.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
context.Result = new RedirectToActionResult("Login", "Auth",
|
||||||
|
new { returnUrl = context.HttpContext.Request.Path });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var moderationAuth = context.HttpContext.RequestServices
|
||||||
|
.GetRequiredService<IModerationAuthService>();
|
||||||
|
|
||||||
|
if (!moderationAuth.IsUserModerator(user))
|
||||||
|
{
|
||||||
|
context.Result = new ForbidResult();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar flag para views
|
||||||
|
context.HttpContext.Items["IsModerator"] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,6 +18,7 @@
|
|||||||
<PackageReference Include="SixLabors.ImageSharp.Web" Version="3.1.0" />
|
<PackageReference Include="SixLabors.ImageSharp.Web" Version="3.1.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" />
|
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" />
|
||||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.54" />
|
<PackageReference Include="HtmlAgilityPack" Version="1.11.54" />
|
||||||
|
<PackageReference Include="SendGrid" Version="9.29.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
10
src/BCards.Web/Configuration/ModerationSettings.cs
Normal file
10
src/BCards.Web/Configuration/ModerationSettings.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace BCards.Web.Configuration
|
||||||
|
{
|
||||||
|
public class ModerationSettings
|
||||||
|
{
|
||||||
|
public Dictionary<string, TimeSpan> PriorityTimeframes { get; set; } = new();
|
||||||
|
public int MaxAttempts { get; set; } = 3;
|
||||||
|
public string ModeratorEmail { get; set; } = "";
|
||||||
|
public List<string> ModeratorEmails { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
using BCards.Web.Models;
|
using BCards.Web.Models;
|
||||||
using BCards.Web.Services;
|
using BCards.Web.Services;
|
||||||
|
using BCards.Web.Utils;
|
||||||
using BCards.Web.ViewModels;
|
using BCards.Web.ViewModels;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@ -15,6 +16,8 @@ public class AdminController : Controller
|
|||||||
private readonly IUserPageService _userPageService;
|
private readonly IUserPageService _userPageService;
|
||||||
private readonly ICategoryService _categoryService;
|
private readonly ICategoryService _categoryService;
|
||||||
private readonly IThemeService _themeService;
|
private readonly IThemeService _themeService;
|
||||||
|
private readonly IModerationService _moderationService;
|
||||||
|
private readonly IEmailService _emailService;
|
||||||
private readonly ILogger<AdminController> _logger;
|
private readonly ILogger<AdminController> _logger;
|
||||||
|
|
||||||
public AdminController(
|
public AdminController(
|
||||||
@ -22,12 +25,16 @@ public class AdminController : Controller
|
|||||||
IUserPageService userPageService,
|
IUserPageService userPageService,
|
||||||
ICategoryService categoryService,
|
ICategoryService categoryService,
|
||||||
IThemeService themeService,
|
IThemeService themeService,
|
||||||
|
IModerationService moderationService,
|
||||||
|
IEmailService emailService,
|
||||||
ILogger<AdminController> logger)
|
ILogger<AdminController> logger)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
_userPageService = userPageService;
|
_userPageService = userPageService;
|
||||||
_categoryService = categoryService;
|
_categoryService = categoryService;
|
||||||
_themeService = themeService;
|
_themeService = themeService;
|
||||||
|
_moderationService = moderationService;
|
||||||
|
_emailService = emailService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,7 +62,10 @@ public class AdminController : Controller
|
|||||||
Status = p.Status,
|
Status = p.Status,
|
||||||
TotalClicks = p.Analytics?.TotalClicks ?? 0,
|
TotalClicks = p.Analytics?.TotalClicks ?? 0,
|
||||||
TotalViews = p.Analytics?.TotalViews ?? 0,
|
TotalViews = p.Analytics?.TotalViews ?? 0,
|
||||||
CreatedAt = p.CreatedAt
|
PreviewToken = p.PreviewToken,
|
||||||
|
CreatedAt = p.CreatedAt,
|
||||||
|
LastModerationStatus = p.ModerationHistory == null || p.ModerationHistory.Last().Status != "rejected" ? null : Enum.Parse<PageStatus>(p.ModerationHistory.Last().Status, true),
|
||||||
|
Motive = p.ModerationHistory == null && p.ModerationHistory.Last().Status == "rejected" ? "" : p.ModerationHistory.Last().Reason
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
CurrentPlan = new PlanInfo
|
CurrentPlan = new PlanInfo
|
||||||
{
|
{
|
||||||
@ -185,10 +195,29 @@ public class AdminController : Controller
|
|||||||
var userPage = await MapToUserPage(model, user.Id);
|
var userPage = await MapToUserPage(model, user.Id);
|
||||||
_logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}");
|
_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;
|
||||||
|
|
||||||
await _userPageService.CreatePageAsync(userPage);
|
await _userPageService.CreatePageAsync(userPage);
|
||||||
_logger.LogInformation("Page created successfully!");
|
_logger.LogInformation("Page created successfully!");
|
||||||
|
|
||||||
TempData["Success"] = "Página criada com sucesso!";
|
// Generate preview token and send for moderation
|
||||||
|
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.";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -196,6 +225,7 @@ public class AdminController : Controller
|
|||||||
ModelState.AddModelError("", "Erro ao criar página. Tente novamente.");
|
ModelState.AddModelError("", "Erro ao criar página. Tente novamente.");
|
||||||
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
||||||
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
||||||
|
TempData["Error"] = $"Erro ao criar página. Tente novamente. TechMsg: {ex.Message}";
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -206,9 +236,36 @@ public class AdminController : Controller
|
|||||||
if (existingPage == null || existingPage.UserId != user.Id)
|
if (existingPage == null || existingPage.UserId != user.Id)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
|
// Check if user can create pages (for users with rejected pages)
|
||||||
|
var canCreatePage = await _moderationService.CanUserCreatePageAsync(user.Id);
|
||||||
|
if (!canCreatePage)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Você não pode editar páginas devido a muitas rejeições. Entre em contato com o suporte.";
|
||||||
|
return RedirectToAction("Dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
UpdateUserPageFromModel(existingPage, model);
|
UpdateUserPageFromModel(existingPage, model);
|
||||||
|
|
||||||
|
// Set status to PendingModeration for updates
|
||||||
|
existingPage.Status = ViewModels.PageStatus.PendingModeration;
|
||||||
|
existingPage.ModerationAttempts = existingPage.ModerationAttempts;
|
||||||
|
|
||||||
await _userPageService.UpdatePageAsync(existingPage);
|
await _userPageService.UpdatePageAsync(existingPage);
|
||||||
TempData["Success"] = "Página atualizada com sucesso!";
|
|
||||||
|
// Generate new preview token
|
||||||
|
var previewToken = await _moderationService.GeneratePreviewTokenAsync(existingPage.Id);
|
||||||
|
var previewUrl = $"{Request.Scheme}://{Request.Host}/page/{existingPage.Category}/{existingPage.Slug}?preview={previewToken}";
|
||||||
|
|
||||||
|
// Send email to user
|
||||||
|
await _emailService.SendModerationStatusAsync(
|
||||||
|
user.Email,
|
||||||
|
user.Name,
|
||||||
|
existingPage.DisplayName,
|
||||||
|
"pending",
|
||||||
|
null,
|
||||||
|
previewUrl);
|
||||||
|
|
||||||
|
TempData["Success"] = "Página atualizada e enviada para moderação!";
|
||||||
}
|
}
|
||||||
|
|
||||||
return RedirectToAction("Dashboard");
|
return RedirectToAction("Dashboard");
|
||||||
@ -457,19 +514,23 @@ public class AdminController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> DeletePage()
|
[Route("DeletePage/{id}")]
|
||||||
|
public async Task<IActionResult> DeletePage(string id)
|
||||||
{
|
{
|
||||||
var user = await _authService.GetCurrentUserAsync(User);
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return RedirectToAction("Login", "Auth");
|
return RedirectToAction("Login", "Auth");
|
||||||
|
|
||||||
var userPage = await _userPageService.GetUserPageAsync(user.Id);
|
var userPage = await _userPageService.GetPageByIdAsync(id);
|
||||||
if (userPage != null)
|
if (userPage == null || userPage.UserId != user.Id)
|
||||||
{
|
{
|
||||||
await _userPageService.DeletePageAsync(userPage.Id);
|
TempData["Error"] = "Página não encontrada!";
|
||||||
TempData["Success"] = "Página excluída com sucesso!";
|
return RedirectToAction("Dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _userPageService.DeletePageAsync(userPage.Id);
|
||||||
|
TempData["Success"] = "Página excluída com sucesso!";
|
||||||
|
|
||||||
return RedirectToAction("Dashboard");
|
return RedirectToAction("Dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -515,10 +576,10 @@ public class AdminController : Controller
|
|||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
DisplayName = model.DisplayName,
|
DisplayName = model.DisplayName,
|
||||||
Category = model.Category.ToLower(),
|
Category = SlugHelper.ConvertCategory(model.Category.ToLower()),
|
||||||
BusinessType = model.BusinessType,
|
BusinessType = model.BusinessType,
|
||||||
Bio = model.Bio,
|
Bio = model.Bio,
|
||||||
Slug = model.Slug.ToLower(),
|
Slug = SlugHelper.CreateSlug(model.Slug.ToLower()),
|
||||||
Theme = theme,
|
Theme = theme,
|
||||||
Status = ViewModels.PageStatus.Active,
|
Status = ViewModels.PageStatus.Active,
|
||||||
Links = new List<LinkItem>()
|
Links = new List<LinkItem>()
|
||||||
|
|||||||
230
src/BCards.Web/Controllers/ModerationController.cs
Normal file
230
src/BCards.Web/Controllers/ModerationController.cs
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using BCards.Web.Services;
|
||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.ViewModels;
|
||||||
|
using BCards.Web.Repositories;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using BCards.Web.Attributes;
|
||||||
|
|
||||||
|
namespace BCards.Web.Controllers;
|
||||||
|
|
||||||
|
[ModeratorAuthorize]
|
||||||
|
[Route("Moderation")]
|
||||||
|
public class ModerationController : Controller
|
||||||
|
{
|
||||||
|
private readonly IModerationService _moderationService;
|
||||||
|
private readonly IEmailService _emailService;
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly ILogger<ModerationController> _logger;
|
||||||
|
|
||||||
|
public ModerationController(
|
||||||
|
IModerationService moderationService,
|
||||||
|
IEmailService emailService,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ILogger<ModerationController> logger)
|
||||||
|
{
|
||||||
|
_moderationService = moderationService;
|
||||||
|
_emailService = emailService;
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("Dashboard")]
|
||||||
|
public async Task<IActionResult> Dashboard(int page = 1, int size = 20)
|
||||||
|
{
|
||||||
|
var skip = (page - 1) * size;
|
||||||
|
var pendingPages = await _moderationService.GetPendingModerationAsync(skip, size);
|
||||||
|
var stats = await _moderationService.GetModerationStatsAsync();
|
||||||
|
|
||||||
|
var viewModel = new ModerationDashboardViewModel
|
||||||
|
{
|
||||||
|
PendingPages = pendingPages.Select(p => new PendingPageViewModel
|
||||||
|
{
|
||||||
|
Id = p.Id,
|
||||||
|
DisplayName = p.DisplayName,
|
||||||
|
Category = p.Category,
|
||||||
|
Slug = p.Slug,
|
||||||
|
CreatedAt = p.CreatedAt,
|
||||||
|
ModerationAttempts = p.ModerationAttempts,
|
||||||
|
PlanType = p.PlanLimitations.PlanType.ToString(),
|
||||||
|
PreviewUrl = !string.IsNullOrEmpty(p.PreviewToken)
|
||||||
|
? $"/page/{p.Category}/{p.Slug}?preview={p.PreviewToken}"
|
||||||
|
: null
|
||||||
|
}).ToList(),
|
||||||
|
Stats = stats,
|
||||||
|
CurrentPage = page,
|
||||||
|
PageSize = size,
|
||||||
|
HasNextPage = pendingPages.Count == size
|
||||||
|
};
|
||||||
|
|
||||||
|
return View(viewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("Review/{id}")]
|
||||||
|
public async Task<IActionResult> Review(string id)
|
||||||
|
{
|
||||||
|
var page = await _moderationService.GetPageForModerationAsync(id);
|
||||||
|
if (page == null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Página não encontrada ou não está pendente de moderação.";
|
||||||
|
return RedirectToAction("Dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userRepository.GetByIdAsync(page.UserId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Usuário não encontrado.";
|
||||||
|
return RedirectToAction("Dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
var viewModel = new ModerationReviewViewModel
|
||||||
|
{
|
||||||
|
Page = page,
|
||||||
|
User = user,
|
||||||
|
PreviewUrl = !string.IsNullOrEmpty(page.PreviewToken)
|
||||||
|
? $"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}"
|
||||||
|
: null,
|
||||||
|
ModerationCriteria = GetModerationCriteria()
|
||||||
|
};
|
||||||
|
|
||||||
|
return View(viewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("Approve/{id}")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> Approve(string id, string notes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var page = await _moderationService.GetPageForModerationAsync(id);
|
||||||
|
if (page == null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Página não encontrada.";
|
||||||
|
return RedirectToAction("Dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userRepository.GetByIdAsync(page.UserId);
|
||||||
|
var moderatorId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system";
|
||||||
|
|
||||||
|
await _moderationService.ApprovePageAsync(id, moderatorId, notes);
|
||||||
|
|
||||||
|
if (user != null)
|
||||||
|
{
|
||||||
|
await _emailService.SendModerationStatusAsync(
|
||||||
|
user.Email,
|
||||||
|
user.Name,
|
||||||
|
page.DisplayName,
|
||||||
|
"approved");
|
||||||
|
}
|
||||||
|
|
||||||
|
TempData["Success"] = $"Página '{page.DisplayName}' aprovada com sucesso!";
|
||||||
|
return RedirectToAction("Dashboard");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error approving page {PageId}", id);
|
||||||
|
TempData["Error"] = "Erro ao aprovar página.";
|
||||||
|
return RedirectToAction("Review", new { id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("Reject/{id}")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> Reject(string id, string reason, List<string> issues)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var page = await _moderationService.GetPageForModerationAsync(id);
|
||||||
|
if (page == null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Página não encontrada.";
|
||||||
|
return RedirectToAction("Dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userRepository.GetByIdAsync(page.UserId);
|
||||||
|
var moderatorId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system";
|
||||||
|
|
||||||
|
await _moderationService.RejectPageAsync(id, moderatorId, reason, issues);
|
||||||
|
|
||||||
|
if (user != null)
|
||||||
|
{
|
||||||
|
await _emailService.SendModerationStatusAsync(
|
||||||
|
user.Email,
|
||||||
|
user.Name,
|
||||||
|
page.DisplayName,
|
||||||
|
"rejected",
|
||||||
|
reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
TempData["Success"] = $"Página '{page.DisplayName}' rejeitada.";
|
||||||
|
return RedirectToAction("Dashboard");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error rejecting page {PageId}", id);
|
||||||
|
TempData["Error"] = "Erro ao rejeitar página.";
|
||||||
|
return RedirectToAction("Review", new { id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("History")]
|
||||||
|
public async Task<IActionResult> History(int page = 1, int size = 20)
|
||||||
|
{
|
||||||
|
var skip = (page - 1) * size;
|
||||||
|
var historyPages = await _moderationService.GetModerationHistoryAsync(skip, size);
|
||||||
|
|
||||||
|
var viewModel = new ModerationHistoryViewModel
|
||||||
|
{
|
||||||
|
Pages = historyPages.Select(p => new ModerationPageViewModel
|
||||||
|
{
|
||||||
|
Id = p.Id,
|
||||||
|
DisplayName = p.DisplayName,
|
||||||
|
Category = p.Category,
|
||||||
|
Slug = p.Slug,
|
||||||
|
CreatedAt = p.CreatedAt,
|
||||||
|
Status = p.Status.ToString(),
|
||||||
|
ModerationAttempts = p.ModerationAttempts,
|
||||||
|
PlanType = p.PlanLimitations.PlanType.ToString(),
|
||||||
|
ApprovedAt = p.ApprovedAt,
|
||||||
|
LastModerationEntry = p.ModerationHistory.LastOrDefault()
|
||||||
|
}).ToList(),
|
||||||
|
CurrentPage = page,
|
||||||
|
PageSize = size,
|
||||||
|
HasNextPage = historyPages.Count == size
|
||||||
|
};
|
||||||
|
|
||||||
|
return View(viewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ModerationCriterion> GetModerationCriteria()
|
||||||
|
{
|
||||||
|
return new List<ModerationCriterion>
|
||||||
|
{
|
||||||
|
new() { Category = "Conteúdo Proibido", Items = new List<string>
|
||||||
|
{
|
||||||
|
"Pornografia e conteúdo sexual explícito",
|
||||||
|
"Drogas ilegais e substâncias controladas",
|
||||||
|
"Armas e explosivos",
|
||||||
|
"Atividades ilegais (fraudes, pirataria)",
|
||||||
|
"Apostas e jogos de azar",
|
||||||
|
"Criptomoedas e esquemas de pirâmide",
|
||||||
|
"Conteúdo que promove violência ou ódio",
|
||||||
|
"Spam e links suspeitos/maliciosos"
|
||||||
|
}},
|
||||||
|
new() { Category = "Conteúdo Suspeito", Items = new List<string>
|
||||||
|
{
|
||||||
|
"Excesso de anúncios (>30% dos links)",
|
||||||
|
"Sites com pop-ups excessivos",
|
||||||
|
"Links encurtados suspeitos",
|
||||||
|
"Conteúdo que imita marcas conhecidas",
|
||||||
|
"Produtos \"milagrosos\""
|
||||||
|
}},
|
||||||
|
new() { Category = "Verificações Técnicas", Items = new List<string>
|
||||||
|
{
|
||||||
|
"Links funcionais (não quebrados)",
|
||||||
|
"Sites com SSL válido",
|
||||||
|
"Não redirecionamentos maliciosos"
|
||||||
|
}}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,17 +10,20 @@ public class UserPageController : Controller
|
|||||||
private readonly ICategoryService _categoryService;
|
private readonly ICategoryService _categoryService;
|
||||||
private readonly ISeoService _seoService;
|
private readonly ISeoService _seoService;
|
||||||
private readonly IThemeService _themeService;
|
private readonly IThemeService _themeService;
|
||||||
|
private readonly IModerationService _moderationService;
|
||||||
|
|
||||||
public UserPageController(
|
public UserPageController(
|
||||||
IUserPageService userPageService,
|
IUserPageService userPageService,
|
||||||
ICategoryService categoryService,
|
ICategoryService categoryService,
|
||||||
ISeoService seoService,
|
ISeoService seoService,
|
||||||
IThemeService themeService)
|
IThemeService themeService,
|
||||||
|
IModerationService moderationService)
|
||||||
{
|
{
|
||||||
_userPageService = userPageService;
|
_userPageService = userPageService;
|
||||||
_categoryService = categoryService;
|
_categoryService = categoryService;
|
||||||
_seoService = seoService;
|
_seoService = seoService;
|
||||||
_themeService = themeService;
|
_themeService = themeService;
|
||||||
|
_moderationService = moderationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
//[Route("{category}/{slug}")]
|
//[Route("{category}/{slug}")]
|
||||||
@ -29,13 +32,49 @@ public class UserPageController : Controller
|
|||||||
public async Task<IActionResult> Display(string category, string slug)
|
public async Task<IActionResult> Display(string category, string slug)
|
||||||
{
|
{
|
||||||
var userPage = await _userPageService.GetPageAsync(category, slug);
|
var userPage = await _userPageService.GetPageAsync(category, slug);
|
||||||
if (userPage == null || !userPage.IsActive)
|
if (userPage == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
var categoryObj = await _categoryService.GetCategoryBySlugAsync(category);
|
var categoryObj = await _categoryService.GetCategoryBySlugAsync(category);
|
||||||
if (categoryObj == null)
|
if (categoryObj == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
|
// Check if it's a preview request
|
||||||
|
var isPreview = HttpContext.Items.ContainsKey("IsPreview") && (bool)HttpContext.Items["IsPreview"];
|
||||||
|
var previewToken = Request.Query["preview"].FirstOrDefault();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(previewToken))
|
||||||
|
{
|
||||||
|
// Handle preview request
|
||||||
|
var isValidPreview = await _moderationService.ValidatePreviewTokenAsync(userPage.Id, previewToken);
|
||||||
|
if (!isValidPreview)
|
||||||
|
{
|
||||||
|
return View("PreviewExpired");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set preview flag
|
||||||
|
ViewBag.IsPreview = true;
|
||||||
|
ViewBag.PreviewToken = previewToken;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Regular request - check if page is active
|
||||||
|
if (userPage.Status == ViewModels.PageStatus.PendingModeration)
|
||||||
|
{
|
||||||
|
return View("PendingModeration");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userPage.Status == ViewModels.PageStatus.Rejected)
|
||||||
|
{
|
||||||
|
return View("PageRejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userPage.Status == ViewModels.PageStatus.Inactive || !userPage.IsActive)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure theme is loaded - critical fix for theme display issue
|
// Ensure theme is loaded - critical fix for theme display issue
|
||||||
if (userPage.Theme?.Id == null || string.IsNullOrEmpty(userPage.Theme.PrimaryColor))
|
if (userPage.Theme?.Id == null || string.IsNullOrEmpty(userPage.Theme.PrimaryColor))
|
||||||
{
|
{
|
||||||
@ -45,10 +84,13 @@ public class UserPageController : Controller
|
|||||||
// Generate SEO settings
|
// Generate SEO settings
|
||||||
var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj);
|
var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj);
|
||||||
|
|
||||||
// Record page view (async, don't wait)
|
// Record page view (async, don't wait) - only for non-preview requests
|
||||||
var referrer = Request.Headers["Referer"].FirstOrDefault();
|
if (!isPreview)
|
||||||
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
|
{
|
||||||
_ = Task.Run(() => _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent));
|
var referrer = Request.Headers["Referer"].FirstOrDefault();
|
||||||
|
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
|
||||||
|
_ = Task.Run(() => _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent));
|
||||||
|
}
|
||||||
|
|
||||||
ViewBag.SeoSettings = seoSettings;
|
ViewBag.SeoSettings = seoSettings;
|
||||||
ViewBag.Category = categoryObj;
|
ViewBag.Category = categoryObj;
|
||||||
|
|||||||
45
src/BCards.Web/Middleware/ModerationAuthMiddleware.cs
Normal file
45
src/BCards.Web/Middleware/ModerationAuthMiddleware.cs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
using BCards.Web.Services;
|
||||||
|
|
||||||
|
namespace BCards.Web.Middleware
|
||||||
|
{
|
||||||
|
public class ModerationAuthMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly IModerationAuthService _moderationAuth;
|
||||||
|
|
||||||
|
public ModerationAuthMiddleware(RequestDelegate next, IModerationAuthService moderationAuth)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_moderationAuth = moderationAuth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var path = context.Request.Path.Value?.ToLowerInvariant();
|
||||||
|
|
||||||
|
// Verificar se é uma rota de moderação
|
||||||
|
if (path != null && path.StartsWith("/moderation"))
|
||||||
|
{
|
||||||
|
// Verificar se usuário está autenticado
|
||||||
|
if (!context.User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
context.Response.Redirect("/Auth/Login?returnUrl=" + Uri.EscapeDataString(context.Request.Path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é moderador
|
||||||
|
if (!_moderationAuth.IsUserModerator(context.User))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = 403;
|
||||||
|
await context.Response.WriteAsync("Acesso negado. Você não tem permissão para acessar esta área.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar flag para usar nas views
|
||||||
|
context.Items["IsModerator"] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/BCards.Web/Middleware/PreviewTokenMiddleware.cs
Normal file
121
src/BCards.Web/Middleware/PreviewTokenMiddleware.cs
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
using BCards.Web.Services;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
namespace BCards.Web.Middleware;
|
||||||
|
|
||||||
|
public class PreviewTokenMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
private readonly ILogger<PreviewTokenMiddleware> _logger;
|
||||||
|
|
||||||
|
public PreviewTokenMiddleware(RequestDelegate next, IMemoryCache cache, ILogger<PreviewTokenMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_cache = cache;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var path = context.Request.Path.Value;
|
||||||
|
var query = context.Request.Query;
|
||||||
|
|
||||||
|
// Verificar se é uma requisição de preview
|
||||||
|
if (path != null && path.StartsWith("/page/") && query.ContainsKey("preview"))
|
||||||
|
{
|
||||||
|
var previewToken = query["preview"].FirstOrDefault();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(previewToken))
|
||||||
|
{
|
||||||
|
var result = await HandlePreviewRequest(context, previewToken);
|
||||||
|
if (!result)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = 404;
|
||||||
|
await context.Response.WriteAsync("Preview não encontrado ou expirado.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> HandlePreviewRequest(HttpContext context, string previewToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Verificar rate limiting por IP
|
||||||
|
var clientIp = GetClientIpAddress(context);
|
||||||
|
var rateLimitKey = $"preview_rate_limit_{clientIp}";
|
||||||
|
|
||||||
|
if (_cache.TryGetValue(rateLimitKey, out int requestCount))
|
||||||
|
{
|
||||||
|
if (requestCount >= 10) // Máximo 10 requisições por minuto por IP
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Rate limit exceeded for IP {IP} on preview token {Token}", clientIp, previewToken);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
_cache.Set(rateLimitKey, requestCount + 1, TimeSpan.FromMinutes(1));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_cache.Set(rateLimitKey, 1, TimeSpan.FromMinutes(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o token é válido
|
||||||
|
var moderationService = context.RequestServices.GetService<IModerationService>();
|
||||||
|
if (moderationService == null)
|
||||||
|
{
|
||||||
|
_logger.LogError("ModerationService not found in DI container");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = await moderationService.GetPageByPreviewTokenAsync(previewToken);
|
||||||
|
if (page == null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Invalid or expired preview token: {Token}", previewToken);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incrementar contador de visualizações
|
||||||
|
var incrementResult = await moderationService.IncrementPreviewViewAsync(page.Id);
|
||||||
|
if (!incrementResult)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Preview view limit exceeded for page {PageId}", page.Id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar informações do preview ao contexto
|
||||||
|
context.Items["IsPreview"] = true;
|
||||||
|
context.Items["PreviewPageId"] = page.Id;
|
||||||
|
context.Items["PreviewToken"] = previewToken;
|
||||||
|
|
||||||
|
_logger.LogInformation("Valid preview request for page {PageId} with token {Token}", page.Id, previewToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error handling preview request with token {Token}", previewToken);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetClientIpAddress(HttpContext context)
|
||||||
|
{
|
||||||
|
// Verificar cabeçalhos de proxy
|
||||||
|
var xForwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
||||||
|
if (!string.IsNullOrEmpty(xForwardedFor))
|
||||||
|
{
|
||||||
|
return xForwardedFor.Split(',')[0].Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
var xRealIp = context.Request.Headers["X-Real-IP"].FirstOrDefault();
|
||||||
|
if (!string.IsNullOrEmpty(xRealIp))
|
||||||
|
{
|
||||||
|
return xRealIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/BCards.Web/Models/ModerationHistory.cs
Normal file
25
src/BCards.Web/Models/ModerationHistory.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace BCards.Web.Models;
|
||||||
|
|
||||||
|
public class ModerationHistory
|
||||||
|
{
|
||||||
|
[BsonElement("attempt")]
|
||||||
|
public int Attempt { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("status")]
|
||||||
|
public string Status { get; set; } = "pending"; // "pending", "approved", "rejected"
|
||||||
|
|
||||||
|
[BsonElement("reason")]
|
||||||
|
public string? Reason { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("moderatorId")]
|
||||||
|
public string? ModeratorId { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("date")]
|
||||||
|
public DateTime Date { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
[BsonElement("issues")]
|
||||||
|
public List<string> Issues { get; set; } = new();
|
||||||
|
}
|
||||||
@ -65,5 +65,26 @@ public class UserPage
|
|||||||
[BsonElement("status")]
|
[BsonElement("status")]
|
||||||
public PageStatus Status { get; set; } = PageStatus.Active;
|
public PageStatus Status { get; set; } = PageStatus.Active;
|
||||||
|
|
||||||
|
[BsonElement("previewToken")]
|
||||||
|
public string? PreviewToken { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("previewTokenExpiry")]
|
||||||
|
public DateTime? PreviewTokenExpiry { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("moderationAttempts")]
|
||||||
|
public int ModerationAttempts { get; set; } = 0;
|
||||||
|
|
||||||
|
[BsonElement("moderationHistory")]
|
||||||
|
public List<ModerationHistory> ModerationHistory { get; set; } = new();
|
||||||
|
|
||||||
|
[BsonElement("approvedAt")]
|
||||||
|
public DateTime? ApprovedAt { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("userScore")]
|
||||||
|
public int UserScore { get; set; } = 100;
|
||||||
|
|
||||||
|
[BsonElement("previewViewCount")]
|
||||||
|
public int PreviewViewCount { get; set; } = 0;
|
||||||
|
|
||||||
public string FullUrl => $"page/{Category}/{Slug}";
|
public string FullUrl => $"page/{Category}/{Slug}";
|
||||||
}
|
}
|
||||||
@ -10,6 +10,8 @@ using MongoDB.Driver;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using Microsoft.AspNetCore.Authentication.OAuth;
|
using Microsoft.AspNetCore.Authentication.OAuth;
|
||||||
|
using SendGrid;
|
||||||
|
using BCards.Web.Middleware;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@ -47,6 +49,10 @@ builder.Services.Configure<GoogleAuthSettings>(
|
|||||||
builder.Services.Configure<MicrosoftAuthSettings>(
|
builder.Services.Configure<MicrosoftAuthSettings>(
|
||||||
builder.Configuration.GetSection("Authentication:Microsoft"));
|
builder.Configuration.GetSection("Authentication:Microsoft"));
|
||||||
|
|
||||||
|
// Adicionar configurações
|
||||||
|
builder.Services.Configure<ModerationSettings>(
|
||||||
|
builder.Configuration.GetSection("Moderation"));
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
builder.Services.AddAuthentication(options =>
|
builder.Services.AddAuthentication(options =>
|
||||||
{
|
{
|
||||||
@ -107,6 +113,8 @@ builder.Services.AddScoped<IUserRepository, UserRepository>();
|
|||||||
builder.Services.AddScoped<IUserPageRepository, UserPageRepository>();
|
builder.Services.AddScoped<IUserPageRepository, UserPageRepository>();
|
||||||
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
|
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
|
||||||
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
|
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
|
||||||
|
builder.Services.AddSingleton<IModerationAuthService, ModerationAuthService>();
|
||||||
|
//builder.Services.AddScoped<IModerationAuthService, ModerationAuthService>();
|
||||||
|
|
||||||
builder.Services.AddScoped<IUserPageService, UserPageService>();
|
builder.Services.AddScoped<IUserPageService, UserPageService>();
|
||||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||||
@ -115,10 +123,19 @@ builder.Services.AddScoped<IAuthService, AuthService>();
|
|||||||
builder.Services.AddScoped<IPaymentService, PaymentService>();
|
builder.Services.AddScoped<IPaymentService, PaymentService>();
|
||||||
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||||||
builder.Services.AddScoped<IOpenGraphService, OpenGraphService>();
|
builder.Services.AddScoped<IOpenGraphService, OpenGraphService>();
|
||||||
|
builder.Services.AddScoped<IModerationService, ModerationService>();
|
||||||
|
builder.Services.AddScoped<IEmailService, EmailService>();
|
||||||
|
|
||||||
// Add HttpClient for OpenGraphService
|
// Add HttpClient for OpenGraphService
|
||||||
builder.Services.AddHttpClient<OpenGraphService>();
|
builder.Services.AddHttpClient<OpenGraphService>();
|
||||||
|
|
||||||
|
// Add SendGrid
|
||||||
|
builder.Services.AddSingleton<ISendGridClient>(provider =>
|
||||||
|
{
|
||||||
|
var apiKey = builder.Configuration["SendGrid:ApiKey"];
|
||||||
|
return new SendGridClient(apiKey);
|
||||||
|
});
|
||||||
|
|
||||||
// Background Services
|
// Background Services
|
||||||
builder.Services.AddHostedService<TrialExpirationService>();
|
builder.Services.AddHostedService<TrialExpirationService>();
|
||||||
|
|
||||||
@ -150,45 +167,60 @@ app.UseAuthorization();
|
|||||||
// Add custom middleware
|
// Add custom middleware
|
||||||
app.UseMiddleware<BCards.Web.Middleware.PlanLimitationMiddleware>();
|
app.UseMiddleware<BCards.Web.Middleware.PlanLimitationMiddleware>();
|
||||||
app.UseMiddleware<BCards.Web.Middleware.PageStatusMiddleware>();
|
app.UseMiddleware<BCards.Web.Middleware.PageStatusMiddleware>();
|
||||||
|
app.UseMiddleware<BCards.Web.Middleware.PreviewTokenMiddleware>();
|
||||||
|
app.UseMiddleware<ModerationAuthMiddleware>();
|
||||||
|
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
Console.WriteLine($"=== REQUEST DEBUG ===");
|
||||||
|
Console.WriteLine($"Path: {context.Request.Path}");
|
||||||
|
Console.WriteLine($"Query: {context.Request.QueryString}");
|
||||||
|
Console.WriteLine($"Method: {context.Request.Method}");
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
app.UseResponseCaching();
|
app.UseResponseCaching();
|
||||||
|
|
||||||
// Rota padr<64>o primeiro (mais espec<65>fica)
|
// Rotas específicas primeiro
|
||||||
app.MapControllerRoute(
|
app.MapControllerRoute(
|
||||||
name: "default",
|
name: "userpage-preview-path",
|
||||||
pattern: "{controller=Home}/{action=Index}/{id?}");
|
|
||||||
|
|
||||||
//Rota customizada depois (mais gen<65>rica)
|
|
||||||
//app.MapControllerRoute(
|
|
||||||
// name: "userpage",
|
|
||||||
// pattern: "page/{category}/{slug}",
|
|
||||||
// defaults: new { controller = "UserPage", action = "Display" },
|
|
||||||
// constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
|
||||||
// Rota para preview
|
|
||||||
app.MapControllerRoute(
|
|
||||||
name: "userpage-preview",
|
|
||||||
pattern: "page/preview/{category}/{slug}",
|
pattern: "page/preview/{category}/{slug}",
|
||||||
defaults: new { controller = "UserPage", action = "Preview" },
|
defaults: new { controller = "UserPage", action = "Preview" },
|
||||||
constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
||||||
|
|
||||||
// Rota para click
|
|
||||||
app.MapControllerRoute(
|
app.MapControllerRoute(
|
||||||
name: "userpage-click",
|
name: "userpage-click",
|
||||||
pattern: "page/click/{pageId}",
|
pattern: "page/click/{pageId}",
|
||||||
defaults: new { controller = "UserPage", action = "RecordClick" });
|
defaults: new { controller = "UserPage", action = "RecordClick" });
|
||||||
|
|
||||||
// Rota principal (deve vir por último)
|
app.MapControllerRoute(
|
||||||
|
name: "moderation",
|
||||||
|
pattern: "moderation/{action=Dashboard}/{id?}",
|
||||||
|
defaults: new { controller = "Moderation" });
|
||||||
|
|
||||||
|
// Rota principal que vai pegar ?preview=token
|
||||||
|
//app.MapControllerRoute(
|
||||||
|
// name: "userpage",
|
||||||
|
// pattern: "page/{category}/{slug}",
|
||||||
|
// defaults: new { controller = "UserPage", action = "Display" },
|
||||||
|
// constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
||||||
|
|
||||||
app.MapControllerRoute(
|
app.MapControllerRoute(
|
||||||
name: "userpage",
|
name: "userpage",
|
||||||
pattern: "page/{category}/{slug}",
|
pattern: "page/{category}/{slug}",
|
||||||
defaults: new { controller = "UserPage", action = "Display" },
|
defaults: new { controller = "UserPage", action = "Display" },
|
||||||
constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
constraints: new
|
||||||
|
{
|
||||||
|
category = @"^[a-zA-Z0-9\-\u00C0-\u017F]+$", // ← Aceita acentos
|
||||||
|
slug = @"^[a-z0-9-]+$"
|
||||||
|
});
|
||||||
|
|
||||||
// Rota padrão
|
// Rota padrão por último
|
||||||
app.MapControllerRoute(
|
app.MapControllerRoute(
|
||||||
name: "default",
|
name: "default",
|
||||||
pattern: "{controller=Home}/{action=Index}/{id?}");
|
pattern: "{controller=Home}/{action=Index}/{id?}");
|
||||||
|
|
||||||
|
|
||||||
// Initialize default data
|
// Initialize default data
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using BCards.Web.Models;
|
using BCards.Web.Models;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
|
||||||
namespace BCards.Web.Repositories;
|
namespace BCards.Web.Repositories;
|
||||||
|
|
||||||
@ -16,4 +17,15 @@ public interface IUserPageRepository
|
|||||||
Task<List<UserPage>> GetRecentPagesAsync(int limit = 10);
|
Task<List<UserPage>> GetRecentPagesAsync(int limit = 10);
|
||||||
Task<List<UserPage>> GetByCategoryAsync(string category, int limit = 20);
|
Task<List<UserPage>> GetByCategoryAsync(string category, int limit = 20);
|
||||||
Task UpdateAnalyticsAsync(string id, PageAnalytics analytics);
|
Task UpdateAnalyticsAsync(string id, PageAnalytics analytics);
|
||||||
|
Task<List<UserPage>> GetManyAsync(
|
||||||
|
FilterDefinition<UserPage> filter,
|
||||||
|
SortDefinition<UserPage>? sort = null,
|
||||||
|
int skip = 0,
|
||||||
|
int take = 20);
|
||||||
|
Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20);
|
||||||
|
Task<long> CountAsync(FilterDefinition<UserPage> filter);
|
||||||
|
Task<UpdateResult> UpdateAsync(string id, UpdateDefinition<UserPage> update);
|
||||||
|
Task<UpdateResult> UpdateManyAsync(FilterDefinition<UserPage> filter, UpdateDefinition<UserPage> update);
|
||||||
|
Task<bool> ApprovePageAsync(string pageId);
|
||||||
|
Task<bool> RejectPageAsync(string pageId, string reason, List<string> issues);
|
||||||
}
|
}
|
||||||
@ -116,4 +116,105 @@ public class UserPageRepository : IUserPageRepository
|
|||||||
.Set(x => x.UpdatedAt, DateTime.UtcNow)
|
.Set(x => x.UpdatedAt, DateTime.UtcNow)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adicione estes métodos no UserPageRepository.cs
|
||||||
|
|
||||||
|
public async Task<List<UserPage>> GetManyAsync(
|
||||||
|
FilterDefinition<UserPage> filter,
|
||||||
|
SortDefinition<UserPage>? sort = null,
|
||||||
|
int skip = 0,
|
||||||
|
int take = 20)
|
||||||
|
{
|
||||||
|
var query = _pages.Find(filter);
|
||||||
|
|
||||||
|
if (sort != null)
|
||||||
|
query = query.Sort(sort);
|
||||||
|
|
||||||
|
return await query.Skip(skip).Limit(take).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> CountAsync(FilterDefinition<UserPage> filter)
|
||||||
|
{
|
||||||
|
return await _pages.CountDocumentsAsync(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Método específico para moderação (mais simples)
|
||||||
|
public async Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20)
|
||||||
|
{
|
||||||
|
var filter = Builders<UserPage>.Filter.Eq(x => x.Status, BCards.Web.ViewModels.PageStatus.PendingModeration);
|
||||||
|
|
||||||
|
var sort = Builders<UserPage>.Sort
|
||||||
|
.Ascending("planLimitations.planType") // Premium primeiro
|
||||||
|
.Ascending(x => x.CreatedAt); // Mais antigos primeiro
|
||||||
|
|
||||||
|
return await _pages.Find(filter)
|
||||||
|
.Sort(sort)
|
||||||
|
.Skip(skip)
|
||||||
|
.Limit(take)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicione estes métodos no UserPageRepository.cs
|
||||||
|
|
||||||
|
public async Task<UpdateResult> UpdateAsync(string id, UpdateDefinition<UserPage> update)
|
||||||
|
{
|
||||||
|
var combinedUpdate = Builders<UserPage>.Update
|
||||||
|
.Combine(update, Builders<UserPage>.Update.Set(x => x.UpdatedAt, DateTime.UtcNow));
|
||||||
|
|
||||||
|
return await _pages.UpdateOneAsync(x => x.Id == id, combinedUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateResult> UpdateManyAsync(FilterDefinition<UserPage> filter, UpdateDefinition<UserPage> update)
|
||||||
|
{
|
||||||
|
var combinedUpdate = Builders<UserPage>.Update
|
||||||
|
.Combine(update, Builders<UserPage>.Update.Set(x => x.UpdatedAt, DateTime.UtcNow));
|
||||||
|
|
||||||
|
return await _pages.UpdateManyAsync(filter, combinedUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Métodos específicos para moderação (mais fáceis de usar)
|
||||||
|
public async Task<bool> ApprovePageAsync(string pageId)
|
||||||
|
{
|
||||||
|
var update = Builders<UserPage>.Update
|
||||||
|
.Set(x => x.Status, BCards.Web.ViewModels.PageStatus.Active)
|
||||||
|
.Set(x => x.ApprovedAt, DateTime.UtcNow)
|
||||||
|
.Set(x => x.PublishedAt, DateTime.UtcNow)
|
||||||
|
.Unset(x => x.PreviewToken)
|
||||||
|
.Unset(x => x.PreviewTokenExpiry)
|
||||||
|
.Set(x => x.UpdatedAt, DateTime.UtcNow);
|
||||||
|
|
||||||
|
var result = await _pages.UpdateOneAsync(x => x.Id == pageId, update);
|
||||||
|
return result.ModifiedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RejectPageAsync(string pageId, string reason, List<string> issues)
|
||||||
|
{
|
||||||
|
var page = await GetByIdAsync(pageId);
|
||||||
|
if (page == null) return false;
|
||||||
|
|
||||||
|
// Adicionar à história de moderação
|
||||||
|
var historyEntry = new ModerationHistory
|
||||||
|
{
|
||||||
|
Attempt = page.ModerationAttempts + 1,
|
||||||
|
Status = "rejected",
|
||||||
|
Reason = reason,
|
||||||
|
Date = DateTime.UtcNow,
|
||||||
|
Issues = issues
|
||||||
|
};
|
||||||
|
|
||||||
|
page.ModerationHistory.Add(historyEntry);
|
||||||
|
|
||||||
|
var update = Builders<UserPage>.Update
|
||||||
|
.Set(x => x.Status, BCards.Web.ViewModels.PageStatus.Rejected)
|
||||||
|
.Set(x => x.ModerationAttempts, page.ModerationAttempts + 1)
|
||||||
|
.Set(x => x.ModerationHistory, page.ModerationHistory)
|
||||||
|
.Unset(x => x.PreviewToken)
|
||||||
|
.Unset(x => x.PreviewTokenExpiry)
|
||||||
|
.Set(x => x.UpdatedAt, DateTime.UtcNow);
|
||||||
|
|
||||||
|
var result = await _pages.UpdateOneAsync(x => x.Id == pageId, update);
|
||||||
|
return result.ModifiedCount > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ using BCards.Web.Repositories;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using BCards.Web.Utils;
|
||||||
|
|
||||||
namespace BCards.Web.Services;
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
@ -22,7 +23,7 @@ public class CategoryService : ICategoryService
|
|||||||
|
|
||||||
public async Task<Category?> GetCategoryBySlugAsync(string slug)
|
public async Task<Category?> GetCategoryBySlugAsync(string slug)
|
||||||
{
|
{
|
||||||
return await _categoryRepository.GetBySlugAsync(slug);
|
return await _categoryRepository.GetBySlugAsync(SlugHelper.CreateSlug(slug));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GenerateSlugAsync(string name)
|
public async Task<string> GenerateSlugAsync(string name)
|
||||||
|
|||||||
207
src/BCards.Web/Services/EmailService.cs
Normal file
207
src/BCards.Web/Services/EmailService.cs
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
using SendGrid;
|
||||||
|
using SendGrid.Helpers.Mail;
|
||||||
|
|
||||||
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
|
public class EmailService : IEmailService
|
||||||
|
{
|
||||||
|
private readonly ISendGridClient _sendGridClient;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly ILogger<EmailService> _logger;
|
||||||
|
|
||||||
|
public EmailService(
|
||||||
|
ISendGridClient sendGridClient,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<EmailService> logger)
|
||||||
|
{
|
||||||
|
_sendGridClient = sendGridClient;
|
||||||
|
_configuration = configuration;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null)
|
||||||
|
{
|
||||||
|
var (subject, htmlContent) = status switch
|
||||||
|
{
|
||||||
|
"pending" => GetPendingModerationTemplate(userName, pageTitle, previewUrl),
|
||||||
|
"approved" => GetApprovedTemplate(userName, pageTitle),
|
||||||
|
"rejected" => GetRejectedTemplate(userName, pageTitle, reason),
|
||||||
|
_ => throw new ArgumentException($"Unknown status: {status}")
|
||||||
|
};
|
||||||
|
|
||||||
|
await SendEmailAsync(userEmail, subject, htmlContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName)
|
||||||
|
{
|
||||||
|
var moderatorEmail = _configuration["Moderation:ModeratorEmail"];
|
||||||
|
if (string.IsNullOrEmpty(moderatorEmail))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var priority = GetPriorityLabel(planType);
|
||||||
|
var subject = $"[{priority}] Nova página para moderação - {pageTitle}";
|
||||||
|
|
||||||
|
var htmlContent = $@"
|
||||||
|
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
||||||
|
<h2 style='color: #333;'>Nova página para moderação</h2>
|
||||||
|
<div style='background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
||||||
|
<p><strong>Título:</strong> {pageTitle}</p>
|
||||||
|
<p><strong>Usuário:</strong> {userName}</p>
|
||||||
|
<p><strong>Plano:</strong> {planType}</p>
|
||||||
|
<p><strong>Prioridade:</strong> <span style='color: {GetPriorityColor(planType)};'>{priority}</span></p>
|
||||||
|
<p><strong>ID da Página:</strong> {pageId}</p>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<a href='{_configuration["BaseUrl"]}/moderation/review/{pageId}'
|
||||||
|
style='background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;'>
|
||||||
|
Moderar Página
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>";
|
||||||
|
|
||||||
|
await SendEmailAsync(moderatorEmail, subject, htmlContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SendEmailAsync(string to, string subject, string htmlContent)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var from = new EmailAddress(
|
||||||
|
_configuration["SendGrid:FromEmail"] ?? "ricardo.carneiro@jobmaker.com.br",
|
||||||
|
_configuration["SendGrid:FromName"] ?? "BCards");
|
||||||
|
|
||||||
|
var toEmail = new EmailAddress(to);
|
||||||
|
var msg = MailHelper.CreateSingleEmail(from, toEmail, subject, null, htmlContent);
|
||||||
|
|
||||||
|
var response = await _sendGridClient.SendEmailAsync(msg);
|
||||||
|
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.Accepted)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Email sent successfully to {Email}", to);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var content = await response.Body.ReadAsStringAsync();
|
||||||
|
_logger.LogWarning("Failed to send email to {Email}. Status: {StatusCode}", to, response.StatusCode);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error sending email to {Email}", to);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private (string subject, string htmlContent) GetPendingModerationTemplate(string userName, string pageTitle, string? previewUrl)
|
||||||
|
{
|
||||||
|
var subject = "📋 Sua página está sendo analisada - bcards.site";
|
||||||
|
var previewButton = !string.IsNullOrEmpty(previewUrl)
|
||||||
|
? $"<p><a href='{previewUrl}' style='background: #28a745; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>Ver Preview</a></p>"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
var htmlContent = $@"
|
||||||
|
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
||||||
|
<h2 style='color: #333;'>Olá {userName}!</h2>
|
||||||
|
<p>Sua página <strong>'{pageTitle}'</strong> foi enviada para análise e estará disponível em breve!</p>
|
||||||
|
|
||||||
|
<div style='background: #e3f2fd; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
||||||
|
<p>🔍 <strong>Tempo estimado:</strong> 3-7 dias úteis</p>
|
||||||
|
<p>👀 <strong>Status:</strong> Em análise</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Nossa equipe verifica se o conteúdo segue nossos termos de uso para manter a qualidade da plataforma.</p>
|
||||||
|
|
||||||
|
{previewButton}
|
||||||
|
|
||||||
|
<hr style='margin: 30px 0;'>
|
||||||
|
<p style='color: #666; font-size: 14px;'>
|
||||||
|
Você receberá outro email assim que sua página for aprovada ou se precisar de ajustes.
|
||||||
|
</p>
|
||||||
|
</div>";
|
||||||
|
|
||||||
|
return (subject, htmlContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (string subject, string htmlContent) GetApprovedTemplate(string userName, string pageTitle)
|
||||||
|
{
|
||||||
|
var subject = "✅ Sua página foi aprovada! - bcards.site";
|
||||||
|
var htmlContent = $@"
|
||||||
|
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
||||||
|
<h2 style='color: #28a745;'>Parabéns {userName}! 🎉</h2>
|
||||||
|
<p>Sua página <strong>'{pageTitle}'</strong> foi aprovada e já está no ar!</p>
|
||||||
|
|
||||||
|
<div style='background: #d4edda; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
||||||
|
<p>✅ <strong>Status:</strong> Aprovada</p>
|
||||||
|
<p>🌐 <strong>Sua página está online!</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Agora você pode:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Compartilhar sua página nas redes sociais</li>
|
||||||
|
<li>Adicionar o link na sua bio</li>
|
||||||
|
<li>Acompanhar as estatísticas no painel</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href='{_configuration["BaseUrl"]}/admin/dashboard'
|
||||||
|
style='background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>
|
||||||
|
Acessar Painel
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>";
|
||||||
|
|
||||||
|
return (subject, htmlContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (string subject, string htmlContent) GetRejectedTemplate(string userName, string pageTitle, string? reason)
|
||||||
|
{
|
||||||
|
var subject = "⚠️ Sua página precisa de ajustes - bcards.site";
|
||||||
|
var reasonText = !string.IsNullOrEmpty(reason) ? $"<p><strong>Motivo:</strong> {reason}</p>" : "";
|
||||||
|
|
||||||
|
var htmlContent = $@"
|
||||||
|
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
||||||
|
<h2 style='color: #dc3545;'>Olá {userName}</h2>
|
||||||
|
<p>Sua página <strong>'{pageTitle}'</strong> não foi aprovada, mas você pode corrigir e reenviar!</p>
|
||||||
|
|
||||||
|
<div style='background: #f8d7da; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
||||||
|
<p>❌ <strong>Status:</strong> Necessita ajustes</p>
|
||||||
|
{reasonText}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Para que sua página seja aprovada, certifique-se de que:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Não contém conteúdo proibido ou suspeito</li>
|
||||||
|
<li>Todos os links estão funcionando</li>
|
||||||
|
<li>As informações são precisas</li>
|
||||||
|
<li>Segue nossos termos de uso</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href='{_configuration["BaseUrl"]}/admin/dashboard'
|
||||||
|
style='background: #dc3545; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>
|
||||||
|
Editar Página
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>";
|
||||||
|
|
||||||
|
return (subject, htmlContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetPriorityLabel(string planType) => planType.ToLower() switch
|
||||||
|
{
|
||||||
|
"premium" => "ALTA",
|
||||||
|
"professional" => "ALTA",
|
||||||
|
"basic" => "MÉDIA",
|
||||||
|
_ => "BAIXA"
|
||||||
|
};
|
||||||
|
|
||||||
|
private string GetPriorityColor(string planType) => planType.ToLower() switch
|
||||||
|
{
|
||||||
|
"premium" => "#dc3545",
|
||||||
|
"professional" => "#fd7e14",
|
||||||
|
"basic" => "#ffc107",
|
||||||
|
_ => "#6c757d"
|
||||||
|
};
|
||||||
|
}
|
||||||
8
src/BCards.Web/Services/IEmailService.cs
Normal file
8
src/BCards.Web/Services/IEmailService.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
|
public interface IEmailService
|
||||||
|
{
|
||||||
|
Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null);
|
||||||
|
Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName);
|
||||||
|
Task<bool> SendEmailAsync(string to, string subject, string htmlContent);
|
||||||
|
}
|
||||||
11
src/BCards.Web/Services/IModerationAuthService.cs
Normal file
11
src/BCards.Web/Services/IModerationAuthService.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace BCards.Web.Services
|
||||||
|
{
|
||||||
|
public interface IModerationAuthService
|
||||||
|
{
|
||||||
|
bool IsUserModerator(ClaimsPrincipal user);
|
||||||
|
bool IsEmailModerator(string email);
|
||||||
|
List<string> GetModeratorEmails();
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/BCards.Web/Services/IModerationService.cs
Normal file
19
src/BCards.Web/Services/IModerationService.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
|
||||||
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
|
public interface IModerationService
|
||||||
|
{
|
||||||
|
Task<string> GeneratePreviewTokenAsync(string pageId);
|
||||||
|
Task<bool> ValidatePreviewTokenAsync(string pageId, string token);
|
||||||
|
Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20);
|
||||||
|
Task<UserPage?> GetPageForModerationAsync(string pageId);
|
||||||
|
Task ApprovePageAsync(string pageId, string moderatorId, string? notes = null);
|
||||||
|
Task RejectPageAsync(string pageId, string moderatorId, string reason, List<string> issues);
|
||||||
|
Task<bool> CanUserCreatePageAsync(string userId);
|
||||||
|
Task<bool> IncrementPreviewViewAsync(string pageId);
|
||||||
|
Task<Dictionary<string, int>> GetModerationStatsAsync();
|
||||||
|
Task<List<UserPage>> GetModerationHistoryAsync(int skip = 0, int take = 20);
|
||||||
|
Task<UserPage?> GetPageByPreviewTokenAsync(string token);
|
||||||
|
Task DeleteForModerationAsync(string pageId);
|
||||||
|
}
|
||||||
39
src/BCards.Web/Services/ModerationAuthService.cs
Normal file
39
src/BCards.Web/Services/ModerationAuthService.cs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
using BCards.Web.Configuration;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace BCards.Web.Services
|
||||||
|
{
|
||||||
|
public class ModerationAuthService : IModerationAuthService
|
||||||
|
{
|
||||||
|
private readonly ModerationSettings _settings;
|
||||||
|
|
||||||
|
public ModerationAuthService(IOptions<ModerationSettings> settings)
|
||||||
|
{
|
||||||
|
_settings = settings.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsUserModerator(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
if (!user.Identity?.IsAuthenticated == true)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var email = user.FindFirst(ClaimTypes.Email)?.Value;
|
||||||
|
return IsEmailModerator(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsEmailModerator(string? email)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return _settings.ModeratorEmails
|
||||||
|
.Any(moderatorEmail => moderatorEmail.Equals(email, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> GetModeratorEmails()
|
||||||
|
{
|
||||||
|
return _settings.ModeratorEmails.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
219
src/BCards.Web/Services/ModerationService.cs
Normal file
219
src/BCards.Web/Services/ModerationService.cs
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.Repositories;
|
||||||
|
using BCards.Web.ViewModels;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
|
||||||
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
|
public class ModerationService : IModerationService
|
||||||
|
{
|
||||||
|
private readonly IUserPageRepository _userPageRepository;
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly ILogger<ModerationService> _logger;
|
||||||
|
|
||||||
|
public ModerationService(
|
||||||
|
IUserPageRepository userPageRepository,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ILogger<ModerationService> logger)
|
||||||
|
{
|
||||||
|
_userPageRepository = userPageRepository;
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GeneratePreviewTokenAsync(string pageId)
|
||||||
|
{
|
||||||
|
var token = Guid.NewGuid().ToString("N")[..16];
|
||||||
|
var expiry = DateTime.UtcNow.AddDays(30); // Token válido por 30 dias
|
||||||
|
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||||
|
page.PreviewToken = token;
|
||||||
|
page.PreviewTokenExpiry = expiry;
|
||||||
|
page.PreviewViewCount = 0;
|
||||||
|
|
||||||
|
await _userPageRepository.UpdateAsync(page);
|
||||||
|
|
||||||
|
_logger.LogInformation("Generated preview token for page {PageId}", pageId);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ValidatePreviewTokenAsync(string pageId, string token)
|
||||||
|
{
|
||||||
|
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||||
|
if (page == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var isValid = page.PreviewToken == token &&
|
||||||
|
page.PreviewTokenExpiry > DateTime.UtcNow &&
|
||||||
|
page.PreviewViewCount < 50;
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20)
|
||||||
|
{
|
||||||
|
var filter = Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.PendingModeration);
|
||||||
|
|
||||||
|
// Ordenar por prioridade do plano e depois por data
|
||||||
|
var sort = Builders<UserPage>.Sort
|
||||||
|
.Ascending("planLimitations.planType")
|
||||||
|
.Ascending(p => p.CreatedAt);
|
||||||
|
|
||||||
|
var pages = await _userPageRepository.GetManyAsync(filter, sort, skip, take);
|
||||||
|
return pages.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserPage?> GetPageForModerationAsync(string pageId)
|
||||||
|
{
|
||||||
|
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||||
|
if (page?.Status != PageStatus.PendingModeration)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteForModerationAsync(string pageId)
|
||||||
|
{
|
||||||
|
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||||
|
await _userPageRepository.DeleteAsync(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ApprovePageAsync(string pageId, string moderatorId, string? notes = null)
|
||||||
|
{
|
||||||
|
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||||
|
if (page == null)
|
||||||
|
throw new ArgumentException("Page not found");
|
||||||
|
|
||||||
|
var moderationEntry = new ModerationHistory
|
||||||
|
{
|
||||||
|
Attempt = page.ModerationAttempts + 1,
|
||||||
|
Status = "approved",
|
||||||
|
ModeratorId = moderatorId,
|
||||||
|
Date = DateTime.UtcNow,
|
||||||
|
Reason = notes
|
||||||
|
};
|
||||||
|
|
||||||
|
page.ModerationHistory.Add(moderationEntry);
|
||||||
|
|
||||||
|
var update = Builders<UserPage>.Update
|
||||||
|
.Set(p => p.Status, PageStatus.Active)
|
||||||
|
.Set(p => p.ApprovedAt, DateTime.UtcNow)
|
||||||
|
.Set(p => p.ModerationAttempts, page.ModerationAttempts + 1)
|
||||||
|
.Set(p => p.ModerationHistory, page.ModerationHistory)
|
||||||
|
.Set(p => p.PublishedAt, DateTime.UtcNow)
|
||||||
|
.Unset(p => p.PreviewToken)
|
||||||
|
.Unset(p => p.PreviewTokenExpiry);
|
||||||
|
|
||||||
|
await _userPageRepository.UpdateAsync(pageId, update);
|
||||||
|
_logger.LogInformation("Page {PageId} approved by moderator {ModeratorId}", pageId, moderatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RejectPageAsync(string pageId, string moderatorId, string reason, List<string> issues)
|
||||||
|
{
|
||||||
|
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||||
|
if (page == null)
|
||||||
|
throw new ArgumentException("Page not found");
|
||||||
|
|
||||||
|
var moderationEntry = new ModerationHistory
|
||||||
|
{
|
||||||
|
Attempt = page.ModerationAttempts + 1,
|
||||||
|
Status = "rejected",
|
||||||
|
ModeratorId = moderatorId,
|
||||||
|
Date = DateTime.UtcNow,
|
||||||
|
Reason = reason,
|
||||||
|
Issues = issues
|
||||||
|
};
|
||||||
|
|
||||||
|
page.ModerationHistory.Add(moderationEntry);
|
||||||
|
|
||||||
|
var newStatus = page.ModerationAttempts >= 2 ? PageStatus.Rejected : PageStatus.Inactive;
|
||||||
|
var userScoreDeduction = Math.Min(20, page.UserScore / 5);
|
||||||
|
|
||||||
|
var update = Builders<UserPage>.Update
|
||||||
|
.Set(p => p.Status, newStatus)
|
||||||
|
.Set(p => p.ModerationAttempts, page.ModerationAttempts + 1)
|
||||||
|
.Set(p => p.ModerationHistory, page.ModerationHistory)
|
||||||
|
.Set(p => p.UserScore, Math.Max(0, page.UserScore - userScoreDeduction))
|
||||||
|
.Unset(p => p.PreviewToken)
|
||||||
|
.Unset(p => p.PreviewTokenExpiry);
|
||||||
|
|
||||||
|
await _userPageRepository.UpdateAsync(pageId, update);
|
||||||
|
_logger.LogInformation("Page {PageId} rejected by moderator {ModeratorId}. Reason: {Reason}",
|
||||||
|
pageId, moderatorId, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CanUserCreatePageAsync(string userId)
|
||||||
|
{
|
||||||
|
var user = await _userRepository.GetByIdAsync(userId);
|
||||||
|
if (user == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
//var rejectedPages = userPages.Count(p => p.Status == PageStatus.Rejected);
|
||||||
|
|
||||||
|
var filter = Builders<UserPage>.Filter.Eq(p => p.UserId, userId);
|
||||||
|
filter &= Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Rejected);
|
||||||
|
|
||||||
|
var rejectedPages = await _userPageRepository.CountAsync(filter);
|
||||||
|
|
||||||
|
// Usuários com mais de 2 páginas rejeitadas não podem criar novas
|
||||||
|
return rejectedPages < 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IncrementPreviewViewAsync(string pageId)
|
||||||
|
{
|
||||||
|
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||||
|
if (page == null || page.PreviewViewCount >= 50)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var update = Builders<UserPage>.Update
|
||||||
|
.Inc(p => p.PreviewViewCount, 1);
|
||||||
|
|
||||||
|
await _userPageRepository.UpdateAsync(pageId, update);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<string, int>> GetModerationStatsAsync()
|
||||||
|
{
|
||||||
|
var stats = new Dictionary<string, int>();
|
||||||
|
|
||||||
|
var pendingCount = await _userPageRepository.CountAsync(
|
||||||
|
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.PendingModeration));
|
||||||
|
|
||||||
|
var approvedToday = await _userPageRepository.CountAsync(
|
||||||
|
Builders<UserPage>.Filter.And(
|
||||||
|
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Active),
|
||||||
|
Builders<UserPage>.Filter.Gte(p => p.ApprovedAt, DateTime.UtcNow.Date)));
|
||||||
|
|
||||||
|
var rejectedToday = await _userPageRepository.CountAsync(
|
||||||
|
Builders<UserPage>.Filter.And(
|
||||||
|
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Rejected),
|
||||||
|
Builders<UserPage>.Filter.Gte(p => p.UpdatedAt, DateTime.UtcNow.Date)));
|
||||||
|
|
||||||
|
stats["pending"] = (int)pendingCount;
|
||||||
|
stats["approvedToday"] = (int)approvedToday;
|
||||||
|
stats["rejectedToday"] = (int)rejectedToday;
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<UserPage>> GetModerationHistoryAsync(int skip = 0, int take = 20)
|
||||||
|
{
|
||||||
|
var filter = Builders<UserPage>.Filter.Or(
|
||||||
|
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Active),
|
||||||
|
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Rejected));
|
||||||
|
|
||||||
|
var sort = Builders<UserPage>.Sort.Descending(p => p.UpdatedAt);
|
||||||
|
|
||||||
|
var pages = await _userPageRepository.GetManyAsync(filter, sort, skip, take);
|
||||||
|
return pages.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserPage?> GetPageByPreviewTokenAsync(string token)
|
||||||
|
{
|
||||||
|
var filter = Builders<UserPage>.Filter.And(
|
||||||
|
Builders<UserPage>.Filter.Eq(p => p.PreviewToken, token),
|
||||||
|
Builders<UserPage>.Filter.Gt(p => p.PreviewTokenExpiry, DateTime.UtcNow));
|
||||||
|
|
||||||
|
var pages = await _userPageRepository.GetManyAsync(filter);
|
||||||
|
return pages.FirstOrDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/BCards.Web/Utils/ModerationMenuViewComponent.cs
Normal file
25
src/BCards.Web/Utils/ModerationMenuViewComponent.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace BCards.Web.Utils
|
||||||
|
{
|
||||||
|
public class ModerationMenuViewComponent : ViewComponent
|
||||||
|
{
|
||||||
|
private readonly IModerationAuthService _moderationAuth;
|
||||||
|
|
||||||
|
public ModerationMenuViewComponent(IModerationAuthService moderationAuth)
|
||||||
|
{
|
||||||
|
_moderationAuth = moderationAuth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IViewComponentResult Invoke()
|
||||||
|
{
|
||||||
|
var user = HttpContext.User;
|
||||||
|
var isModerator = user.Identity?.IsAuthenticated == true &&
|
||||||
|
_moderationAuth.IsUserModerator(user);
|
||||||
|
|
||||||
|
return View(isModerator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/BCards.Web/Utils/SlugHelper.cs
Normal file
116
src/BCards.Web/Utils/SlugHelper.cs
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace BCards.Web.Utils
|
||||||
|
{
|
||||||
|
public static class SlugHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Remove acentos e caracteres especiais, criando um slug limpo
|
||||||
|
/// </summary>
|
||||||
|
public static string RemoveAccents(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
// Normalizar para NFD (decompor caracteres acentuados)
|
||||||
|
var normalizedString = text.Normalize(NormalizationForm.FormD);
|
||||||
|
var stringBuilder = new StringBuilder();
|
||||||
|
|
||||||
|
// Filtrar apenas caracteres que não são marcas diacríticas
|
||||||
|
foreach (var c in normalizedString)
|
||||||
|
{
|
||||||
|
var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
|
||||||
|
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
|
||||||
|
{
|
||||||
|
stringBuilder.Append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stringBuilder.ToString().Normalize(NormalizationForm.FormC);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cria um slug limpo e URL-friendly
|
||||||
|
/// </summary>
|
||||||
|
public static string CreateSlug(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
// 1. Remover acentos
|
||||||
|
var slug = RemoveAccents(text);
|
||||||
|
|
||||||
|
// 2. Converter para minúsculas
|
||||||
|
slug = slug.ToLowerInvariant();
|
||||||
|
|
||||||
|
// 3. Substituir espaços e caracteres especiais por hífen
|
||||||
|
slug = Regex.Replace(slug, @"[^a-z0-9\s-]", "");
|
||||||
|
|
||||||
|
// 4. Substituir múltiplos espaços por hífen único
|
||||||
|
slug = Regex.Replace(slug, @"[\s-]+", "-");
|
||||||
|
|
||||||
|
// 5. Remover hífens do início e fim
|
||||||
|
slug = slug.Trim('-');
|
||||||
|
|
||||||
|
// 6. Limitar tamanho (opcional)
|
||||||
|
if (slug.Length > 50)
|
||||||
|
slug = slug.Substring(0, 50).TrimEnd('-');
|
||||||
|
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cria uma categoria limpa (sem acentos, minúscula)
|
||||||
|
/// </summary>
|
||||||
|
public static string CreateCategorySlug(string category)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(category))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var slug = RemoveAccents(category);
|
||||||
|
slug = slug.ToLowerInvariant();
|
||||||
|
slug = Regex.Replace(slug, @"[^a-z0-9]", "");
|
||||||
|
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dicionário de conversões comuns para categorias brasileiras
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<string, string> CategoryMappings = new()
|
||||||
|
{
|
||||||
|
{ "saúde", "saude" },
|
||||||
|
{ "educação", "educacao" },
|
||||||
|
{ "tecnologia", "tecnologia" },
|
||||||
|
{ "negócios", "negocios" },
|
||||||
|
{ "serviços", "servicos" },
|
||||||
|
{ "alimentação", "alimentacao" },
|
||||||
|
{ "construção", "construcao" },
|
||||||
|
{ "automóveis", "automoveis" },
|
||||||
|
{ "beleza", "beleza" },
|
||||||
|
{ "esportes", "esportes" },
|
||||||
|
{ "música", "musica" },
|
||||||
|
{ "fotografia", "fotografia" }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converte categoria com mapeamento personalizado
|
||||||
|
/// </summary>
|
||||||
|
public static string ConvertCategory(string category)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(category))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var lowerCategory = category.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
// Verificar mapeamento direto
|
||||||
|
if (CategoryMappings.ContainsKey(lowerCategory))
|
||||||
|
return CategoryMappings[lowerCategory];
|
||||||
|
|
||||||
|
// Fallback para conversão automática
|
||||||
|
return CreateCategorySlug(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/BCards.Web/Utils/ViewExtensions.cs
Normal file
14
src/BCards.Web/Utils/ViewExtensions.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
using BCards.Web.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace BCards.Web.Utils
|
||||||
|
{
|
||||||
|
public static class ViewExtensions
|
||||||
|
{
|
||||||
|
public static bool IsModerator(this ClaimsPrincipal user, IServiceProvider services)
|
||||||
|
{
|
||||||
|
var moderationAuth = services.GetRequiredService<IModerationAuthService>();
|
||||||
|
return moderationAuth.IsUserModerator(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -100,7 +100,10 @@ public class UserPageSummary
|
|||||||
public int TotalClicks { get; set; } = 0;
|
public int TotalClicks { get; set; } = 0;
|
||||||
public int TotalViews { get; set; } = 0;
|
public int TotalViews { get; set; } = 0;
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public string PublicUrl => $"/page/{Category.ToLower()}/{Slug.ToLower()}";
|
public string? PreviewToken { get; set; } = string.Empty;
|
||||||
|
public string PublicUrl => $"/page/{Category.ToLower()}/{Slug.ToLower()}?preview={PreviewToken}";
|
||||||
|
public PageStatus? LastModerationStatus { get; set; } = PageStatus.PendingModeration;
|
||||||
|
public string Motive { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PlanInfo
|
public class PlanInfo
|
||||||
@ -120,5 +123,7 @@ public enum PageStatus
|
|||||||
Active, // Funcionando normalmente
|
Active, // Funcionando normalmente
|
||||||
Expired, // Trial vencido -> 301 redirect
|
Expired, // Trial vencido -> 301 redirect
|
||||||
PendingPayment, // Pagamento atrasado -> aviso na página
|
PendingPayment, // Pagamento atrasado -> aviso na página
|
||||||
Inactive // Pausada pelo usuário
|
Inactive, // Pausada pelo usuário
|
||||||
|
PendingModeration = 4, // Aguardando moderação
|
||||||
|
Rejected = 5 // Rejeitada na moderação
|
||||||
}
|
}
|
||||||
81
src/BCards.Web/ViewModels/ModerationViewModel.cs
Normal file
81
src/BCards.Web/ViewModels/ModerationViewModel.cs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
|
||||||
|
namespace BCards.Web.ViewModels;
|
||||||
|
|
||||||
|
public class ModerationDashboardViewModel
|
||||||
|
{
|
||||||
|
public List<PendingPageViewModel> PendingPages { get; set; } = new();
|
||||||
|
public Dictionary<string, int> Stats { get; set; } = new();
|
||||||
|
public int CurrentPage { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
public bool HasNextPage { get; set; } = false;
|
||||||
|
public bool HasPreviousPage => CurrentPage > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ModerationPageViewModel
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public int ModerationAttempts { get; set; }
|
||||||
|
public string PlanType { get; set; } = string.Empty;
|
||||||
|
public string? PreviewUrl { get; set; }
|
||||||
|
public DateTime? ApprovedAt { get; set; }
|
||||||
|
public ModerationHistory? LastModerationEntry { get; set; }
|
||||||
|
|
||||||
|
public string PriorityLabel => PlanType.ToLower() switch
|
||||||
|
{
|
||||||
|
"premium" => "ALTA",
|
||||||
|
"professional" => "ALTA",
|
||||||
|
"basic" => "MÉDIA",
|
||||||
|
_ => "BAIXA"
|
||||||
|
};
|
||||||
|
|
||||||
|
public string PriorityColor => PlanType.ToLower() switch
|
||||||
|
{
|
||||||
|
"premium" => "danger",
|
||||||
|
"professional" => "warning",
|
||||||
|
"basic" => "info",
|
||||||
|
_ => "secondary"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ModerationReviewViewModel
|
||||||
|
{
|
||||||
|
public UserPage Page { get; set; } = new();
|
||||||
|
public User User { get; set; } = new();
|
||||||
|
public string? PreviewUrl { get; set; }
|
||||||
|
public List<ModerationCriterion> ModerationCriteria { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ModerationHistoryViewModel
|
||||||
|
{
|
||||||
|
public List<ModerationPageViewModel> Pages { get; set; } = new();
|
||||||
|
public int CurrentPage { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
public bool HasNextPage { get; set; } = false;
|
||||||
|
public bool HasPreviousPage => CurrentPage > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ModerationCriterion
|
||||||
|
{
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
public List<string> Items { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PendingPageViewModel
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public string DisplayName { get; set; } = "";
|
||||||
|
public string Slug { get; set; } = "";
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
public string PlanType { get; set; } = "";
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public int ModerationAttempts { get; set; }
|
||||||
|
public string PreviewUrl { get; set; } = "";
|
||||||
|
public string PriorityLabel { get; set; } = "";
|
||||||
|
public string PriorityColor { get; set; } = "";
|
||||||
|
}
|
||||||
@ -16,7 +16,7 @@
|
|||||||
@if (!string.IsNullOrEmpty(Model.CurrentUser.ProfileImage))
|
@if (!string.IsNullOrEmpty(Model.CurrentUser.ProfileImage))
|
||||||
{
|
{
|
||||||
<img src="@Model.CurrentUser.ProfileImage" alt="@Model.CurrentUser.Name"
|
<img src="@Model.CurrentUser.ProfileImage" alt="@Model.CurrentUser.Name"
|
||||||
class="rounded-circle" style="width: 60px; height: 60px; object-fit: cover;">
|
class="rounded-circle" style="width: 60px; height: 60px; object-fit: cover;">
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -28,11 +28,30 @@
|
|||||||
<div class="col-md-6 col-lg-4 mb-3">
|
<div class="col-md-6 col-lg-4 mb-3">
|
||||||
<div class="card h-100 @(page.Status == BCards.Web.ViewModels.PageStatus.Active ? "" : "border-warning")">
|
<div class="card h-100 @(page.Status == BCards.Web.ViewModels.PageStatus.Active ? "" : "border-warning")">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h6 class="card-title">@(page.DisplayName)</h6>
|
<h6 class="card-title">
|
||||||
|
@(page.DisplayName)
|
||||||
|
<form method="post" action="/Admin/DeletePage/@(page.Id)" style="display: inline;" onsubmit="return confirm('Tem certeza que deseja excluir esta página?')">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<button type="submit" class="btn btn-link text-danger p-0" title="Excluir página"
|
||||||
|
style="font-size: 12px; text-decoration: none;">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</h6>
|
||||||
<p class="text-muted small mb-2">@(page.Category)/@(page.Slug)</p>
|
<p class="text-muted small mb-2">@(page.Category)/@(page.Slug)</p>
|
||||||
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
@switch (page.Status)
|
@{
|
||||||
|
var pageStatus = page.Status;
|
||||||
|
if (page.Status == BCards.Web.ViewModels.PageStatus.Inactive)
|
||||||
|
{
|
||||||
|
if (page.LastModerationStatus.HasValue)
|
||||||
|
{
|
||||||
|
pageStatus = page.LastModerationStatus.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@switch (pageStatus)
|
||||||
{
|
{
|
||||||
case BCards.Web.ViewModels.PageStatus.Active:
|
case BCards.Web.ViewModels.PageStatus.Active:
|
||||||
<span class="badge bg-success">Ativa</span>
|
<span class="badge bg-success">Ativa</span>
|
||||||
@ -46,9 +65,14 @@
|
|||||||
case BCards.Web.ViewModels.PageStatus.Inactive:
|
case BCards.Web.ViewModels.PageStatus.Inactive:
|
||||||
<span class="badge bg-secondary">Inativa</span>
|
<span class="badge bg-secondary">Inativa</span>
|
||||||
break;
|
break;
|
||||||
|
case BCards.Web.ViewModels.PageStatus.PendingModeration:
|
||||||
|
<span class="badge bg-warning">Aguardando</span>
|
||||||
|
break;
|
||||||
|
case BCards.Web.ViewModels.PageStatus.Rejected:
|
||||||
|
<span class="badge bg-danger">Rejeitada</span>
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (Model.CurrentPlan.AllowsAnalytics)
|
@if (Model.CurrentPlan.AllowsAnalytics)
|
||||||
{
|
{
|
||||||
<div class="row text-center small mb-3">
|
<div class="row text-center small mb-3">
|
||||||
@ -65,13 +89,40 @@
|
|||||||
|
|
||||||
<div class="d-flex gap-1 flex-wrap">
|
<div class="d-flex gap-1 flex-wrap">
|
||||||
<a href="@Url.Action("ManagePage", new { id = page.Id })"
|
<a href="@Url.Action("ManagePage", new { id = page.Id })"
|
||||||
class="btn btn-sm btn-outline-primary flex-fill">Editar</a>
|
class="btn btn-sm btn-outline-primary flex-fill">Editar</a>
|
||||||
<a href="@(page.PublicUrl)" target="_blank"
|
<a href="@(page.PublicUrl)" target="_blank"
|
||||||
class="btn btn-sm btn-outline-success flex-fill">Ver</a>
|
class="btn btn-sm btn-outline-success flex-fill">Ver</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-transparent">
|
<div class="card-footer bg-transparent">
|
||||||
<small class="text-muted">Criada em @(page.CreatedAt.ToString("dd/MM/yyyy"))</small>
|
<small class="text-muted">Criada em @(page.CreatedAt.ToString("dd/MM/yyyy"))</small>
|
||||||
|
@if ((page.LastModerationStatus ?? page.Status) == BCards.Web.ViewModels.PageStatus.Rejected && !string.IsNullOrEmpty(page.Motive))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show mt-2" role="alert">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2 mt-1"></i>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<strong>Motivo da rejeição:</strong><br>
|
||||||
|
<small>@(page.Motive)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (page.LastModerationStatus == BCards.Web.ViewModels.PageStatus.Active && !string.IsNullOrEmpty(page.Motive))
|
||||||
|
{
|
||||||
|
<div class="alert alert-info alert-dismissible fade show mt-2" role="alert">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2 mt-1"></i>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<strong>Motivo:</strong><br>
|
||||||
|
<small>@(page.Motive)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1105,3 +1105,19 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (TempData["Error"] != null)
|
||||||
|
{
|
||||||
|
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||||
|
<div class="toast show" role="alert">
|
||||||
|
<div class="toast-header">
|
||||||
|
<i class="fas fa-exclamation-triangle text-warning me-2"></i>
|
||||||
|
<strong class="me-auto">Atenção</strong>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body">
|
||||||
|
@TempData["Error"]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|||||||
60
src/BCards.Web/Views/Moderation/Dashboard.cshtml
Normal file
60
src/BCards.Web/Views/Moderation/Dashboard.cshtml
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
@using BCards.Web.ViewModels
|
||||||
|
@model ModerationDashboardViewModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Dashboard de Moderação";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1>Dashboard de Moderação</h1>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h4>Sistema de Moderação</h4>
|
||||||
|
<p>Páginas pendentes: <strong>@Model.PendingPages.Count</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.PendingPages.Any())
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Páginas Pendentes</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Categoria</th>
|
||||||
|
<th>Criada em</th>
|
||||||
|
<th>Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var pageItem in Model.PendingPages)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@pageItem.DisplayName</td>
|
||||||
|
<td>@pageItem.Category</td>
|
||||||
|
<td>@pageItem.CreatedAt.ToString("dd/MM/yyyy")</td>
|
||||||
|
<td>
|
||||||
|
<a href="/moderation/review/@pageItem.Id" class="btn btn-sm btn-primary">
|
||||||
|
Moderar
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<h4>✅ Nenhuma página pendente!</h4>
|
||||||
|
<p>Todas as páginas foram processadas.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
83
src/BCards.Web/Views/Moderation/History.cshtml
Normal file
83
src/BCards.Web/Views/Moderation/History.cshtml
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
@using BCards.Web.ViewModels
|
||||||
|
@model ModerationHistoryViewModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Histórico de Moderação";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1>Histórico de Moderação</h1>
|
||||||
|
<a href="/moderation/dashboard" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-arrow-left"></i> Voltar
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h4>Histórico</h4>
|
||||||
|
<p>Páginas processadas: <strong>@Model.Pages.Count</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.Pages.Any())
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Páginas Processadas</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Categoria</th>
|
||||||
|
<th>Processada em</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var pageItem in Model.Pages)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
@if (pageItem.Status == "Active")
|
||||||
|
{
|
||||||
|
<span class="badge bg-success">Aprovada</span>
|
||||||
|
}
|
||||||
|
else if (pageItem.Status == "Rejected")
|
||||||
|
{
|
||||||
|
<span class="badge bg-danger">Rejeitada</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">@pageItem.Status</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>@pageItem.DisplayName</td>
|
||||||
|
<td>@pageItem.Category</td>
|
||||||
|
<td>
|
||||||
|
@if (pageItem.ApprovedAt.HasValue)
|
||||||
|
{
|
||||||
|
@pageItem.ApprovedAt.Value.ToString("dd/MM/yyyy")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted">Pendente</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<h4>📋 Nenhum histórico ainda</h4>
|
||||||
|
<p>Ainda não há páginas processadas.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
267
src/BCards.Web/Views/Moderation/Review.cshtml
Normal file
267
src/BCards.Web/Views/Moderation/Review.cshtml
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
@using BCards.Web.ViewModels
|
||||||
|
@model ModerationReviewViewModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Revisar Página";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1>Moderar Página</h1>
|
||||||
|
<a href="/moderation/dashboard" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left"></i> Voltar
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Informações da Página -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Informações da Página</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Nome:</strong> @Model.Page.DisplayName
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Categoria:</strong>
|
||||||
|
<span class="badge bg-light text-dark">@Model.Page.Category</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Slug:</strong> @Model.Page.Slug
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Tipo:</strong> @Model.Page.BusinessType
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Plano:</strong>
|
||||||
|
<span class="badge bg-info">@Model.Page.PlanLimitations.PlanType</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Criado em:</strong> @Model.Page.CreatedAt.ToString("dd/MM/yyyy HH:mm")
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Tentativas:</strong> @Model.Page.ModerationAttempts
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Total de Links:</strong> @Model.Page.Links.Count
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Informações do Usuário -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Informações do Usuário</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Nome:</strong> @Model.User.Name
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Email:</strong> @Model.User.Email
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Score:</strong> @Model.Page.UserScore
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Membro desde:</strong> @Model.User.CreatedAt.ToString("dd/MM/yyyy")
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview da Página -->
|
||||||
|
@if (!string.IsNullOrEmpty(Model.PreviewUrl))
|
||||||
|
{
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Preview da Página</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<a href="@Model.PreviewUrl" target="_blank" class="btn btn-info btn-block">
|
||||||
|
<i class="fas fa-external-link-alt"></i> Abrir Preview
|
||||||
|
</a>
|
||||||
|
<small class="text-muted mt-2 d-block">
|
||||||
|
Visualizações: @Model.Page.PreviewViewCount/50
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conteúdo da Página -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Conteúdo da Página</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Page.Bio))
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Biografia:</strong>
|
||||||
|
<p>@Model.Page.Bio</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Links (@Model.Page.Links.Count):</strong>
|
||||||
|
<div class="mt-2">
|
||||||
|
@foreach (var link in Model.Page.Links.OrderBy(l => l.Order))
|
||||||
|
{
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<strong>@link.Title</strong>
|
||||||
|
@if (link.Type == LinkType.Product)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success ms-2">Produto</span>
|
||||||
|
}
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">@link.Url</small>
|
||||||
|
@if (!string.IsNullOrEmpty(link.Description))
|
||||||
|
{
|
||||||
|
<br>
|
||||||
|
<small>@link.Description</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="@link.Url" target="_blank" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="fas fa-external-link-alt"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Critérios de Moderação -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Critérios de Moderação</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="moderationForm">
|
||||||
|
@foreach (var criterion in Model.ModerationCriteria)
|
||||||
|
{
|
||||||
|
<div class="mb-4">
|
||||||
|
<h6 class="text-danger">🚫 @criterion.Category</h6>
|
||||||
|
@foreach (var item in criterion.Items)
|
||||||
|
{
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="issues" value="@item" id="issue_@(item.GetHashCode())">
|
||||||
|
<label class="form-check-label" for="issue_@(item.GetHashCode())">
|
||||||
|
@item
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Histórico de Moderação -->
|
||||||
|
@if (Model.Page.ModerationHistory.Any())
|
||||||
|
{
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Histórico de Moderação</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@foreach (var history in Model.Page.ModerationHistory.OrderByDescending(h => h.Date))
|
||||||
|
{
|
||||||
|
<div class="mb-3 pb-3 border-bottom">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<strong>Tentativa @history.Attempt</strong>
|
||||||
|
<small class="text-muted">@history.Date.ToString("dd/MM/yyyy HH:mm")</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="badge bg-@(history.Status == "approved" ? "success" : "danger")">
|
||||||
|
@(history.Status == "approved" ? "Aprovada" : "Rejeitada")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(history.Reason))
|
||||||
|
{
|
||||||
|
<div class="mt-2">
|
||||||
|
<strong>Motivo:</strong> @history.Reason
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (history.Issues.Any())
|
||||||
|
{
|
||||||
|
<div class="mt-2">
|
||||||
|
<strong>Problemas:</strong>
|
||||||
|
<ul class="mb-0">
|
||||||
|
@foreach (var issue in history.Issues)
|
||||||
|
{
|
||||||
|
<li>@issue</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Ações de Moderação -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Ações de Moderação</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<form asp-action="Approve" asp-route-id="@Model.Page.Id" method="post">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="notes" class="form-label">Notas (opcional)</label>
|
||||||
|
<textarea class="form-control" id="notes" name="notes" rows="3"
|
||||||
|
placeholder="Observações sobre a aprovação..."></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success btn-block">
|
||||||
|
<i class="fas fa-check"></i> Aprovar Página
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<form asp-action="Reject" asp-route-id="@Model.Page.Id" method="post">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="reason" class="form-label">Motivo da Rejeição *</label>
|
||||||
|
<textarea class="form-control" id="reason" name="reason" rows="3"
|
||||||
|
placeholder="Explique o motivo da rejeição..." required></textarea>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="selectedIssues" name="issues" value="">
|
||||||
|
<button type="submit" class="btn btn-danger btn-block">
|
||||||
|
<i class="fas fa-times"></i> Rejeitar Página
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
document.querySelector('form[asp-action="Reject"]').addEventListener('submit', function(e) {
|
||||||
|
const checkedIssues = Array.from(document.querySelectorAll('input[name="issues"]:checked'))
|
||||||
|
.map(cb => cb.value);
|
||||||
|
document.getElementById('selectedIssues').value = JSON.stringify(checkedIssues);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
@model bool
|
||||||
|
|
||||||
|
@if (Model)
|
||||||
|
{
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle text-warning fw-bold" href="#" id="moderationDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="fas fa-shield-alt me-1"></i>Moderação
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="moderationDropdown">
|
||||||
|
<li>
|
||||||
|
<h6 class="dropdown-header">
|
||||||
|
<i class="fas fa-shield-alt me-2"></i>Área de Moderação
|
||||||
|
</h6>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="/moderation/dashboard">
|
||||||
|
<i class="fas fa-tachometer-alt me-2"></i>Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="/moderation/history">
|
||||||
|
<i class="fas fa-history me-2"></i>Histórico
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item text-muted small" href="#" onclick="return false;">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>Você é um moderador
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
@ -57,21 +57,30 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Pricing">Planos</a>
|
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Pricing">Planos</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
@* Menu de Moderação via ViewComponent *@
|
||||||
|
@await Component.InvokeAsync("ModerationMenu")
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
@if (User.Identity?.IsAuthenticated == true)
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-area="" asp-controller="Admin" asp-action="Dashboard">Dashboard</a>
|
<a class="nav-link text-dark" asp-area="" asp-controller="Admin" asp-action="Dashboard">
|
||||||
|
<i class="fas fa-user me-1"></i>Dashboard
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Logout">Sair</a>
|
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Logout">
|
||||||
|
<i class="fas fa-sign-out-alt me-1"></i>Sair
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Login">Entrar</a>
|
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Login">
|
||||||
|
<i class="fas fa-sign-in-alt me-1"></i>Entrar
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
30
src/BCards.Web/Views/UserPage/PageRejected.cshtml
Normal file
30
src/BCards.Web/Views/UserPage/PageRejected.cshtml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "Página Rejeitada";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="fas fa-times-circle text-danger fa-4x"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="h4 mb-3">Página Rejeitada</h2>
|
||||||
|
<p class="lead mb-4">
|
||||||
|
Esta página foi rejeitada na moderação e não está disponível publicamente.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
O conteúdo não atende aos nossos termos de uso ou padrões de qualidade.
|
||||||
|
<br>
|
||||||
|
<strong>Proprietário:</strong> Verifique seu painel para mais detalhes
|
||||||
|
</p>
|
||||||
|
<a href="/" class="btn btn-primary">
|
||||||
|
<i class="fas fa-home"></i> Voltar ao Início
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
34
src/BCards.Web/Views/UserPage/PendingModeration.cshtml
Normal file
34
src/BCards.Web/Views/UserPage/PendingModeration.cshtml
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "Página em Análise";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="fas fa-hourglass-half text-warning fa-4x"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="h4 mb-3">Página em Análise</h2>
|
||||||
|
<p class="lead mb-4">
|
||||||
|
Esta página está sendo analisada por nossa equipe de moderação.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Estamos verificando se o conteúdo segue nossos termos de uso para manter a qualidade da plataforma.
|
||||||
|
<br>
|
||||||
|
<strong>Tempo estimado:</strong> 3-7 dias úteis
|
||||||
|
</p>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<strong>Proprietário da página:</strong> Verifique seu email para o link de preview
|
||||||
|
</div>
|
||||||
|
<a href="/" class="btn btn-primary">
|
||||||
|
<i class="fas fa-home"></i> Voltar ao Início
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
38
src/BCards.Web/Views/UserPage/PreviewExpired.cshtml
Normal file
38
src/BCards.Web/Views/UserPage/PreviewExpired.cshtml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "Preview Expirado";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="fas fa-clock text-warning fa-4x"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="h4 mb-3">Preview Expirado</h2>
|
||||||
|
<p class="lead mb-4">
|
||||||
|
O link de preview que você acessou não é mais válido.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Isso pode acontecer se:
|
||||||
|
<br>
|
||||||
|
• O link expirou (30 dias)
|
||||||
|
<br>
|
||||||
|
• Excedeu o limite de visualizações (50)
|
||||||
|
<br>
|
||||||
|
• A página já foi processada
|
||||||
|
</p>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<strong>Proprietário:</strong> Acesse seu painel para ver o status atual
|
||||||
|
</div>
|
||||||
|
<a href="/" class="btn btn-primary">
|
||||||
|
<i class="fas fa-home"></i> Voltar ao Início
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -44,5 +44,25 @@
|
|||||||
"MaxLinks": -1,
|
"MaxLinks": -1,
|
||||||
"Features": [ "custom_themes", "full_analytics", "multiple_domains", "priority_support" ]
|
"Features": [ "custom_themes", "full_analytics", "multiple_domains", "priority_support" ]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"Moderation": {
|
||||||
|
"PriorityTimeframes": {
|
||||||
|
"Trial": "7.00:00:00",
|
||||||
|
"Basic": "7.00:00:00",
|
||||||
|
"Professional": "3.00:00:00",
|
||||||
|
"Premium": "1.00:00:00"
|
||||||
|
},
|
||||||
|
"MaxAttempts": 3,
|
||||||
|
"ModeratorEmail": "ricardo.carneiro@jobmaker.com.br",
|
||||||
|
"ModeratorEmails": [
|
||||||
|
"rrcgoncalves@gmail.com",
|
||||||
|
"rirocarneiro@gmail.com"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SendGrid": {
|
||||||
|
"ApiKey": "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",
|
||||||
|
"FromEmail": "ricardo.carneiro@jobmaker.com.br",
|
||||||
|
"FromName": "Ricardo Carneiro"
|
||||||
|
},
|
||||||
|
"BaseUrl": "https://bcards.site"
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user