Files

529 lines
19 KiB
C#
Raw Permalink Normal View History

2026-01-29 13:43:44 -03:00
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Infrastructure.Data;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Core.Interfaces;
using Microsoft.AspNetCore.Authorization;
using MotoresArgentinosV2.Core.DTOs;
namespace MotoresArgentinosV2.API.Controllers;
[Authorize(Roles = "Admin")]
[ApiController]
[Route("api/[controller]")]
public class AdminController : ControllerBase
{
private readonly MotoresV2DbContext _context;
private readonly IAdSyncService _syncService;
private readonly INotificationService _notificationService;
public AdminController(MotoresV2DbContext context, IAdSyncService syncService, INotificationService notificationService)
{
_context = context;
_syncService = syncService;
_notificationService = notificationService;
}
// --- MODERACIÓN ---
// GESTIÓN GLOBAL DE AVISOS (Buscador Admin)
[HttpGet("ads")]
public async Task<IActionResult> GetAllAds(
[FromQuery] string? q = null,
[FromQuery] int? statusId = null,
[FromQuery] int page = 1)
{
const int pageSize = 20;
var query = _context.Ads
.Include(a => a.Brand)
.Include(a => a.User)
.Include(a => a.Photos) // Para mostrar la miniatura
.AsNoTracking() // Optimización de lectura
.AsQueryable();
// Filtro por Texto (Marca, Modelo, Email Usuario, Nombre Usuario)
if (!string.IsNullOrEmpty(q))
{
query = query.Where(a =>
a.VersionName!.Contains(q) ||
a.Brand.Name.Contains(q) ||
a.User.Email.Contains(q) ||
a.User.UserName.Contains(q)
);
}
// Filtro por Estado
if (statusId.HasValue)
{
query = query.Where(a => a.StatusID == statusId.Value);
}
var total = await query.CountAsync();
var ads = await query
.OrderByDescending(a => a.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(a => new
{
a.AdID,
BrandName = a.Brand != null ? a.Brand.Name : null,
VersionName = a.VersionName,
StatusID = a.StatusID,
IsFeatured = a.IsFeatured,
2026-01-29 13:43:44 -03:00
CreatedAt = a.CreatedAt,
PublishedAt = a.PublishedAt,
ExpiresAt = a.ExpiresAt,
DeletedAt = a.DeletedAt,
Views = a.ViewsCounter,
LegacyID = a.LegacyAdID,
// Datos de la transacción APROBADA más reciente
PaidAmount = _context.Transactions
.Where(t => t.AdID == a.AdID && t.Status == "APPROVED")
.OrderByDescending(t => t.CreatedAt)
.Select(t => (decimal?)t.Amount) // Cast a nullable para detectar si no hubo pago
.FirstOrDefault(),
PaidDate = _context.Transactions
.Where(t => t.AdID == a.AdID && t.Status == "APPROVED")
.OrderByDescending(t => t.CreatedAt)
.Select(t => (DateTime?)t.CreatedAt)
.FirstOrDefault(),
UserEmail = a.User.Email,
UserName = a.User.UserName,
Thumbnail = a.Photos.Where(p => p.IsCover).Select(p => p.FilePath).FirstOrDefault()
?? a.Photos.Select(p => p.FilePath).FirstOrDefault()
})
.ToListAsync();
return Ok(new { ads, total, page, pageSize });
}
[HttpGet("ads/pending")]
public async Task<IActionResult> GetPendingAds()
{
var ads = await _context.Ads
.Include(a => a.User)
.Include(a => a.Photos)
.Where(a => a.StatusID == (int)AdStatusEnum.ModerationPending)
.OrderByDescending(a => a.CreatedAt)
.Select(a => new
{
a.AdID,
VersionName = a.Brand != null ? $"{a.Brand.Name} {a.VersionName}" : a.VersionName,
a.Price,
a.Currency,
a.CreatedAt,
UserID = a.UserID,
UserName = a.User.UserName,
Email = a.User.Email,
IsFeatured = a.IsFeatured,
2026-01-29 13:43:44 -03:00
Thumbnail = a.Photos.Where(p => p.IsCover).Select(p => p.FilePath).FirstOrDefault() ?? a.Photos.Select(p => p.FilePath).FirstOrDefault()
})
.ToListAsync();
return Ok(ads);
}
[HttpPost("ads/{id}/approve")]
public async Task<IActionResult> ApproveAd(int id)
{
var ad = await _context.Ads.Include(a => a.User).Include(a => a.Brand).FirstOrDefaultAsync(a => a.AdID == id);
if (ad == null) return NotFound();
ad.StatusID = (int)AdStatusEnum.Active;
ad.PublishedAt = DateTime.UtcNow;
ad.ExpiresAt = DateTime.UtcNow.AddDays(30);
await _context.SaveChangesAsync();
// Audit Log
var adminId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
_context.AuditLogs.Add(new AuditLog
{
Action = "AD_APPROVED",
Entity = "Ad",
EntityID = id,
UserID = adminId,
Details = $"Aviso '{ad.Brand?.Name} {ad.VersionName}' aprobado por administrador."
});
await _context.SaveChangesAsync();
// Sincronizar a Legacy
try
{
await _syncService.SyncAdToLegacyAsync(id);
var adTitle = $"{ad.Brand?.Name} {ad.VersionName}";
await _notificationService.SendAdStatusChangedEmailAsync(ad.User?.Email ?? string.Empty, adTitle, "APROBADO");
}
catch (Exception)
{
// Logueamos pero no bloqueamos la aprobación en V2
}
return Ok(new { message = "Aviso aprobado y publicado." });
}
[HttpPost("ads/{id}/reject")]
public async Task<IActionResult> RejectAd(int id, [FromBody] string reason)
{
var ad = await _context.Ads.Include(a => a.User).Include(a => a.Brand).FirstOrDefaultAsync(a => a.AdID == id);
if (ad == null) return NotFound();
ad.StatusID = (int)AdStatusEnum.Rejected;
await _context.SaveChangesAsync();
// Audit Log
var adminId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
_context.AuditLogs.Add(new AuditLog
{
Action = "AD_REJECTED",
Entity = "Ad",
EntityID = id,
UserID = adminId,
Details = $"Aviso '{ad.Brand?.Name} {ad.VersionName}' rechazado. Motivo: {reason}"
});
await _context.SaveChangesAsync();
// Notificar rechazo
var adTitle = $"{ad.Brand?.Name} {ad.VersionName}";
await _notificationService.SendAdStatusChangedEmailAsync(ad.User?.Email ?? string.Empty, adTitle, "RECHAZADO", reason);
return Ok(new { message = "Aviso rechazado." });
}
// --- TRANSACCIONES ---
[HttpGet("transactions")]
public async Task<IActionResult> GetTransactions(
[FromQuery] string? status = null,
[FromQuery] string? userSearch = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] int page = 1)
{
const int pageSize = 20;
var query = from t in _context.Transactions
join a in _context.Ads on t.AdID equals a.AdID into adGroup
from ad in adGroup.DefaultIfEmpty() // Left Join con Ads
join u in _context.Users on ad.UserID equals u.UserID into userGroup
from user in userGroup.DefaultIfEmpty() // Left Join con Users
select new { t, ad, user };
// 1. Filtro por Estado
if (!string.IsNullOrEmpty(status))
{
query = query.Where(x => x.t.Status == status);
}
// 2. Filtro por Usuario (Búsqueda)
if (!string.IsNullOrEmpty(userSearch))
{
// Solo buscamos si el usuario existe (no es nulo)
query = query.Where(x =>
x.user != null &&
(x.user.Email.Contains(userSearch) || x.user.UserName.Contains(userSearch))
);
}
// 3. Filtro por Fechas
if (fromDate.HasValue)
{
query = query.Where(x => x.t.CreatedAt >= fromDate.Value);
}
if (toDate.HasValue)
{
var toDateEndOfDay = toDate.Value.Date.AddHours(23).AddMinutes(59).AddSeconds(59);
query = query.Where(x => x.t.CreatedAt <= toDateEndOfDay);
}
// 4. Paginación y Ejecución
var total = await query.CountAsync();
var txs = await query
.OrderByDescending(x => x.t.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.t.TransactionID,
x.t.OperationCode,
x.t.Amount,
x.t.Status,
x.t.CreatedAt,
x.t.UpdatedAt,
AdID = x.t.AdID,
// LÓGICA DE PRIORIDAD: Snapshot > Relación Viva > Default
UserID = (x.ad != null) ? x.ad.UserID : 0,
UserName = !string.IsNullOrEmpty(x.t.SnapshotUserName)
? x.t.SnapshotUserName
: ((x.user != null) ? x.user.UserName : "ELIMINADO"),
UserEmail = !string.IsNullOrEmpty(x.t.SnapshotUserEmail)
? x.t.SnapshotUserEmail
: ((x.user != null) ? x.user.Email : "-"),
AdTitle = !string.IsNullOrEmpty(x.t.SnapshotAdTitle)
? x.t.SnapshotAdTitle
: ((x.ad != null && x.ad.Brand != null) ? $"{x.ad.Brand.Name} {x.ad.VersionName}" : "Vehículo Desconocido/Eliminado")
})
.ToListAsync();
return Ok(new { transactions = txs, total, page, pageSize });
}
// --- USUARIOS ---
[HttpGet("users")]
public async Task<IActionResult> GetUsers([FromQuery] string? q = null, [FromQuery] int page = 1)
{
const int pageSize = 20;
var query = _context.Users.AsQueryable();
if (!string.IsNullOrEmpty(q))
{
query = query.Where(u => (u.Email ?? "").Contains(q) ||
(u.UserName ?? "").Contains(q) ||
(u.FirstName ?? "").Contains(q) ||
(u.LastName ?? "").Contains(q));
}
var total = await query.CountAsync();
var users = await query
.OrderByDescending(u => u.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(u => new
{
u.UserID,
u.UserName,
u.Email,
u.FirstName,
u.LastName,
u.UserType,
u.MigrationStatus,
u.IsBlocked,
u.CreatedAt
})
.ToListAsync();
return Ok(new { users, total, page, pageSize });
}
[HttpGet("users/{id}")]
public async Task<IActionResult> GetUserDetail(int id)
{
var user = await _context.Users
.Where(u => u.UserID == id)
.Select(u => new UserDetailDto
{
UserID = u.UserID,
UserName = u.UserName,
Email = u.Email,
FirstName = u.FirstName,
LastName = u.LastName,
PhoneNumber = u.PhoneNumber,
UserType = u.UserType,
IsBlocked = u.IsBlocked,
MigrationStatus = u.MigrationStatus,
IsEmailVerified = u.IsEmailVerified,
CreatedAt = u.CreatedAt
})
.FirstOrDefaultAsync();
if (user == null) return NotFound();
return Ok(user);
}
[HttpPut("users/{id}")]
public async Task<IActionResult> UpdateUser(int id, [FromBody] UserUpdateDto dto)
{
var user = await _context.Users.FindAsync(id);
if (user == null) return NotFound();
user.UserName = dto.UserName;
user.FirstName = dto.FirstName;
user.LastName = dto.LastName;
user.PhoneNumber = dto.PhoneNumber;
user.UserType = dto.UserType;
await _context.SaveChangesAsync();
// Audit Log
var adminId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
_context.AuditLogs.Add(new AuditLog
{
Action = "USER_UPDATED",
Entity = "User",
EntityID = id,
UserID = adminId,
Details = $"Usuario '{user.UserName}' actualizado por administrador."
});
await _context.SaveChangesAsync();
return Ok(new { message = "Usuario actualizado correctamente." });
}
[HttpGet("stats")]
public async Task<IActionResult> GetStats()
{
var stats = new
{
// 1. Usamos AuditLogs para contar TODAS las creaciones históricas,
// incluso si el aviso ya fue borrado físicamente de la tabla Ads.
TotalAds = await _context.AuditLogs.CountAsync(l => l.Action == "AD_CREATED"),
// 2. Avisos actualmente activos (Status = 4)
ActiveAds = await _context.Ads.CountAsync(a => a.StatusID == (int)AdStatusEnum.Active),
// 3. Pendientes de moderación (Status = 3)
PendingAds = await _context.Ads.CountAsync(a => a.StatusID == (int)AdStatusEnum.ModerationPending),
// 4. Total de Usuarios
TotalUsers = await _context.Users.CountAsync(),
// 5. Mensajes NO LEÍDOS (IsRead = false)
UnreadMessages = await _context.ChatMessages.CountAsync(m => !m.IsRead)
};
return Ok(stats);
}
[HttpGet("audit")]
public async Task<IActionResult> GetAuditLogs(
[FromQuery] string? actionType = null,
[FromQuery] string? entity = null,
[FromQuery] int? userId = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] int page = 1)
{
const int pageSize = 50;
var query = _context.AuditLogs.AsQueryable();
if (!string.IsNullOrEmpty(actionType))
query = query.Where(l => l.Action == actionType);
if (!string.IsNullOrEmpty(entity))
query = query.Where(l => l.Entity == entity);
if (userId.HasValue)
query = query.Where(l => l.UserID == userId.Value);
if (fromDate.HasValue)
query = query.Where(l => l.CreatedAt >= fromDate.Value);
if (toDate.HasValue)
query = query.Where(l => l.CreatedAt <= toDate.Value);
var total = await query.CountAsync();
var logs = await query
.OrderByDescending(l => l.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(l => new
{
l.AuditLogID,
l.Action,
l.Entity,
l.EntityID,
l.UserID,
l.Details,
l.CreatedAt,
UserName = l.UserID == 0 ? "Sistema" : _context.Users.Where(u => u.UserID == l.UserID).Select(u => u.UserName).FirstOrDefault() ?? "Desconocido"
})
.ToListAsync();
return Ok(new { logs, total, page, pageSize });
}
// BÚSQUEDA DE USUARIOS PARA ASIGNAR AVISO
[HttpGet("users/search")]
public async Task<IActionResult> SearchUsers([FromQuery] string q)
{
if (string.IsNullOrEmpty(q)) return Ok(new List<object>());
var users = await _context.Users
.Where(u => (u.Email ?? "").Contains(q) || (u.UserName ?? "").Contains(q) || (u.FirstName ?? "").Contains(q) || (u.LastName ?? "").Contains(q))
.Take(10)
.Select(u => new { u.UserID, u.UserName, u.Email, u.FirstName, u.LastName, u.PhoneNumber, u.IsBlocked })
.ToListAsync();
return Ok(users);
}
// GESTIÓN DE BLOQUEO
[HttpPost("users/{id}/toggle-block")]
public async Task<IActionResult> ToggleBlockUser(int id)
{
var user = await _context.Users.FindAsync(id);
if (user == null) return NotFound();
// Evitar autobloqueo
var currentAdminId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
if (user.UserID == currentAdminId) return BadRequest("No puedes bloquearte a ti mismo.");
user.IsBlocked = !user.IsBlocked;
// 📝 AUDITORÍA
_context.AuditLogs.Add(new AuditLog
{
Action = user.IsBlocked ? "USER_BLOCKED" : "USER_UNBLOCKED",
Entity = "User",
EntityID = user.UserID,
UserID = currentAdminId,
Details = $"Usuario '{user.UserName}' {(user.IsBlocked ? "bloqueado" : "desbloqueado")} por administrador."
});
await _context.SaveChangesAsync();
return Ok(new { message = user.IsBlocked ? "Usuario bloqueado" : "Usuario desbloqueado", isBlocked = user.IsBlocked });
}
// REPUBLICAR AVISO VENCIDO
[HttpPost("ads/{id}/republish")]
public async Task<IActionResult> RepublishAd(int id)
{
var ad = await _context.Ads.Include(a => a.Brand).FirstOrDefaultAsync(a => a.AdID == id);
if (ad == null) return NotFound();
// Verificación de seguridad: solo se pueden republicar avisos vencidos.
if (ad.StatusID != (int)AdStatusEnum.Expired)
{
return BadRequest("Solo se pueden republicar avisos que se encuentren vencidos.");
}
// Actualizamos el estado y las fechas para un nuevo ciclo de 30 días.
ad.StatusID = (int)AdStatusEnum.Active;
ad.PublishedAt = DateTime.UtcNow; // La nueva fecha de publicación es ahora.
ad.ExpiresAt = DateTime.UtcNow.AddDays(30);
ad.ExpirationWarningSent = false; // Reseteamos la advertencia de expiración.
// Audit Log
var adminId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
_context.AuditLogs.Add(new AuditLog
{
Action = "AD_REPUBLISHED_BY_ADMIN",
Entity = "Ad",
EntityID = id,
UserID = adminId,
Details = $"Aviso ID {id} ('{ad.Brand?.Name} {ad.VersionName}') republicado por administrador."
});
await _context.SaveChangesAsync();
// Aquí podrías agregar una sincronización con el sistema legacy si fuera necesario.
// await _syncService.SyncAdToLegacyAsync(id);
return Ok(new { message = "Aviso republicado y activado por 30 días." });
}
}