527 lines
19 KiB
C#
527 lines
19 KiB
C#
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,
|
|
|
|
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,
|
|
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." });
|
|
}
|
|
}
|