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." });
|
||||
}
|
||||
}
|
||||
816
Backend/MotoresArgentinosV2.API/Controllers/AdsV2Controller.cs
Normal file
816
Backend/MotoresArgentinosV2.API/Controllers/AdsV2Controller.cs
Normal file
@@ -0,0 +1,816 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MotoresArgentinosV2.Infrastructure.Data;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
using MotoresArgentinosV2.Infrastructure.Services;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace MotoresArgentinosV2.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AdsV2Controller : ControllerBase
|
||||
{
|
||||
private readonly MotoresV2DbContext _context;
|
||||
private readonly IImageStorageService _imageService;
|
||||
private readonly IIdentityService _identityService;
|
||||
|
||||
public AdsV2Controller(MotoresV2DbContext context, IImageStorageService imageService, IIdentityService identityService)
|
||||
{
|
||||
_context = context;
|
||||
_imageService = imageService;
|
||||
_identityService = identityService;
|
||||
}
|
||||
|
||||
private int GetCurrentUserId()
|
||||
{
|
||||
return int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
|
||||
}
|
||||
|
||||
private bool IsUserAdmin()
|
||||
{
|
||||
return User.IsInRole("Admin");
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] string? q,
|
||||
[FromQuery] string? c,
|
||||
[FromQuery] int? minPrice,
|
||||
[FromQuery] int? maxPrice,
|
||||
[FromQuery] string? currency,
|
||||
[FromQuery] int? minYear,
|
||||
[FromQuery] int? maxYear,
|
||||
[FromQuery] int? userId,
|
||||
[FromQuery] int? brandId,
|
||||
[FromQuery] int? modelId,
|
||||
[FromQuery] string? fuel,
|
||||
[FromQuery] string? transmission,
|
||||
[FromQuery] string? color,
|
||||
[FromQuery] bool? isFeatured)
|
||||
{
|
||||
var query = _context.Ads
|
||||
.Include(a => a.Photos)
|
||||
.Include(a => a.Features)
|
||||
.Include(a => a.Brand)
|
||||
.Include(a => a.Model)
|
||||
.AsQueryable();
|
||||
|
||||
if (isFeatured.HasValue) query = query.Where(a => a.IsFeatured == isFeatured.Value);
|
||||
|
||||
if (brandId.HasValue) query = query.Where(a => a.BrandID == brandId.Value);
|
||||
if (modelId.HasValue) query = query.Where(a => a.ModelID == modelId.Value);
|
||||
|
||||
if (!string.IsNullOrEmpty(currency))
|
||||
{
|
||||
query = query.Where(a => a.Currency == currency);
|
||||
}
|
||||
|
||||
if (minPrice.HasValue) query = query.Where(a => a.Price >= minPrice.Value);
|
||||
if (maxPrice.HasValue) query = query.Where(a => a.Price <= maxPrice.Value);
|
||||
|
||||
if (!string.IsNullOrEmpty(fuel))
|
||||
query = query.Where(a => a.FuelType == fuel || a.Features.Any(f => f.FeatureKey == "Combustible" && f.FeatureValue == fuel));
|
||||
|
||||
if (!string.IsNullOrEmpty(transmission))
|
||||
query = query.Where(a => a.Transmission == transmission || a.Features.Any(f => f.FeatureKey == "Transmision" && f.FeatureValue == transmission));
|
||||
|
||||
if (!string.IsNullOrEmpty(color))
|
||||
query = query.Where(a => a.Color == color || a.Features.Any(f => f.FeatureKey == "Color" && f.FeatureValue == color));
|
||||
|
||||
if (!string.IsNullOrEmpty(Request.Query["segment"]))
|
||||
{
|
||||
var segment = Request.Query["segment"].ToString();
|
||||
query = query.Where(a => a.Segment == segment);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(Request.Query["location"]))
|
||||
{
|
||||
var loc = Request.Query["location"].ToString();
|
||||
query = query.Where(a => a.Location != null && a.Location.Contains(loc));
|
||||
}
|
||||
if (!string.IsNullOrEmpty(Request.Query["condition"]))
|
||||
{
|
||||
var cond = Request.Query["condition"].ToString();
|
||||
query = query.Where(a => a.Condition == cond);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Request.Query["steering"]))
|
||||
{
|
||||
var st = Request.Query["steering"].ToString();
|
||||
query = query.Where(a => a.Steering == st);
|
||||
}
|
||||
|
||||
if (int.TryParse(Request.Query["doorCount"], out int dc))
|
||||
{
|
||||
query = query.Where(a => a.DoorCount == dc);
|
||||
}
|
||||
|
||||
if (!userId.HasValue)
|
||||
{
|
||||
var publicStatuses = new[] {
|
||||
(int)AdStatusEnum.Active,
|
||||
(int)AdStatusEnum.Sold,
|
||||
(int)AdStatusEnum.Reserved
|
||||
};
|
||||
query = query.Where(a => publicStatuses.Contains(a.StatusID));
|
||||
}
|
||||
else
|
||||
{
|
||||
query = query.Where(a => a.UserID == userId.Value);
|
||||
}
|
||||
|
||||
// --- LÓGICA DE BÚSQUEDA POR PALABRAS ---
|
||||
if (!string.IsNullOrEmpty(q))
|
||||
{
|
||||
// 1. Dividimos el término de búsqueda en palabras individuales.
|
||||
var keywords = q.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// 2. Por cada palabra, agregamos una condición 'Where'.
|
||||
// Esto crea un AND implícito: el aviso debe coincidir con TODAS las palabras.
|
||||
foreach (var keyword in keywords)
|
||||
{
|
||||
var lowerKeyword = keyword.ToLower();
|
||||
query = query.Where(a =>
|
||||
(a.Brand != null && a.Brand.Name.ToLower().Contains(lowerKeyword)) ||
|
||||
(a.Model != null && a.Model.Name.ToLower().Contains(lowerKeyword)) ||
|
||||
(a.VersionName != null && a.VersionName.ToLower().Contains(lowerKeyword))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(c))
|
||||
{
|
||||
int typeId = c == "EAUTOS" ? 1 : (c == "EMOTOS" ? 2 : 0);
|
||||
if (typeId > 0) query = query.Where(a => a.VehicleTypeID == typeId);
|
||||
}
|
||||
|
||||
if (minPrice.HasValue) query = query.Where(a => a.Price >= minPrice.Value);
|
||||
if (maxPrice.HasValue) query = query.Where(a => a.Price <= maxPrice.Value);
|
||||
if (minYear.HasValue) query = query.Where(a => a.Year >= minYear.Value);
|
||||
if (maxYear.HasValue) query = query.Where(a => a.Year <= maxYear.Value);
|
||||
|
||||
var results = await query.OrderByDescending(a => a.IsFeatured)
|
||||
.ThenByDescending(a => a.CreatedAt)
|
||||
.Select(a => new
|
||||
{
|
||||
id = a.AdID,
|
||||
brandName = a.Brand != null ? a.Brand.Name : null,
|
||||
versionName = a.VersionName,
|
||||
price = a.Price,
|
||||
currency = a.Currency,
|
||||
year = a.Year,
|
||||
km = a.KM,
|
||||
image = a.Photos.OrderBy(p => p.SortOrder).Select(p => p.FilePath).FirstOrDefault(),
|
||||
isFeatured = a.IsFeatured,
|
||||
statusId = a.StatusID,
|
||||
viewsCounter = a.ViewsCounter,
|
||||
|
||||
createdAt = a.CreatedAt,
|
||||
location = a.Location,
|
||||
fuelType = a.FuelType,
|
||||
color = a.Color,
|
||||
segment = a.Segment,
|
||||
condition = a.Condition,
|
||||
doorCount = a.DoorCount,
|
||||
transmission = a.Transmission,
|
||||
steering = a.Steering
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
[HttpGet("search-suggestions")]
|
||||
public async Task<IActionResult> GetSearchSuggestions([FromQuery] string term)
|
||||
{
|
||||
if (string.IsNullOrEmpty(term) || term.Length < 2)
|
||||
{
|
||||
return Ok(new List<string>());
|
||||
}
|
||||
|
||||
// Buscar en Marcas
|
||||
var brands = await _context.Brands
|
||||
.Where(b => b.Name.Contains(term))
|
||||
.Select(b => b.Name)
|
||||
.ToListAsync();
|
||||
|
||||
// Buscar en Modelos
|
||||
var models = await _context.Models
|
||||
.Where(m => m.Name.Contains(term))
|
||||
.Select(m => m.Name)
|
||||
.ToListAsync();
|
||||
|
||||
// Combinar, eliminar duplicados y tomar los primeros 5
|
||||
var suggestions = brands.Concat(models)
|
||||
.Distinct()
|
||||
.OrderBy(s => s)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
return Ok(suggestions);
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var ad = await _context.Ads
|
||||
.Include(a => a.Photos)
|
||||
.Include(a => a.Features)
|
||||
.Include(a => a.Brand)
|
||||
.Include(a => a.Model)
|
||||
.Include(a => a.User)
|
||||
.FirstOrDefaultAsync(a => a.AdID == id);
|
||||
|
||||
if (ad == null) return NotFound();
|
||||
|
||||
// 1. SEGURIDAD Y CONTROL DE ACCESO
|
||||
// Obtenemos datos del usuario actual
|
||||
var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
int currentUserId = int.TryParse(userIdStr, out int uid) ? uid : 0;
|
||||
bool isAdmin = User.IsInRole("Admin");
|
||||
bool isOwner = ad.UserID == currentUserId;
|
||||
|
||||
// Definimos qué estados son visibles para todo el mundo
|
||||
var publicStatuses = new[] {
|
||||
(int)AdStatusEnum.Active,
|
||||
(int)AdStatusEnum.Reserved,
|
||||
(int)AdStatusEnum.Sold
|
||||
};
|
||||
|
||||
bool isPublic = publicStatuses.Contains(ad.StatusID);
|
||||
|
||||
// REGLA A: Si está ELIMINADO (9), nadie lo ve por URL pública (solo admin podría en dashboard)
|
||||
if (ad.StatusID == (int)AdStatusEnum.Deleted && !isAdmin)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// REGLA B: Si NO es público (ej: Vencido, Borrador, Pausado) y NO es dueño ni admin -> 404
|
||||
if (!isPublic && !isOwner && !isAdmin)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// 2. LÓGICA DE CONTEO
|
||||
try
|
||||
{
|
||||
// REGLA: Solo contamos si NO es el dueño (isOwner ya calculado arriba)
|
||||
if (!isOwner)
|
||||
{
|
||||
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0";
|
||||
var cutoffDate = DateTime.UtcNow.AddHours(-24);
|
||||
|
||||
// Verificamos si esta IP ya vio este aviso en las últimas 24hs
|
||||
var alreadyViewed = await _context.AdViewLogs
|
||||
.AnyAsync(v => v.AdID == id && v.IPAddress == userIp && v.ViewDate > cutoffDate);
|
||||
|
||||
if (!alreadyViewed)
|
||||
{
|
||||
// A. Registrar Log
|
||||
_context.AdViewLogs.Add(new AdViewLog
|
||||
{
|
||||
AdID = id,
|
||||
IPAddress = userIp,
|
||||
ViewDate = DateTime.UtcNow
|
||||
});
|
||||
|
||||
// B. Incrementar Contador
|
||||
await _context.Ads
|
||||
.Where(a => a.AdID == id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.ViewsCounter, a => a.ViewsCounter + 1));
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ignoramos errores de conteo para no bloquear la visualización
|
||||
}
|
||||
|
||||
// 3. RESPUESTA
|
||||
return Ok(new
|
||||
{
|
||||
adID = ad.AdID,
|
||||
userID = ad.UserID,
|
||||
ownerUserType = ad.User?.UserType,
|
||||
vehicleTypeID = ad.VehicleTypeID,
|
||||
brandID = ad.BrandID,
|
||||
modelID = ad.ModelID,
|
||||
versionName = ad.VersionName,
|
||||
brandName = ad.Brand?.Name,
|
||||
|
||||
year = ad.Year,
|
||||
km = ad.KM,
|
||||
price = ad.Price,
|
||||
currency = ad.Currency,
|
||||
description = ad.Description,
|
||||
isFeatured = ad.IsFeatured,
|
||||
statusID = ad.StatusID,
|
||||
createdAt = ad.CreatedAt,
|
||||
publishedAt = ad.PublishedAt,
|
||||
expiresAt = ad.ExpiresAt,
|
||||
legacyAdID = ad.LegacyAdID,
|
||||
viewsCounter = ad.ViewsCounter,
|
||||
fuelType = ad.FuelType,
|
||||
color = ad.Color,
|
||||
segment = ad.Segment,
|
||||
location = ad.Location,
|
||||
condition = ad.Condition,
|
||||
doorCount = ad.DoorCount,
|
||||
transmission = ad.Transmission,
|
||||
steering = ad.Steering,
|
||||
contactPhone = ad.ContactPhone,
|
||||
contactEmail = ad.ContactEmail,
|
||||
displayContactInfo = ad.DisplayContactInfo,
|
||||
photos = ad.Photos.Select(p => new { p.PhotoID, p.FilePath, p.IsCover, p.SortOrder }),
|
||||
features = ad.Features.Select(f => new { f.FeatureKey, f.FeatureValue }),
|
||||
brand = ad.Brand != null ? new { id = ad.Brand.BrandID, name = ad.Brand.Name } : null,
|
||||
model = ad.Model != null ? new { id = ad.Model.ModelID, name = ad.Model.Name } : null
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateAdRequestDto request)
|
||||
{
|
||||
var currentUserId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
|
||||
var userRole = User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
|
||||
bool isAdmin = userRole == "Admin";
|
||||
|
||||
int finalUserId = currentUserId;
|
||||
|
||||
if (isAdmin)
|
||||
{
|
||||
if (request.TargetUserID.HasValue)
|
||||
{
|
||||
finalUserId = request.TargetUserID.Value;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(request.GhostUserEmail))
|
||||
{
|
||||
// Pasamos nombre y apellido por separado
|
||||
var ghost = await _identityService.CreateGhostUserAsync(
|
||||
request.GhostUserEmail,
|
||||
request.GhostFirstName ?? "Usuario",
|
||||
request.GhostLastName ?? "",
|
||||
request.GhostUserPhone ?? ""
|
||||
);
|
||||
finalUserId = ghost.UserID;
|
||||
}
|
||||
}
|
||||
|
||||
// 🟢 LÓGICA DE MODELO DINÁMICO
|
||||
int finalModelId = request.ModelID;
|
||||
|
||||
// Si el front manda 0 (modelo nuevo escrito a mano)
|
||||
if (finalModelId == 0 && !string.IsNullOrEmpty(request.VersionName))
|
||||
{
|
||||
// Intentamos extraer el nombre del modelo desde el VersionName
|
||||
var brand = await _context.Brands.FindAsync(request.BrandID);
|
||||
string modelNameCandidate = request.VersionName;
|
||||
|
||||
// Si el nombre de versión empieza con la marca (ej: "Fiat 600"), quitamos la marca
|
||||
if (brand != null && modelNameCandidate.StartsWith(brand.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
modelNameCandidate = modelNameCandidate.Substring(brand.Name.Length).Trim();
|
||||
}
|
||||
|
||||
// Si quedó vacío o es muy genérico, usar un default
|
||||
if (string.IsNullOrWhiteSpace(modelNameCandidate)) modelNameCandidate = "Modelo Desconocido";
|
||||
|
||||
// Opcional: Tomar solo la primera palabra como nombre del modelo para agrupar mejor
|
||||
// var firstWord = modelNameCandidate.Split(' ')[0];
|
||||
// if (firstWord.Length > 2) modelNameCandidate = firstWord;
|
||||
|
||||
// Buscar si ya existe este modelo para esta marca
|
||||
var existingModel = await _context.Models
|
||||
.FirstOrDefaultAsync(m => m.BrandID == request.BrandID && m.Name == modelNameCandidate);
|
||||
|
||||
if (existingModel != null)
|
||||
{
|
||||
finalModelId = existingModel.ModelID;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Crear Nuevo Modelo
|
||||
var newModel = new Model
|
||||
{
|
||||
BrandID = request.BrandID,
|
||||
Name = modelNameCandidate
|
||||
};
|
||||
_context.Models.Add(newModel);
|
||||
await _context.SaveChangesAsync(); // Guardar para obtener ID
|
||||
finalModelId = newModel.ModelID;
|
||||
}
|
||||
}
|
||||
|
||||
var ad = new Ad
|
||||
{
|
||||
UserID = finalUserId,
|
||||
VehicleTypeID = request.VehicleTypeID,
|
||||
BrandID = request.BrandID,
|
||||
ModelID = finalModelId, // Usamos el ID resuelto
|
||||
VersionName = request.VersionName,
|
||||
Year = request.Year,
|
||||
KM = request.KM,
|
||||
Price = request.Price,
|
||||
Currency = request.Currency,
|
||||
Description = request.Description,
|
||||
IsFeatured = request.IsFeatured,
|
||||
|
||||
FuelType = request.FuelType,
|
||||
Color = request.Color,
|
||||
Segment = request.Segment,
|
||||
Location = request.Location,
|
||||
Condition = request.Condition,
|
||||
DoorCount = request.DoorCount,
|
||||
Transmission = request.Transmission,
|
||||
Steering = request.Steering,
|
||||
|
||||
ContactPhone = request.ContactPhone,
|
||||
ContactEmail = request.ContactEmail,
|
||||
DisplayContactInfo = request.DisplayContactInfo,
|
||||
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (isAdmin)
|
||||
{
|
||||
ad.StatusID = (int)AdStatusEnum.Active;
|
||||
ad.PublishedAt = DateTime.UtcNow;
|
||||
ad.ExpiresAt = DateTime.UtcNow.AddDays(30);
|
||||
}
|
||||
else
|
||||
{
|
||||
ad.StatusID = (int)AdStatusEnum.Draft;
|
||||
}
|
||||
|
||||
_context.Ads.Add(ad);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// 📝 AUDITORÍA
|
||||
var brandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? "";
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "AD_CREATED",
|
||||
Entity = "Ad",
|
||||
EntityID = ad.AdID,
|
||||
UserID = currentUserId,
|
||||
Details = $"Aviso '{brandName} {ad.VersionName}' creado. Estado inicial: {ad.StatusID}"
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { id = ad.AdID }, ad);
|
||||
}
|
||||
|
||||
[HttpGet("brands/{vehicleTypeId}")]
|
||||
public async Task<IActionResult> GetBrands(int vehicleTypeId)
|
||||
{
|
||||
var brands = await _context.Set<Brand>()
|
||||
.Where(b => b.VehicleTypeID == vehicleTypeId)
|
||||
.Select(b => new { id = b.BrandID, name = b.Name })
|
||||
.ToListAsync();
|
||||
return Ok(brands);
|
||||
}
|
||||
|
||||
[HttpGet("models/{brandId}")]
|
||||
public async Task<IActionResult> GetModels(int brandId)
|
||||
{
|
||||
var models = await _context.Set<Model>()
|
||||
.Where(m => m.BrandID == brandId)
|
||||
.Select(m => new { id = m.ModelID, name = m.Name })
|
||||
.ToListAsync();
|
||||
return Ok(models);
|
||||
}
|
||||
|
||||
[HttpGet("models/search")]
|
||||
public async Task<IActionResult> SearchModels([FromQuery] int brandId, [FromQuery] string query)
|
||||
{
|
||||
if (string.IsNullOrEmpty(query) || query.Length < 2) return Ok(new List<object>());
|
||||
|
||||
var models = await _context.Models
|
||||
.Where(m => m.BrandID == brandId && m.Name.Contains(query))
|
||||
.OrderBy(m => m.Name)
|
||||
.Take(20)
|
||||
.Select(m => new { id = m.ModelID, name = m.Name })
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(models);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/upload-photos")]
|
||||
public async Task<IActionResult> UploadPhotos(int id, [FromForm] IFormFileCollection files)
|
||||
{
|
||||
if (files == null || files.Count == 0) return BadRequest("No se recibieron archivos en la petición.");
|
||||
|
||||
var ad = await _context.Ads.Include(a => a.Photos).FirstOrDefaultAsync(a => a.AdID == id);
|
||||
if (ad == null) return NotFound("Aviso no encontrado.");
|
||||
|
||||
// 🛡️ SEGURIDAD: Verificar propiedad o ser admin
|
||||
var currentUserId = GetCurrentUserId();
|
||||
if (ad.UserID != currentUserId && !IsUserAdmin()) return Forbid();
|
||||
|
||||
int currentCount = ad.Photos.Count;
|
||||
int availableSlots = 5 - currentCount;
|
||||
|
||||
if (availableSlots <= 0)
|
||||
{
|
||||
return BadRequest($"Límite de 5 fotos alcanzado. Espacios disponibles: 0. Fotos actuales: {currentCount}");
|
||||
}
|
||||
|
||||
var filesToProcess = files.Take(availableSlots).ToList();
|
||||
var uploadedCount = 0;
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var file in filesToProcess)
|
||||
{
|
||||
if (file.Length > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var relativePath = await _imageService.SaveAdImageAsync(id, file);
|
||||
|
||||
_context.AdPhotos.Add(new AdPhoto
|
||||
{
|
||||
AdID = id,
|
||||
FilePath = relativePath,
|
||||
IsCover = !_context.AdPhotos.Any(p => p.AdID == id) && uploadedCount == 0,
|
||||
SortOrder = currentCount + uploadedCount
|
||||
});
|
||||
|
||||
uploadedCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"{file.FileName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadedCount > 0)
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
message = $"{uploadedCount} fotos procesadas.",
|
||||
errors = errors.Any() ? errors : null
|
||||
});
|
||||
}
|
||||
|
||||
return BadRequest(new
|
||||
{
|
||||
message = "No se pudieron procesar las imágenes.",
|
||||
details = errors
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("photos/{photoId}")]
|
||||
public async Task<IActionResult> DeletePhoto(int photoId)
|
||||
{
|
||||
var photo = await _context.AdPhotos.Include(p => p.Ad).FirstOrDefaultAsync(p => p.PhotoID == photoId);
|
||||
if (photo == null) return NotFound();
|
||||
|
||||
// 🛡️ SEGURIDAD: Verificar propiedad o ser admin
|
||||
var currentUserId = GetCurrentUserId();
|
||||
if (photo.Ad.UserID != currentUserId && !IsUserAdmin()) return Forbid();
|
||||
|
||||
// Eliminar del disco
|
||||
_imageService.DeleteAdImage(photo.FilePath);
|
||||
|
||||
// Eliminar de la base de datos
|
||||
_context.AdPhotos.Remove(photo);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] CreateAdRequestDto updatedAdDto)
|
||||
{
|
||||
var ad = await _context.Ads.FindAsync(id);
|
||||
if (ad == null) return NotFound();
|
||||
|
||||
var userId = GetCurrentUserId();
|
||||
|
||||
if (ad.UserID != userId && !IsUserAdmin())
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
// Si está en moderación, no se puede editar (salvo Admin)
|
||||
if (!IsUserAdmin() && ad.StatusID == (int)AdStatusEnum.ModerationPending)
|
||||
{
|
||||
return BadRequest("No puedes editar un aviso que está en proceso de revisión.");
|
||||
}
|
||||
|
||||
// --- Lógica de Modelo Dinámico (Si edita a un modelo nuevo) ---
|
||||
int finalModelId = updatedAdDto.ModelID;
|
||||
if (finalModelId == 0 && !string.IsNullOrEmpty(updatedAdDto.VersionName))
|
||||
{
|
||||
var brand = await _context.Brands.FindAsync(updatedAdDto.BrandID);
|
||||
string modelNameCandidate = updatedAdDto.VersionName;
|
||||
if (brand != null && modelNameCandidate.StartsWith(brand.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
modelNameCandidate = modelNameCandidate.Substring(brand.Name.Length).Trim();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(modelNameCandidate)) modelNameCandidate = "Modelo Desconocido";
|
||||
|
||||
var existingModel = await _context.Models
|
||||
.FirstOrDefaultAsync(m => m.BrandID == updatedAdDto.BrandID && m.Name == modelNameCandidate);
|
||||
|
||||
if (existingModel != null)
|
||||
{
|
||||
finalModelId = existingModel.ModelID;
|
||||
}
|
||||
else
|
||||
{
|
||||
var newModel = new Model { BrandID = updatedAdDto.BrandID, Name = modelNameCandidate };
|
||||
_context.Models.Add(newModel);
|
||||
await _context.SaveChangesAsync();
|
||||
finalModelId = newModel.ModelID;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mapeo manual desde el DTO a la Entidad ---
|
||||
ad.BrandID = updatedAdDto.BrandID;
|
||||
ad.ModelID = finalModelId; // Usar el ID resuelto
|
||||
ad.VersionName = updatedAdDto.VersionName;
|
||||
ad.Year = updatedAdDto.Year;
|
||||
ad.KM = updatedAdDto.KM;
|
||||
ad.Price = updatedAdDto.Price;
|
||||
ad.Currency = updatedAdDto.Currency;
|
||||
ad.Description = updatedAdDto.Description;
|
||||
|
||||
ad.FuelType = updatedAdDto.FuelType;
|
||||
ad.Color = updatedAdDto.Color;
|
||||
ad.Segment = updatedAdDto.Segment;
|
||||
ad.Location = updatedAdDto.Location;
|
||||
ad.Condition = updatedAdDto.Condition;
|
||||
ad.DoorCount = updatedAdDto.DoorCount;
|
||||
ad.Transmission = updatedAdDto.Transmission;
|
||||
ad.Steering = updatedAdDto.Steering;
|
||||
ad.ContactPhone = updatedAdDto.ContactPhone;
|
||||
ad.ContactEmail = updatedAdDto.ContactEmail;
|
||||
ad.DisplayContactInfo = updatedAdDto.DisplayContactInfo;
|
||||
// Nota: IsFeatured y otros campos sensibles se manejan por separado (pago/admin)
|
||||
|
||||
// 📝 AUDITORÍA
|
||||
var adBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? "";
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "AD_UPDATED",
|
||||
Entity = "Ad",
|
||||
EntityID = ad.AdID,
|
||||
UserID = userId,
|
||||
Details = $"Aviso ID {ad.AdID} ({adBrandName} {ad.VersionName}) actualizado por el propietario/admin."
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return Ok(ad);
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var ad = await _context.Ads.FindAsync(id);
|
||||
if (ad == null) return NotFound();
|
||||
|
||||
var userId = GetCurrentUserId();
|
||||
|
||||
if (ad.UserID != userId && !IsUserAdmin())
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
ad.StatusID = (int)AdStatusEnum.Deleted;
|
||||
ad.DeletedAt = DateTime.UtcNow;
|
||||
|
||||
// 📝 AUDITORÍA
|
||||
var delBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? "";
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "AD_DELETED_SOFT",
|
||||
Entity = "Ad",
|
||||
EntityID = ad.AdID,
|
||||
UserID = userId,
|
||||
Details = $"Aviso ID {ad.AdID} ({delBrandName} {ad.VersionName}) marcado como eliminado (borrado lógico)."
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("favorites")]
|
||||
public async Task<IActionResult> AddFavorite([FromBody] Favorite fav)
|
||||
{
|
||||
if (await _context.Favorites.AnyAsync(f => f.UserID == fav.UserID && f.AdID == fav.AdID))
|
||||
return BadRequest("Ya es favorito");
|
||||
|
||||
_context.Favorites.Add(fav);
|
||||
await _context.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpDelete("favorites")]
|
||||
public async Task<IActionResult> RemoveFavorite([FromQuery] int userId, [FromQuery] int adId)
|
||||
{
|
||||
var fav = await _context.Favorites.FindAsync(userId, adId);
|
||||
if (fav == null) return NotFound();
|
||||
|
||||
_context.Favorites.Remove(fav);
|
||||
await _context.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("favorites/{userId}")]
|
||||
public async Task<IActionResult> GetFavorites(int userId)
|
||||
{
|
||||
var ads = await _context.Favorites
|
||||
.Where(f => f.UserID == userId)
|
||||
.Join(_context.Ads, f => f.AdID, a => a.AdID, (f, a) => a)
|
||||
.Include(a => a.Photos)
|
||||
.Select(a => new
|
||||
{
|
||||
id = a.AdID,
|
||||
BrandName = a.Brand != null ? a.Brand.Name : null,
|
||||
VersionName = a.VersionName,
|
||||
price = a.Price,
|
||||
currency = a.Currency,
|
||||
year = a.Year,
|
||||
km = a.KM,
|
||||
image = a.Photos.OrderBy(p => p.SortOrder).Select(p => p.FilePath).FirstOrDefault()
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(ads);
|
||||
}
|
||||
|
||||
[HttpPatch("{id}/status")]
|
||||
public async Task<IActionResult> ChangeStatus(int id, [FromBody] int newStatus)
|
||||
{
|
||||
var ad = await _context.Ads.FindAsync(id);
|
||||
if (ad == null) return NotFound();
|
||||
|
||||
var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
|
||||
var userRole = User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
|
||||
bool isAdmin = userRole == "Admin";
|
||||
|
||||
if (ad.UserID != userId && !isAdmin) return Forbid();
|
||||
|
||||
// 🛡️ VALIDACIONES DE ESTADO
|
||||
if (!isAdmin)
|
||||
{
|
||||
// 1. No activar borradores sin pagar
|
||||
if (ad.StatusID == (int)AdStatusEnum.Draft || ad.StatusID == (int)AdStatusEnum.PaymentPending)
|
||||
{
|
||||
return BadRequest("Debes completar el pago para activar este aviso.");
|
||||
}
|
||||
|
||||
// 2. NUEVO: No tocar si está en moderación
|
||||
if (ad.StatusID == (int)AdStatusEnum.ModerationPending)
|
||||
{
|
||||
return BadRequest("El aviso está en revisión. Espera la aprobación del administrador.");
|
||||
}
|
||||
}
|
||||
|
||||
// Validar estados destino permitidos para el usuario
|
||||
var allowedUserStatuses = new[] { 4, 6, 7, 9, 10 };
|
||||
if (!isAdmin && !allowedUserStatuses.Contains(newStatus))
|
||||
{
|
||||
return BadRequest("Estado destino no permitido.");
|
||||
}
|
||||
|
||||
int oldStatus = ad.StatusID;
|
||||
ad.StatusID = newStatus;
|
||||
|
||||
// 📝 AUDITORÍA
|
||||
var statusBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? "";
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "AD_STATUS_CHANGED",
|
||||
Entity = "Ad",
|
||||
EntityID = ad.AdID,
|
||||
UserID = userId,
|
||||
Details = $"Estado de aviso ({statusBrandName} {ad.VersionName}) cambiado de {oldStatus} a {newStatus}."
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new { message = "Estado actualizado" });
|
||||
}
|
||||
|
||||
[HttpGet("payment-methods")]
|
||||
public async Task<IActionResult> GetPaymentMethods()
|
||||
{
|
||||
var methods = await _context.PaymentMethods
|
||||
.OrderBy(p => p.PaymentMethodID)
|
||||
.Select(p => new { id = p.PaymentMethodID, mediodepago = p.Name })
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(methods);
|
||||
}
|
||||
}
|
||||
485
Backend/MotoresArgentinosV2.API/Controllers/AuthController.cs
Normal file
485
Backend/MotoresArgentinosV2.API/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,485 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using MotoresArgentinosV2.Infrastructure.Data;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace MotoresArgentinosV2.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
// CORRECCIÓN: Se quitó [EnableRateLimiting("AuthPolicy")] de aquí para no bloquear /me ni /logout
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IIdentityService _identityService;
|
||||
private readonly ITokenService _tokenService;
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly MotoresV2DbContext _context;
|
||||
|
||||
public AuthController(IIdentityService identityService, ITokenService tokenService, INotificationService notificationService, MotoresV2DbContext context)
|
||||
{
|
||||
_identityService = identityService;
|
||||
_tokenService = tokenService;
|
||||
_notificationService = notificationService;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// Helper privado para cookies
|
||||
private void SetTokenCookie(string token, string cookieName)
|
||||
{
|
||||
var cookieOptions = new CookieOptions
|
||||
{
|
||||
HttpOnly = true, // Seguridad: JS no puede leer esto
|
||||
Expires = DateTime.UtcNow.AddDays(7),
|
||||
Secure = true, // Solo HTTPS (localhost con https cuenta)
|
||||
SameSite = SameSiteMode.Strict,
|
||||
IsEssential = true
|
||||
};
|
||||
Response.Cookies.Append(cookieName, token, cookieOptions);
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO (5 intentos/min)
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
var (user, message) = await _identityService.AuthenticateAsync(request.Username, request.Password);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
if (message == "EMAIL_NOT_VERIFIED")
|
||||
return Unauthorized(new { message = "Debes verificar tu email antes de ingresar." });
|
||||
|
||||
if (message == "USER_BLOCKED")
|
||||
return Unauthorized(new { message = "Tu usuario ha sido bloqueado por un administrador." });
|
||||
|
||||
return Unauthorized(new { message = "Credenciales inválidas" });
|
||||
}
|
||||
|
||||
if (message == "FORCE_PASSWORD_CHANGE")
|
||||
{
|
||||
return Ok(new { status = "MIGRATION_REQUIRED", username = user.UserName });
|
||||
}
|
||||
|
||||
// Lógica MFA (Si aplica)
|
||||
if (user.IsMFAEnabled)
|
||||
{
|
||||
return Ok(new { status = "TOTP_REQUIRED", username = user.UserName });
|
||||
}
|
||||
if (user.UserType == 3 && string.IsNullOrEmpty(user.MFASecret)) // Admin forzar setup
|
||||
{
|
||||
var secret = _tokenService.GenerateBase32Secret();
|
||||
user.MFASecret = secret;
|
||||
await _context.SaveChangesAsync();
|
||||
var qrUri = _tokenService.GetQrCodeUri(user.Email, secret);
|
||||
return Ok(new { status = "MFA_SETUP_REQUIRED", username = user.UserName, qrUri, secret });
|
||||
}
|
||||
|
||||
// --- LOGIN EXITOSO ---
|
||||
|
||||
// 1. Generar Tokens
|
||||
var jwtToken = _tokenService.GenerateJwtToken(user);
|
||||
var refreshToken = _tokenService.GenerateRefreshToken(HttpContext.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0");
|
||||
|
||||
// 2. Guardar RefreshToken en DB
|
||||
// Asegúrate de que la propiedad RefreshTokens exista en User (Paso 2 abajo)
|
||||
user.RefreshTokens.Add(refreshToken);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// 3. Setear Cookies
|
||||
SetTokenCookie(jwtToken, "accessToken");
|
||||
SetTokenCookie(refreshToken.Token, "refreshToken");
|
||||
|
||||
// 4. Audit Log
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "LOGIN_SUCCESS",
|
||||
Entity = "User",
|
||||
EntityID = user.UserID,
|
||||
UserID = user.UserID,
|
||||
Details = "Login con Cookies exitoso"
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// 5. Retornar User (Sin el token, porque va en cookie)
|
||||
return Ok(new
|
||||
{
|
||||
status = "SUCCESS",
|
||||
recommendMfa = !user.IsMFAEnabled,
|
||||
user = new
|
||||
{
|
||||
id = user.UserID,
|
||||
username = user.UserName,
|
||||
email = user.Email,
|
||||
firstName = user.FirstName,
|
||||
lastName = user.LastName,
|
||||
userType = user.UserType,
|
||||
isMFAEnabled = user.IsMFAEnabled
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("refresh-token")]
|
||||
// NO PROTEGIDO ESTRICTAMENTE (Usa límite global)
|
||||
public async Task<IActionResult> RefreshToken()
|
||||
{
|
||||
var refreshToken = Request.Cookies["refreshToken"];
|
||||
if (string.IsNullOrEmpty(refreshToken))
|
||||
return Unauthorized(new { message = "Token no proporcionado" });
|
||||
|
||||
var user = await _context.Users
|
||||
.Include(u => u.RefreshTokens)
|
||||
.FirstOrDefaultAsync(u => u.RefreshTokens.Any(t => t.Token == refreshToken));
|
||||
|
||||
if (user == null) return Unauthorized(new { message = "Usuario no encontrado" });
|
||||
|
||||
var storedToken = user.RefreshTokens.SingleOrDefault(x => x.Token == refreshToken);
|
||||
|
||||
if (storedToken == null || !storedToken.IsActive)
|
||||
return Unauthorized(new { message = "Token inválido o expirado" });
|
||||
|
||||
// Rotar Refresh Token
|
||||
var newRefreshToken = _tokenService.GenerateRefreshToken(HttpContext.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0");
|
||||
|
||||
storedToken.Revoked = DateTime.UtcNow;
|
||||
storedToken.RevokedByIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
storedToken.ReplacedByToken = newRefreshToken.Token;
|
||||
|
||||
user.RefreshTokens.Add(newRefreshToken);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Nuevo JWT
|
||||
var newJwtToken = _tokenService.GenerateJwtToken(user);
|
||||
|
||||
// Actualizar Cookies
|
||||
SetTokenCookie(newJwtToken, "accessToken");
|
||||
SetTokenCookie(newRefreshToken.Token, "refreshToken");
|
||||
|
||||
return Ok(new { message = "Token renovado" });
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
// NO PROTEGIDO ESTRICTAMENTE
|
||||
public IActionResult Logout()
|
||||
{
|
||||
Response.Cookies.Delete("accessToken");
|
||||
Response.Cookies.Delete("refreshToken");
|
||||
return Ok(new { message = "Sesión cerrada" });
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
// 1. Sin [Authorize] para controlar nosotros la respuesta
|
||||
public async Task<IActionResult> GetMe()
|
||||
{
|
||||
// 2. Verificamos si existe la cookie de acceso
|
||||
var hasToken = Request.Cookies.ContainsKey("accessToken");
|
||||
|
||||
// 3. Si NO tiene cookie, es un visitante anónimo.
|
||||
// Devolvemos 200 OK con null para no ensuciar la consola con 401.
|
||||
if (!hasToken)
|
||||
{
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
// 4. Si TIENE cookie, verificamos si el Middleware pudo leer el usuario.
|
||||
var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
// Si tiene cookie pero userIdStr es null, significa que el token expiró o es inválido.
|
||||
// Aquí SI devolvemos 401 para disparar el "Auto Refresh" del frontend.
|
||||
if (string.IsNullOrEmpty(userIdStr))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// 5. Todo válido, buscamos datos
|
||||
var userId = int.Parse(userIdStr);
|
||||
var user = await _context.Users.FindAsync(userId);
|
||||
|
||||
if (user == null) return Unauthorized(); // Usuario borrado de DB
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
id = user.UserID,
|
||||
username = user.UserName,
|
||||
email = user.Email,
|
||||
firstName = user.FirstName,
|
||||
lastName = user.LastName,
|
||||
userType = user.UserType,
|
||||
phoneNumber = user.PhoneNumber,
|
||||
isMFAEnabled = user.IsMFAEnabled
|
||||
});
|
||||
}
|
||||
|
||||
// Permite a un usuario logueado iniciar el proceso de MFA voluntariamente
|
||||
[Authorize]
|
||||
[HttpPost("init-mfa")]
|
||||
public async Task<IActionResult> InitMFA()
|
||||
{
|
||||
var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
|
||||
|
||||
var user = await _context.Users.FindAsync(int.Parse(userIdStr));
|
||||
if (user == null) return NotFound();
|
||||
|
||||
// Generar secreto si no tiene
|
||||
if (string.IsNullOrEmpty(user.MFASecret))
|
||||
{
|
||||
user.MFASecret = _tokenService.GenerateBase32Secret();
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// 📝 AUDITORÍA
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "MFA_INITIATED",
|
||||
Entity = "User",
|
||||
EntityID = user.UserID,
|
||||
UserID = user.UserID,
|
||||
Details = "Proceso de configuración de MFA (TOTP) iniciado."
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var qrUri = _tokenService.GetQrCodeUri(user.Email, user.MFASecret);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
qrUri = qrUri,
|
||||
secret = user.MFASecret
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("verify-mfa")]
|
||||
[EnableRateLimiting("AuthPolicy")]
|
||||
public async Task<IActionResult> VerifyMFA([FromBody] MFARequest request)
|
||||
{
|
||||
var user = await _context.Users.FirstOrDefaultAsync(u => u.UserName == request.Username);
|
||||
if (user == null || string.IsNullOrEmpty(user.MFASecret))
|
||||
{
|
||||
return Unauthorized(new { message = "Sesión de autenticación inválida" });
|
||||
}
|
||||
|
||||
bool isValid = _tokenService.ValidateTOTP(user.MFASecret, request.Code);
|
||||
if (!isValid)
|
||||
{
|
||||
return Unauthorized(new { message = "Código de verificación inválido" });
|
||||
}
|
||||
|
||||
if (!user.IsMFAEnabled)
|
||||
{
|
||||
user.IsMFAEnabled = true;
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var token = _tokenService.GenerateJwtToken(user);
|
||||
var refreshToken = _tokenService.GenerateRefreshToken(HttpContext.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0");
|
||||
|
||||
// Cargar colección explícitamente para evitar errores de EF
|
||||
await _context.Entry(user).Collection(u => u.RefreshTokens).LoadAsync();
|
||||
|
||||
user.RefreshTokens.Add(refreshToken);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Setear Cookies Seguras
|
||||
SetTokenCookie(token, "accessToken");
|
||||
SetTokenCookie(refreshToken.Token, "refreshToken");
|
||||
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "LOGIN_TOTP_SUCCESS",
|
||||
Entity = "User",
|
||||
EntityID = user.UserID,
|
||||
UserID = user.UserID,
|
||||
Details = "Login con TOTP (Authenticator) exitoso"
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
status = "SUCCESS",
|
||||
// user: retornamos datos para el frontend, pero NO el token
|
||||
user = new
|
||||
{
|
||||
id = user.UserID,
|
||||
username = user.UserName,
|
||||
email = user.Email,
|
||||
firstName = user.FirstName,
|
||||
lastName = user.LastName,
|
||||
userType = user.UserType,
|
||||
isMFAEnabled = user.IsMFAEnabled
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("disable-mfa")]
|
||||
public async Task<IActionResult> DisableMFA()
|
||||
{
|
||||
var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
|
||||
|
||||
var user = await _context.Users.FindAsync(int.Parse(userIdStr));
|
||||
if (user == null) return NotFound();
|
||||
|
||||
// Desactivamos MFA y limpiamos el secreto por seguridad
|
||||
user.IsMFAEnabled = false;
|
||||
user.MFASecret = null;
|
||||
|
||||
// 📝 AUDITORÍA
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "MFA_DISABLED",
|
||||
Entity = "User",
|
||||
EntityID = user.UserID,
|
||||
UserID = user.UserID,
|
||||
Details = "Autenticación de dos factores desactivada por el usuario."
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new { message = "MFA Desactivado correctamente." });
|
||||
}
|
||||
|
||||
[HttpPost("migrate-password")]
|
||||
[EnableRateLimiting("AuthPolicy")]
|
||||
public async Task<IActionResult> MigratePassword([FromBody] MigrateRequest request)
|
||||
{
|
||||
var success = await _identityService.MigratePasswordAsync(request.Username, request.NewPassword);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { message = "No se pudo actualizar la contraseña" });
|
||||
}
|
||||
|
||||
return Ok(new { message = "Contraseña actualizada con éxito. Ya puede iniciar sesión." });
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
|
||||
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
||||
{
|
||||
var (success, message) = await _identityService.RegisterUserAsync(request);
|
||||
if (!success) return BadRequest(new { message });
|
||||
|
||||
// 📝 AUDITORÍA
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "USER_REGISTERED",
|
||||
Entity = "User",
|
||||
EntityID = 0,
|
||||
UserID = 0,
|
||||
Details = $"Nuevo registro de usuario: {request.Email}"
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new { message });
|
||||
}
|
||||
|
||||
[HttpPost("verify-email")]
|
||||
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
|
||||
public async Task<IActionResult> VerifyEmail([FromBody] VerifyEmailRequest request)
|
||||
{
|
||||
var (success, message) = await _identityService.VerifyEmailAsync(request.Token);
|
||||
if (!success) return BadRequest(new { message });
|
||||
|
||||
// 📝 AUDITORÍA
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "EMAIL_VERIFIED",
|
||||
Entity = "User",
|
||||
EntityID = 0,
|
||||
UserID = 0,
|
||||
Details = "Correo electrónico verificado con éxito vía token."
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new { message });
|
||||
}
|
||||
|
||||
[HttpPost("resend-verification")]
|
||||
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
|
||||
public async Task<IActionResult> ResendVerification([FromBody] ResendVerificationRequest request)
|
||||
{
|
||||
var (success, message) = await _identityService.ResendVerificationEmailAsync(request.Email);
|
||||
if (!success) return BadRequest(new { message });
|
||||
return Ok(new { message });
|
||||
}
|
||||
|
||||
[HttpPost("forgot-password")]
|
||||
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
|
||||
public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest request)
|
||||
{
|
||||
var (success, message) = await _identityService.ForgotPasswordAsync(request.Email);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
// Si falló por Rate Limit, devolvemos BadRequest o 429 Too Many Requests
|
||||
return BadRequest(new { message });
|
||||
}
|
||||
|
||||
return Ok(new { message });
|
||||
}
|
||||
|
||||
[HttpPost("reset-password")]
|
||||
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
|
||||
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
|
||||
{
|
||||
var (success, message) = await _identityService.ResetPasswordAsync(request.Token, request.NewPassword);
|
||||
if (!success) return BadRequest(new { message });
|
||||
|
||||
// 📝 AUDITORÍA
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "PASSWORD_RESET_SUCCESS",
|
||||
Entity = "User",
|
||||
EntityID = 0, // No tenemos el ID directo aquí sin buscarlo
|
||||
UserID = 0,
|
||||
Details = "Contraseña restablecida vía token de recuperación."
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new { message });
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("change-password")]
|
||||
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
{
|
||||
var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
|
||||
var (success, message) = await _identityService.ChangePasswordAsync(userId, request.CurrentPassword, request.NewPassword);
|
||||
|
||||
if (!success) return BadRequest(new { message });
|
||||
|
||||
// Enviar notificación por mail
|
||||
try
|
||||
{
|
||||
var user = await _context.Users.FindAsync(userId);
|
||||
if (user != null)
|
||||
{
|
||||
await _notificationService.SendSecurityAlertEmailAsync(user.Email, "Cambio de contraseña");
|
||||
}
|
||||
}
|
||||
catch (Exception) { /* No bloqueamos el éxito si falla el mail */ }
|
||||
|
||||
// 📝 AUDITORÍA
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "PASSWORD_CHANGED",
|
||||
Entity = "User",
|
||||
EntityID = userId,
|
||||
UserID = userId,
|
||||
Details = "Contraseña cambiada por el usuario desde la configuración."
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new { message });
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangePasswordRequest
|
||||
{
|
||||
public string CurrentPassword { get; set; } = string.Empty;
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
namespace MotoresArgentinosV2.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AvisosLegacyController : ControllerBase
|
||||
{
|
||||
private readonly IAvisosLegacyService _avisosService;
|
||||
private readonly ILogger<AvisosLegacyController> _logger;
|
||||
|
||||
public AvisosLegacyController(IAvisosLegacyService avisosService, ILogger<AvisosLegacyController> logger)
|
||||
{
|
||||
_avisosService = avisosService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene las tarifas y configuración de avisos según la tarea y paquete
|
||||
/// </summary>
|
||||
/// <param name="tarea">Tipo de tarea (ej: EMOTORES, EAUTOS)</param>
|
||||
/// <param name="paquete">ID del paquete (opcional, default 0)</param>
|
||||
[HttpGet("configuracion")]
|
||||
public async Task<ActionResult<List<DatosAvisoDto>>> GetConfiguracion([FromQuery] string tarea, [FromQuery] int paquete = 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(tarea))
|
||||
{
|
||||
return BadRequest("El parámetro 'tarea' es obligatorio.");
|
||||
}
|
||||
|
||||
var resultados = await _avisosService.ObtenerDatosAvisosAsync(tarea, paquete);
|
||||
return Ok(resultados);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener configuración de avisos");
|
||||
return StatusCode(500, "Ocurrió un error interno al procesar la solicitud.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea un nuevo aviso en el sistema legacy
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<bool>> CrearAviso([FromBody] InsertarAvisoDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
var resultado = await _avisosService.InsertarAvisoAsync(dto);
|
||||
|
||||
if (resultado)
|
||||
{
|
||||
return CreatedAtAction(nameof(CrearAviso), true);
|
||||
}
|
||||
|
||||
return BadRequest("No se pudo crear el aviso.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al crear aviso para cliente {IdCliente}", dto.IdCliente);
|
||||
return StatusCode(500, "Ocurrió un error interno al crear el aviso.");
|
||||
}
|
||||
}
|
||||
[HttpGet("cliente/{nroDoc}")]
|
||||
public async Task<ActionResult<List<AvisoWebDto>>> GetAvisosPorCliente(string nroDoc)
|
||||
{
|
||||
var avisos = await _avisosService.ObtenerAvisosPorClienteAsync(nroDoc);
|
||||
return Ok(avisos);
|
||||
}
|
||||
}
|
||||
126
Backend/MotoresArgentinosV2.API/Controllers/ChatController.cs
Normal file
126
Backend/MotoresArgentinosV2.API/Controllers/ChatController.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MotoresArgentinosV2.Infrastructure.Data;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace MotoresArgentinosV2.API.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ChatController : ControllerBase
|
||||
{
|
||||
private readonly MotoresV2DbContext _context;
|
||||
private readonly INotificationService _notificationService;
|
||||
|
||||
public ChatController(MotoresV2DbContext context, INotificationService notificationService)
|
||||
{
|
||||
_context = context;
|
||||
_notificationService = notificationService;
|
||||
}
|
||||
|
||||
[HttpPost("send")]
|
||||
public async Task<IActionResult> SendMessage([FromBody] ChatMessage msg)
|
||||
{
|
||||
// 1. Guardar Mensaje
|
||||
msg.SentAt = DateTime.UtcNow;
|
||||
msg.IsRead = false;
|
||||
|
||||
_context.ChatMessages.Add(msg);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// 2. Notificar por Email (Con manejo de errores silencioso)
|
||||
try
|
||||
{
|
||||
var receiver = await _context.Users.FindAsync(msg.ReceiverID);
|
||||
var sender = await _context.Users.FindAsync(msg.SenderID);
|
||||
|
||||
if (receiver != null && !string.IsNullOrEmpty(receiver.Email))
|
||||
{
|
||||
// LÓGICA DE NOMBRE DE REMITENTE
|
||||
string senderDisplayName;
|
||||
|
||||
if (sender != null && sender.UserType == 3) // 3 = ADMIN
|
||||
{
|
||||
// Caso: Moderador escribe a Usuario
|
||||
senderDisplayName = "Un moderador de Motores Argentinos";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Caso: Usuario responde a Moderador
|
||||
string name = sender?.UserName ?? "Un usuario";
|
||||
senderDisplayName = $"El usuario {name}";
|
||||
}
|
||||
|
||||
await _notificationService.SendChatNotificationEmailAsync(
|
||||
receiver.Email,
|
||||
senderDisplayName, // Pasamos el nombre formateado
|
||||
msg.MessageText,
|
||||
msg.AdID);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Loguear error sin romper el flujo del chat
|
||||
Console.WriteLine($"Error enviando notificación de chat: {ex.Message}");
|
||||
}
|
||||
|
||||
return Ok(msg);
|
||||
}
|
||||
|
||||
[HttpGet("inbox/{userId}")]
|
||||
public async Task<IActionResult> GetInbox(int userId)
|
||||
{
|
||||
// Obtener todas las conversaciones donde el usuario es remitente o destinatario
|
||||
var messages = await _context.ChatMessages
|
||||
.Where(m => m.SenderID == userId || m.ReceiverID == userId)
|
||||
.OrderByDescending(m => m.SentAt)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(messages);
|
||||
}
|
||||
|
||||
[HttpGet("conversation/{adId}/{user1Id}/{user2Id}")]
|
||||
public async Task<IActionResult> GetConversation(int adId, int user1Id, int user2Id)
|
||||
{
|
||||
var messages = await _context.ChatMessages
|
||||
.Where(m => m.AdID == adId &&
|
||||
((m.SenderID == user1Id && m.ReceiverID == user2Id) ||
|
||||
(m.SenderID == user2Id && m.ReceiverID == user1Id)))
|
||||
.OrderBy(m => m.SentAt)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(messages);
|
||||
}
|
||||
|
||||
[HttpPost("mark-read/{messageId}")]
|
||||
public async Task<IActionResult> MarkRead(int messageId)
|
||||
{
|
||||
var msg = await _context.ChatMessages.FindAsync(messageId);
|
||||
if (msg == null) return NotFound();
|
||||
|
||||
msg.IsRead = true;
|
||||
await _context.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpGet("unread-count/{userId}")]
|
||||
public async Task<IActionResult> GetUnreadCount(int userId)
|
||||
{
|
||||
// Seguridad: Asegurarse de que el usuario que consulta es el dueño de los mensajes o un admin.
|
||||
var currentUserId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
|
||||
var isAdmin = User.IsInRole("Admin");
|
||||
|
||||
if (currentUserId != userId && !isAdmin)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var count = await _context.ChatMessages
|
||||
.CountAsync(m => m.ReceiverID == userId && !m.IsRead);
|
||||
|
||||
return Ok(new { count });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
namespace MotoresArgentinosV2.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class OperacionesLegacyController : ControllerBase
|
||||
{
|
||||
private readonly IOperacionesLegacyService _operacionesService;
|
||||
private readonly ILogger<OperacionesLegacyController> _logger;
|
||||
|
||||
public OperacionesLegacyController(IOperacionesLegacyService operacionesService, ILogger<OperacionesLegacyController> logger)
|
||||
{
|
||||
_operacionesService = operacionesService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene los medios de pago disponibles
|
||||
/// </summary>
|
||||
[HttpGet("medios-pago")]
|
||||
public async Task<ActionResult<List<MedioDePago>>> GetMediosDePago()
|
||||
{
|
||||
try
|
||||
{
|
||||
var medios = await _operacionesService.ObtenerMediosDePagoAsync();
|
||||
return Ok(medios);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener medios de pago");
|
||||
return StatusCode(500, "Ocurrió un error interno al obtener medios de pago");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Busca una operación por su número de operación
|
||||
/// </summary>
|
||||
[HttpGet("{noperacion}")]
|
||||
public async Task<ActionResult<List<Operacion>>> GetOperacion(string noperacion)
|
||||
{
|
||||
try
|
||||
{
|
||||
var operaciones = await _operacionesService.ObtenerOperacionesPorNumeroAsync(noperacion);
|
||||
|
||||
if (operaciones == null || !operaciones.Any())
|
||||
{
|
||||
return NotFound($"No se encontraron operaciones con el número {noperacion}");
|
||||
}
|
||||
|
||||
return Ok(operaciones);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener operación {Noperacion}", noperacion);
|
||||
return StatusCode(500, "Ocurrió un error interno al buscar la operación");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene operaciones realizadas en un rango de fechas
|
||||
/// </summary>
|
||||
[HttpGet("buscar")]
|
||||
public async Task<ActionResult<List<Operacion>>> GetOperacionesPorFecha([FromQuery] DateTime fechaInicio, [FromQuery] DateTime fechaFin)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (fechaInicio > fechaFin)
|
||||
{
|
||||
return BadRequest("La fecha de inicio no puede ser mayor a la fecha de fin.");
|
||||
}
|
||||
|
||||
var operaciones = await _operacionesService.ObtenerOperacionesPorFechasAsync(fechaInicio, fechaFin);
|
||||
return Ok(operaciones);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al buscar operaciones por fecha");
|
||||
return StatusCode(500, "Ocurrió un error interno al buscar operaciones.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registra una nueva operación de pago
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult> CrearOperacion([FromBody] Operacion operacion)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
// Validar campos mínimos necesarios si es necesario
|
||||
if (string.IsNullOrEmpty(operacion.Noperacion))
|
||||
{
|
||||
return BadRequest("El número de operación es obligatorio.");
|
||||
}
|
||||
|
||||
var resultado = await _operacionesService.InsertarOperacionAsync(operacion);
|
||||
|
||||
if (resultado)
|
||||
{
|
||||
return CreatedAtAction(nameof(GetOperacion), new { noperacion = operacion.Noperacion }, operacion);
|
||||
}
|
||||
|
||||
return BadRequest("No se pudo registrar la operación.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al crear operación {Noperacion}", operacion.Noperacion);
|
||||
return StatusCode(500, "Ocurrió un error interno al registrar la operación.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MotoresArgentinosV2.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class PaymentsController : ControllerBase
|
||||
{
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public PaymentsController(IPaymentService paymentService, IConfiguration config)
|
||||
{
|
||||
_paymentService = paymentService;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
[HttpPost("process")]
|
||||
[EnableRateLimiting("AuthPolicy")]
|
||||
public async Task<IActionResult> ProcessPayment([FromBody] CreatePaymentRequestDto request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
|
||||
|
||||
var result = await _paymentService.ProcessPaymentAsync(request, userId);
|
||||
|
||||
if (result.Status == "approved")
|
||||
{
|
||||
return Ok(new { status = "approved", id = result.PaymentId });
|
||||
}
|
||||
else if (result.Status == "in_process")
|
||||
{
|
||||
return Ok(new { status = "in_process", message = "El pago está siendo revisado." });
|
||||
}
|
||||
else
|
||||
{
|
||||
return BadRequest(new { status = "rejected", detail = result.StatusDetail });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// --- MÉTODO WEBHOOK ACTUALIZADO CON VALIDACIÓN DE FIRMA ---
|
||||
[HttpPost("webhook")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> MercadoPagoWebhook([FromQuery] string? topic, [FromQuery] string? id)
|
||||
{
|
||||
// 1. EXTRACCIÓN DE DATOS DE LA PETICIÓN
|
||||
Request.Headers.TryGetValue("x-request-id", out var xRequestId);
|
||||
Request.Headers.TryGetValue("x-signature", out var xSignature);
|
||||
|
||||
var resourceId = id;
|
||||
var resourceTopic = topic;
|
||||
|
||||
if (string.IsNullOrEmpty(resourceId))
|
||||
{
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
using var reader = new System.IO.StreamReader(Request.Body, leaveOpen: true);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
Request.Body.Position = 0;
|
||||
|
||||
var json = System.Text.Json.JsonDocument.Parse(body);
|
||||
if (json.RootElement.TryGetProperty("type", out var typeProp))
|
||||
{
|
||||
resourceTopic = typeProp.GetString();
|
||||
}
|
||||
if (json.RootElement.TryGetProperty("data", out var dataProp) && dataProp.TryGetProperty("id", out var idProp))
|
||||
{
|
||||
resourceId = idProp.GetString();
|
||||
}
|
||||
}
|
||||
catch { /* Ignorar errores de lectura */ }
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(resourceId) || resourceTopic != "payment")
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// 2. VALIDACIÓN DE LA FIRMA
|
||||
var secret = _config["MercadoPago:WebhookSecret"];
|
||||
if (!string.IsNullOrEmpty(secret))
|
||||
{
|
||||
var signatureParts = xSignature.ToString().Split(',')
|
||||
.Select(part => part.Trim().Split('='))
|
||||
.ToDictionary(split => split[0], split => split.Length > 1 ? split[1] : "");
|
||||
|
||||
if (!signatureParts.TryGetValue("ts", out var ts) || !signatureParts.TryGetValue("v1", out var hash))
|
||||
{
|
||||
return Unauthorized("Invalid signature header.");
|
||||
}
|
||||
|
||||
// Según la documentación de MP, el data.id debe estar en minúsculas para la firma.
|
||||
var manifest = $"id:{resourceId.ToLower()};request-id:{xRequestId};ts:{ts};";
|
||||
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var computedHashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(manifest));
|
||||
var computedHash = Convert.ToHexString(computedHashBytes).ToLower();
|
||||
|
||||
if (computedHash != hash)
|
||||
{
|
||||
return Unauthorized("Signature mismatch.");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. PROCESAMIENTO (SOLO SI LA FIRMA ES VÁLIDA)
|
||||
try
|
||||
{
|
||||
await _paymentService.ProcessWebhookAsync(resourceTopic, resourceId);
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error procesando webhook validado: {ex.Message}");
|
||||
return StatusCode(500, "Internal server error processing webhook.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("check-status/{adId}")]
|
||||
public async Task<IActionResult> CheckStatus(int adId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _paymentService.CheckPaymentStatusAsync(adId);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
using MotoresArgentinosV2.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace MotoresArgentinosV2.API.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ProfileController : ControllerBase
|
||||
{
|
||||
private readonly MotoresV2DbContext _context;
|
||||
|
||||
public ProfileController(MotoresV2DbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetProfile()
|
||||
{
|
||||
var userId = int.Parse(User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "0");
|
||||
var user = await _context.Users
|
||||
.Where(u => u.UserID == userId)
|
||||
.Select(u => new
|
||||
{
|
||||
u.UserID,
|
||||
u.UserName,
|
||||
u.Email,
|
||||
u.FirstName,
|
||||
u.LastName,
|
||||
u.PhoneNumber,
|
||||
u.UserType,
|
||||
u.CreatedAt,
|
||||
u.IsEmailVerified
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (user == null) return NotFound();
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
public async Task<IActionResult> UpdateProfile([FromBody] ProfileUpdateDto dto)
|
||||
{
|
||||
var userId = int.Parse(User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "0");
|
||||
var user = await _context.Users.FindAsync(userId);
|
||||
|
||||
if (user == null) return NotFound();
|
||||
|
||||
user.FirstName = dto.FirstName;
|
||||
user.LastName = dto.LastName;
|
||||
user.PhoneNumber = dto.PhoneNumber;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Audit Log
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "PROFILE_UPDATED",
|
||||
Entity = "User",
|
||||
EntityID = userId,
|
||||
UserID = userId,
|
||||
Details = "Usuario actualizó su perfil personal."
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new { message = "Perfil actualizado con éxito." });
|
||||
}
|
||||
}
|
||||
180
Backend/MotoresArgentinosV2.API/Controllers/SeedController.cs
Normal file
180
Backend/MotoresArgentinosV2.API/Controllers/SeedController.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MotoresArgentinosV2.Infrastructure.Data;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
namespace MotoresArgentinosV2.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class SeedController : ControllerBase
|
||||
{
|
||||
private readonly MotoresV2DbContext _context;
|
||||
private readonly IPasswordService _passwordService;
|
||||
|
||||
public SeedController(MotoresV2DbContext context, IPasswordService passwordService)
|
||||
{
|
||||
_context = context;
|
||||
_passwordService = passwordService;
|
||||
}
|
||||
|
||||
[HttpPost("database")]
|
||||
public async Task<IActionResult> SeedDatabase()
|
||||
{
|
||||
// 1. Asegurar Marcas y Modelos
|
||||
if (!await _context.Brands.AnyAsync())
|
||||
{
|
||||
var toyota = new Brand { VehicleTypeID = 1, Name = "Toyota" };
|
||||
var ford = new Brand { VehicleTypeID = 1, Name = "Ford" };
|
||||
var vw = new Brand { VehicleTypeID = 1, Name = "Volkswagen" };
|
||||
var honda = new Brand { VehicleTypeID = 2, Name = "Honda" };
|
||||
var yamaha = new Brand { VehicleTypeID = 2, Name = "Yamaha" };
|
||||
|
||||
_context.Brands.AddRange(toyota, ford, vw, honda, yamaha);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_context.Models.AddRange(
|
||||
new Model { BrandID = toyota.BrandID, Name = "Corolla" },
|
||||
new Model { BrandID = toyota.BrandID, Name = "Hilux" },
|
||||
new Model { BrandID = ford.BrandID, Name = "Ranger" },
|
||||
new Model { BrandID = vw.BrandID, Name = "Amarok" },
|
||||
new Model { BrandID = honda.BrandID, Name = "Wave 110" },
|
||||
new Model { BrandID = yamaha.BrandID, Name = "FZ FI" }
|
||||
);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// 2. Crear Usuarios de Prueba
|
||||
var testUser = await _context.Users.FirstOrDefaultAsync(u => u.UserName == "testuser");
|
||||
if (testUser == null)
|
||||
{
|
||||
testUser = new User
|
||||
{
|
||||
UserName = "testuser",
|
||||
Email = "test@motores.com.ar",
|
||||
PasswordHash = _passwordService.HashPassword("test123"),
|
||||
FirstName = "Usuario",
|
||||
LastName = "Prueba",
|
||||
MigrationStatus = 1,
|
||||
UserType = 1
|
||||
};
|
||||
_context.Users.Add(testUser);
|
||||
}
|
||||
|
||||
var adminUser = await _context.Users.FirstOrDefaultAsync(u => u.UserName == "admin");
|
||||
if (adminUser == null)
|
||||
{
|
||||
adminUser = new User
|
||||
{
|
||||
UserName = "admin",
|
||||
Email = "admin@motoresargentinos.com.ar",
|
||||
PasswordHash = _passwordService.HashPassword("admin123"),
|
||||
FirstName = "Admin",
|
||||
LastName = "Motores",
|
||||
MigrationStatus = 1,
|
||||
UserType = 3 // ADMIN
|
||||
};
|
||||
_context.Users.Add(adminUser);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// 3. Crear Avisos de Prueba
|
||||
if (!await _context.Ads.AnyAsync())
|
||||
{
|
||||
var brands = await _context.Brands.ToListAsync();
|
||||
var models = await _context.Models.ToListAsync();
|
||||
|
||||
var ad1 = new Ad
|
||||
{
|
||||
UserID = testUser.UserID,
|
||||
VehicleTypeID = 1,
|
||||
BrandID = brands.First(b => b.Name == "Toyota").BrandID,
|
||||
ModelID = models.First(m => m.Name == "Corolla").ModelID,
|
||||
VersionName = "Toyota Corolla 1.8 XLI",
|
||||
Year = 2022,
|
||||
KM = 15000,
|
||||
Price = 25000,
|
||||
Currency = "USD",
|
||||
Description = "Excelente estado, único dueño. Service al día.",
|
||||
StatusID = 4, // Activo
|
||||
IsFeatured = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
PublishedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var ad2 = new Ad
|
||||
{
|
||||
UserID = testUser.UserID,
|
||||
VehicleTypeID = 2,
|
||||
BrandID = brands.First(b => b.Name == "Honda").BrandID,
|
||||
ModelID = models.First(m => m.Name == "Wave 110").ModelID,
|
||||
VersionName = "Honda Wave 110 S",
|
||||
Year = 2023,
|
||||
KM = 2500,
|
||||
Price = 1800,
|
||||
Currency = "USD",
|
||||
Description = "Impecable, como nueva. Muy económica.",
|
||||
StatusID = 4, // Activo
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
PublishedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var ad3 = new Ad
|
||||
{
|
||||
UserID = testUser.UserID,
|
||||
VehicleTypeID = 1,
|
||||
BrandID = brands.First(b => b.Name == "Ford").BrandID,
|
||||
ModelID = models.First(m => m.Name == "Ranger").ModelID,
|
||||
VersionName = "Ford Ranger Limited 4x4",
|
||||
Year = 2021,
|
||||
KM = 35000,
|
||||
Price = 42000,
|
||||
Currency = "USD",
|
||||
Description = "Camioneta impecable, lista para transferir.",
|
||||
StatusID = 3, // Moderacion Pendiente
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.Ads.AddRange(ad1, ad2, ad3);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Agregar fotos
|
||||
_context.AdPhotos.AddRange(
|
||||
new AdPhoto { AdID = ad1.AdID, FilePath = "https://images.unsplash.com/photo-1621007947382-bb3c3994e3fb?auto=format&fit=crop&q=80&w=1200", IsCover = true },
|
||||
new AdPhoto { AdID = ad1.AdID, FilePath = "https://images.unsplash.com/photo-1590362891991-f776e933a68e?auto=format&fit=crop&q=80&w=1200" },
|
||||
new AdPhoto { AdID = ad1.AdID, FilePath = "https://images.unsplash.com/photo-1549317661-bd32c8ce0db2?auto=format&fit=crop&q=80&w=1200" },
|
||||
new AdPhoto { AdID = ad2.AdID, FilePath = "https://images.unsplash.com/photo-1558981403-c5f91cbba527?auto=format&fit=crop&q=80&w=800", IsCover = true },
|
||||
new AdPhoto { AdID = ad3.AdID, FilePath = "https://images.unsplash.com/photo-1533473359331-0135ef1b58bf?auto=format&fit=crop&q=80&w=1200", IsCover = true }
|
||||
);
|
||||
|
||||
// Agregar Características Técnicas
|
||||
_context.AdFeatures.AddRange(
|
||||
new AdFeature { AdID = ad1.AdID, FeatureKey = "Combustible", FeatureValue = "Nafta" },
|
||||
new AdFeature { AdID = ad1.AdID, FeatureKey = "Transmision", FeatureValue = "Automática" },
|
||||
new AdFeature { AdID = ad1.AdID, FeatureKey = "Color", FeatureValue = "Blanco" },
|
||||
new AdFeature { AdID = ad2.AdID, FeatureKey = "Combustible", FeatureValue = "Nafta" },
|
||||
new AdFeature { AdID = ad2.AdID, FeatureKey = "Color", FeatureValue = "Rojo" }
|
||||
);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok("Database seeded successfully with Features and Multiple Photos");
|
||||
}
|
||||
|
||||
[HttpPost("reset")]
|
||||
public async Task<IActionResult> ResetDatabase()
|
||||
{
|
||||
_context.ChangeTracker.Clear();
|
||||
await _context.Database.ExecuteSqlRawAsync("DELETE FROM Transactions");
|
||||
await _context.Database.ExecuteSqlRawAsync("DELETE FROM AdPhotos");
|
||||
await _context.Database.ExecuteSqlRawAsync("DELETE FROM AdFeatures");
|
||||
await _context.Database.ExecuteSqlRawAsync("DELETE FROM Ads");
|
||||
await _context.Database.ExecuteSqlRawAsync("DELETE FROM Models");
|
||||
await _context.Database.ExecuteSqlRawAsync("DELETE FROM Brands");
|
||||
|
||||
return await SeedDatabase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
namespace MotoresArgentinosV2.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class UsuariosLegacyController : ControllerBase
|
||||
{
|
||||
private readonly IUsuariosLegacyService _usuariosService;
|
||||
|
||||
public UsuariosLegacyController(IUsuariosLegacyService usuariosService)
|
||||
{
|
||||
_usuariosService = usuariosService;
|
||||
}
|
||||
|
||||
[HttpGet("particular/{usuario}")]
|
||||
public async Task<ActionResult<UsuarioLegacyDto>> GetParticular(string usuario)
|
||||
{
|
||||
var datos = await _usuariosService.ObtenerParticularPorUsuarioAsync(usuario);
|
||||
if (datos == null) return NotFound("Usuario particular no encontrado en legacy.");
|
||||
return Ok(datos);
|
||||
}
|
||||
|
||||
[HttpGet("agencia/{usuario}")]
|
||||
public async Task<ActionResult<AgenciaLegacyDto>> GetAgencia(string usuario)
|
||||
{
|
||||
var datos = await _usuariosService.ObtenerAgenciaPorUsuarioAsync(usuario);
|
||||
if (datos == null) return NotFound("Agencia no encontrada en legacy.");
|
||||
return Ok(datos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotNetEnv" Version="3.1.1" />
|
||||
<PackageReference Include="mercadopago-sdk" Version="2.11.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MotoresArgentinosV2.Infrastructure\MotoresArgentinosV2.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@MotoresArgentinosV2.API_HostAddress = http://localhost:5262
|
||||
|
||||
GET {{MotoresArgentinosV2.API_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
216
Backend/MotoresArgentinosV2.API/Program.cs
Normal file
216
Backend/MotoresArgentinosV2.API/Program.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.AspNetCore.RateLimiting; // Rate Limit
|
||||
using System.Threading.RateLimiting; // Rate Limit
|
||||
using System.Text;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using MotoresArgentinosV2.Infrastructure.Data;
|
||||
using MotoresArgentinosV2.Infrastructure.Services;
|
||||
using MotoresArgentinosV2.Core.Models;
|
||||
using DotNetEnv;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
|
||||
// 🔒 ENV VARS
|
||||
Env.Load();
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Forzar a la configuración a leer las variables
|
||||
builder.Configuration.AddEnvironmentVariables();
|
||||
|
||||
// 🔒 KESTREL HARDENING
|
||||
builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);
|
||||
|
||||
// LOGGING
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddConsole();
|
||||
builder.Logging.AddDebug();
|
||||
|
||||
// 🔒 CORS POLICY
|
||||
var frontendUrls = (builder.Configuration["AppSettings:FrontendUrl"] ?? "http://localhost:5173").Split(',');
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("AllowSpecificOrigin",
|
||||
policy => policy.WithOrigins(frontendUrls)
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials());
|
||||
});
|
||||
|
||||
// FORWARDED HEADERS (CRÍTICO PARA DOCKER/NGINX)
|
||||
// Por defecto, .NET solo confía en localhost. En Docker, Nginx tiene otra IP.
|
||||
// Debemos limpiar las redes conocidas para que confíe en el proxy interno de Docker.
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
options.KnownIPNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
// 🔒 RATE LIMITING
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
|
||||
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
|
||||
{
|
||||
// En producción detrás de Nginx, RemoteIpAddress será la IP real del usuario.
|
||||
// Si por alguna razón falla (ej: conexión directa local), usamos "unknown".
|
||||
var remoteIp = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
// Si es Loopback (localhost), sin límites (útil para dev)
|
||||
if (System.Net.IPAddress.IsLoopback(context.Connection.RemoteIpAddress!))
|
||||
{
|
||||
return RateLimitPartition.GetNoLimiter("loopback");
|
||||
}
|
||||
|
||||
return RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: remoteIp, // Clave correcta: IP del usuario
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
AutoReplenishment = true,
|
||||
PermitLimit = 100,
|
||||
QueueLimit = 2,
|
||||
Window = TimeSpan.FromMinutes(1)
|
||||
});
|
||||
});
|
||||
|
||||
options.AddPolicy("AuthPolicy", context =>
|
||||
{
|
||||
// 🟢 FIX: Si es localhost, SIN LÍMITES
|
||||
var remoteIp = context.Connection.RemoteIpAddress;
|
||||
if (System.Net.IPAddress.IsLoopback(remoteIp!))
|
||||
{
|
||||
return RateLimitPartition.GetNoLimiter("loopback_auth");
|
||||
}
|
||||
|
||||
return RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: remoteIp?.ToString() ?? "unknown",
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
AutoReplenishment = true,
|
||||
PermitLimit = 5, // 5 intentos por minuto para IPs externas
|
||||
QueueLimit = 0,
|
||||
Window = TimeSpan.FromMinutes(1)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// DB CONTEXTS
|
||||
builder.Services.AddDbContext<InternetDbContext>(options =>
|
||||
options.UseSqlServer(builder.Configuration.GetConnectionString("Internet")));
|
||||
builder.Services.AddDbContext<AutosDbContext>(options =>
|
||||
options.UseSqlServer(builder.Configuration.GetConnectionString("Autos")));
|
||||
builder.Services.AddDbContext<MotoresV2DbContext>(options =>
|
||||
options.UseSqlServer(builder.Configuration.GetConnectionString("MotoresV2"),
|
||||
sqlOptions => sqlOptions.EnableRetryOnFailure()));
|
||||
|
||||
// SERVICIOS
|
||||
builder.Services.AddScoped<IAvisosLegacyService, AvisosLegacyService>();
|
||||
builder.Services.AddScoped<IOperacionesLegacyService, OperacionesLegacyService>();
|
||||
builder.Services.AddScoped<IUsuariosLegacyService, UsuariosLegacyService>();
|
||||
builder.Services.AddScoped<IPasswordService, PasswordService>();
|
||||
builder.Services.AddScoped<IIdentityService, IdentityService>();
|
||||
builder.Services.AddScoped<ILegacyPaymentService, LegacyPaymentService>();
|
||||
builder.Services.AddScoped<IPaymentService, MercadoPagoService>();
|
||||
builder.Services.AddScoped<IAdSyncService, AdSyncService>();
|
||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||
builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("SmtpSettings"));
|
||||
builder.Services.AddScoped<IEmailService, SmtpEmailService>();
|
||||
builder.Services.AddScoped<IImageStorageService, ImageStorageService>();
|
||||
builder.Services.AddHostedService<AdExpirationService>();
|
||||
|
||||
// 🔒 JWT AUTH
|
||||
var jwtKey = builder.Configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key Missing");
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = builder.Configuration["Jwt:Issuer"],
|
||||
ValidAudience = builder.Configuration["Jwt:Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey))
|
||||
};
|
||||
// 🟢 LEER TOKEN DESDE COOKIE
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
// Buscar el token en la cookie llamada "accessToken"
|
||||
var accessToken = context.Request.Cookies["accessToken"];
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
context.Token = accessToken;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// USAR EL MIDDLEWARE AL PRINCIPIO
|
||||
// Debe ser lo primero para que el RateLimiter y los Logs vean la IP real
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
// 🔒 HEADERS DE SEGURIDAD MIDDLEWARE
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
context.Response.Headers.Append("X-Frame-Options", "DENY");
|
||||
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
|
||||
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
context.Response.Headers.Append("X-XSS-Protection", "1; mode=block");
|
||||
|
||||
// CSP adaptada para permitir pagos en Payway y WebSockets de Vite
|
||||
string csp = "default-src 'self'; " +
|
||||
"img-src 'self' data: https: blob:; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"connect-src 'self' https: ws: wss:; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self' https://developers-ventasonline.payway.com.ar; " +
|
||||
"frame-ancestors 'none';";
|
||||
context.Response.Headers.Append("Content-Security-Policy", csp);
|
||||
|
||||
context.Response.Headers.Remove("Server");
|
||||
context.Response.Headers.Remove("X-Powered-By");
|
||||
await next();
|
||||
});
|
||||
|
||||
// PIPELINE
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
else
|
||||
{
|
||||
// 🔒 HSTS en Producción
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseStaticFiles();
|
||||
|
||||
// 🔒 APLICAR CORS & RATE LIMIT
|
||||
app.UseCors("AllowSpecificOrigin");
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5262",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7251;http://localhost:5262",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Backend/MotoresArgentinosV2.API/appsettings.json
Normal file
9
Backend/MotoresArgentinosV2.API/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Reference in New Issue
Block a user