Init Commit
This commit is contained in:
526
Backend/MotoresArgentinosV2.API/Controllers/AdminController.cs
Normal file
526
Backend/MotoresArgentinosV2.API/Controllers/AdminController.cs
Normal file
@@ -0,0 +1,526 @@
|
||||
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." });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user