Init Commit
This commit is contained in:
31
Backend/Dockerfile.API
Normal file
31
Backend/Dockerfile.API
Normal file
@@ -0,0 +1,31 @@
|
||||
# Etapa de construcción
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copiar archivos .sln y .csproj para restaurar dependencias
|
||||
COPY MotoresArgentinosV2.sln ./
|
||||
COPY Backend/MotoresArgentinosV2.API/*.csproj Backend/MotoresArgentinosV2.API/
|
||||
COPY Backend/MotoresArgentinosV2.Core/*.csproj Backend/MotoresArgentinosV2.Core/
|
||||
COPY Backend/MotoresArgentinosV2.Infrastructure/*.csproj Backend/MotoresArgentinosV2.Infrastructure/
|
||||
COPY Backend/MotoresArgentinosV2.MigrationTool/*.csproj Backend/MotoresArgentinosV2.MigrationTool/
|
||||
|
||||
RUN dotnet restore
|
||||
|
||||
# Copiar el resto del código y construir
|
||||
COPY . .
|
||||
WORKDIR "/src/Backend/MotoresArgentinosV2.API"
|
||||
RUN dotnet build "MotoresArgentinosV2.API.csproj" -c Release -o /app/build
|
||||
|
||||
# Publicar
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "MotoresArgentinosV2.API.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# Etapa final: Ejecución
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
|
||||
# Exponer el puerto configurado (ASP.NET 10 usa 8080 por defecto en contenedores)
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["dotnet", "MotoresArgentinosV2.API.dll"]
|
||||
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": "*"
|
||||
}
|
||||
120
Backend/MotoresArgentinosV2.Core/DTOs/AdDtos.cs
Normal file
120
Backend/MotoresArgentinosV2.Core/DTOs/AdDtos.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MotoresArgentinosV2.Core.DTOs;
|
||||
|
||||
public class CreateAdRequestDto
|
||||
{
|
||||
[Required]
|
||||
[Range(1, 3, ErrorMessage = "Tipo de vehículo inválido")]
|
||||
[JsonPropertyName("vehicleTypeID")]
|
||||
public int VehicleTypeID { get; set; }
|
||||
|
||||
[Required]
|
||||
[JsonPropertyName("brandID")]
|
||||
public int BrandID { get; set; }
|
||||
|
||||
[JsonPropertyName("modelID")]
|
||||
public int ModelID { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(100, MinimumLength = 1, ErrorMessage = "La versión debe tener entre 1 y 100 caracteres.")]
|
||||
[RegularExpression(@"^[a-zA-Z0-9\s\-\.\(\),/áéíóúÁÉÍÓÚñÑ]+$", ErrorMessage = "Caracteres no permitidos en el nombre de versión.")]
|
||||
[JsonPropertyName("versionName")]
|
||||
public string VersionName { get; set; } = string.Empty;
|
||||
|
||||
[Range(1900, 2100)]
|
||||
[JsonPropertyName("year")]
|
||||
public int Year { get; set; }
|
||||
|
||||
[Range(0, 2000000)]
|
||||
[JsonPropertyName("km")]
|
||||
public int KM { get; set; }
|
||||
|
||||
[Range(0, 999999999)]
|
||||
[JsonPropertyName("price")]
|
||||
public decimal Price { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(3, MinimumLength = 3)]
|
||||
[RegularExpression(@"^(ARS|USD)$", ErrorMessage = "Moneda inválida.")]
|
||||
[JsonPropertyName("currency")]
|
||||
public string Currency { get; set; } = "ARS";
|
||||
|
||||
[StringLength(1000, ErrorMessage = "La descripción no puede superar los 1000 caracteres.")]
|
||||
[RegularExpression(@"^(?!.*<script>).*$", ErrorMessage = "Contenido no permitido.")]
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("isFeatured")]
|
||||
public bool IsFeatured { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
[JsonPropertyName("fuelType")]
|
||||
public string? FuelType { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
[JsonPropertyName("color")]
|
||||
public string? Color { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
[JsonPropertyName("segment")]
|
||||
public string? Segment { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
[JsonPropertyName("location")]
|
||||
public string? Location { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
[JsonPropertyName("condition")]
|
||||
public string? Condition { get; set; }
|
||||
|
||||
[JsonPropertyName("doorCount")]
|
||||
public int? DoorCount { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
[JsonPropertyName("transmission")]
|
||||
public string? Transmission { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
[JsonPropertyName("steering")]
|
||||
public string? Steering { get; set; }
|
||||
|
||||
// --- Contacto Personalizado ---
|
||||
|
||||
[StringLength(50)]
|
||||
[RegularExpression(@"^\+?[0-9\s\-\(\)]+$", ErrorMessage = "Formato de teléfono inválido.")]
|
||||
[JsonPropertyName("contactPhone")]
|
||||
public string? ContactPhone { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
[EmailAddress(ErrorMessage = "Formato de email inválido.")]
|
||||
[JsonPropertyName("contactEmail")]
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
[JsonPropertyName("displayContactInfo")]
|
||||
public bool DisplayContactInfo { get; set; } = true;
|
||||
|
||||
// --- Admin Only ---
|
||||
|
||||
[JsonPropertyName("targetUserID")]
|
||||
public int? TargetUserID { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
[EmailAddress(ErrorMessage = "Email de usuario fantasma inválido.")]
|
||||
[JsonPropertyName("ghostUserEmail")]
|
||||
public string? GhostUserEmail { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
[JsonPropertyName("ghostFirstName")]
|
||||
public string? GhostFirstName { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
[JsonPropertyName("ghostLastName")]
|
||||
public string? GhostLastName { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
[RegularExpression(@"^\+?[0-9\s\-\(\)]+$", ErrorMessage = "Teléfono de usuario fantasma inválido.")]
|
||||
[JsonPropertyName("ghostUserPhone")]
|
||||
public string? GhostUserPhone { get; set; }
|
||||
}
|
||||
50
Backend/MotoresArgentinosV2.Core/DTOs/AuthDtos.cs
Normal file
50
Backend/MotoresArgentinosV2.Core/DTOs/AuthDtos.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
namespace MotoresArgentinosV2.Core.DTOs;
|
||||
|
||||
public class LoginRequest
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class MigrateRequest
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class MFARequest
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Code { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class RegisterRequest
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
public string LastName { get; set; } = string.Empty;
|
||||
public string PhoneNumber { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class VerifyEmailRequest
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class ResendVerificationRequest
|
||||
{
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class ForgotPasswordRequest
|
||||
{
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class ResetPasswordRequest
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
13
Backend/MotoresArgentinosV2.Core/DTOs/AvisoWebDto.cs
Normal file
13
Backend/MotoresArgentinosV2.Core/DTOs/AvisoWebDto.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace MotoresArgentinosV2.Core.DTOs;
|
||||
|
||||
public class AvisoWebDto
|
||||
{
|
||||
public string NombreAviso { get; set; } = string.Empty;
|
||||
public DateTime? FechaInicio { get; set; }
|
||||
public decimal ImporteAviso { get; set; }
|
||||
public string? Estado { get; set; }
|
||||
|
||||
// Agrego campos extra útiles si existen (deducidos)
|
||||
public int? NroOperacion { get; set; }
|
||||
public string? Razon { get; set; }
|
||||
}
|
||||
66
Backend/MotoresArgentinosV2.Core/DTOs/DatosAvisoDto.cs
Normal file
66
Backend/MotoresArgentinosV2.Core/DTOs/DatosAvisoDto.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MotoresArgentinosV2.Core.DTOs;
|
||||
|
||||
public class DatosAvisoDto
|
||||
{
|
||||
// --- Bytes (TinyInt en SQL) ---
|
||||
[Column("ID_TIPOAVI")]
|
||||
public byte IdTipoavi { get; set; }
|
||||
|
||||
[Column("ID_SUBRUBRO")]
|
||||
public byte IdSubrubro { get; set; }
|
||||
|
||||
// --- Shorts (SmallInt en SQL) ---
|
||||
// ESTE ERA EL CAUSANTE DEL ERROR PRINCIPAL
|
||||
[Column("ID_RUBRO")]
|
||||
public short IdRubro { get; set; }
|
||||
|
||||
// --- Ints (Int32 en SQL) ---
|
||||
[Column("ID_COMBINADO")]
|
||||
public int IdCombinado { get; set; }
|
||||
|
||||
[Column("PORCENTAJE_COMBINADO")]
|
||||
public int PorcentajeCombinado { get; set; }
|
||||
|
||||
[Column("CANTIDAD_DIAS")]
|
||||
public int CantidadDias { get; set; }
|
||||
|
||||
[Column("DIAS_CORRIDOS")]
|
||||
public int DiasCorridos { get; set; }
|
||||
|
||||
[Column("PALABRAS")]
|
||||
public int Palabras { get; set; }
|
||||
|
||||
[Column("CENTIMETROS")]
|
||||
public int Centimetros { get; set; }
|
||||
|
||||
[Column("COLUMNAS")]
|
||||
public int Columnas { get; set; }
|
||||
|
||||
[Column("TOTAL_AVISOS")]
|
||||
public int TotalAvisos { get; set; }
|
||||
|
||||
[Column("DESTACADO")]
|
||||
public int Destacado { get; set; }
|
||||
|
||||
[Column("PAQUETE")]
|
||||
public int Paquete { get; set; }
|
||||
|
||||
// --- Decimales ---
|
||||
[Column("IMPORTE_SINIVA")]
|
||||
public decimal ImporteSiniva { get; set; }
|
||||
|
||||
[Column("IMPORTE_TOTSINIVA")]
|
||||
public decimal ImporteTotsiniva { get; set; }
|
||||
|
||||
// --- Strings ---
|
||||
[Column("NOMAVI")]
|
||||
public string Nomavi { get; set; } = string.Empty;
|
||||
|
||||
[Column("TEXTOAVI")]
|
||||
public string Textoavi { get; set; } = string.Empty;
|
||||
|
||||
[Column("DESCRIPCION")]
|
||||
public string Descripcion { get; set; } = string.Empty;
|
||||
}
|
||||
70
Backend/MotoresArgentinosV2.Core/DTOs/InsertarAvisoDto.cs
Normal file
70
Backend/MotoresArgentinosV2.Core/DTOs/InsertarAvisoDto.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
namespace MotoresArgentinosV2.Core.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// DTO para insertar un aviso mediante el SP spInsertaAvisos
|
||||
/// </summary>
|
||||
public class InsertarAvisoDto
|
||||
{
|
||||
// Datos básicos
|
||||
public string Tipo { get; set; } = string.Empty;
|
||||
public int NroOperacion { get; set; }
|
||||
public int IdCliente { get; set; }
|
||||
public int Tipodoc { get; set; }
|
||||
public string NroDoc { get; set; } = string.Empty;
|
||||
public string Razon { get; set; } = string.Empty;
|
||||
|
||||
// Ubicación del cliente
|
||||
public string Calle { get; set; } = string.Empty;
|
||||
public string Numero { get; set; } = string.Empty;
|
||||
public string Localidad { get; set; } = string.Empty;
|
||||
public string CodigoPostal { get; set; } = string.Empty;
|
||||
|
||||
// Contacto
|
||||
public string Telefono { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
// Impuestos
|
||||
public byte IdTipoiva { get; set; }
|
||||
public decimal PorcentajeIva1 { get; set; }
|
||||
public decimal PorcentajeIva2 { get; set; }
|
||||
public decimal PorcentajePercepcion { get; set; }
|
||||
|
||||
// Datos del aviso
|
||||
public byte IdTipoaviso { get; set; }
|
||||
public string Nombreaviso { get; set; } = string.Empty;
|
||||
public short IdRubro { get; set; }
|
||||
public byte IdSubrubro { get; set; }
|
||||
public byte IdCombinado { get; set; }
|
||||
public decimal PorcentajeCombinado { get; set; }
|
||||
|
||||
// Publicación
|
||||
public DateTime FechaInicio { get; set; }
|
||||
public byte CantDias { get; set; }
|
||||
public bool DiasCorridos { get; set; }
|
||||
public byte Palabras { get; set; }
|
||||
public decimal Centimetros { get; set; }
|
||||
public byte Columnas { get; set; }
|
||||
|
||||
// Pago
|
||||
public byte IdTarjeta { get; set; }
|
||||
public string NroTarjeta { get; set; } = string.Empty;
|
||||
public int CvcTarjeta { get; set; }
|
||||
public DateTime Vencimiento { get; set; }
|
||||
|
||||
// Dirección de envío (si aplica)
|
||||
public string CalleEnvio { get; set; } = string.Empty;
|
||||
public string NumeroEnvio { get; set; } = string.Empty;
|
||||
public string LocalidadEnvio { get; set; } = string.Empty;
|
||||
|
||||
// Importes
|
||||
public decimal Tarifa { get; set; }
|
||||
public decimal ImporteAviso { get; set; }
|
||||
public decimal ImporteIva1 { get; set; }
|
||||
public decimal ImporteIva2 { get; set; }
|
||||
public decimal ImportePercepcion { get; set; }
|
||||
|
||||
// Extras
|
||||
public int Cantavi { get; set; } = 1;
|
||||
public int Paquete { get; set; } = 0;
|
||||
public bool Destacado { get; set; } = false;
|
||||
}
|
||||
38
Backend/MotoresArgentinosV2.Core/DTOs/PaymentDtos.cs
Normal file
38
Backend/MotoresArgentinosV2.Core/DTOs/PaymentDtos.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MotoresArgentinosV2.Core.DTOs;
|
||||
|
||||
public class CreatePaymentRequestDto
|
||||
{
|
||||
[Required]
|
||||
public int AdId { get; set; } // ID del aviso que se está pagando
|
||||
|
||||
[Required]
|
||||
public string Token { get; set; } = string.Empty; // Token de la tarjeta generado en el front
|
||||
|
||||
[Required]
|
||||
public string PaymentMethodId { get; set; } = string.Empty; // ej: "visa", "master"
|
||||
|
||||
[Required]
|
||||
public int Installments { get; set; } // Cuotas
|
||||
|
||||
[Required]
|
||||
public string IssuerId { get; set; } = string.Empty; // Banco emisor
|
||||
|
||||
[Required]
|
||||
public decimal TransactionAmount { get; set; }
|
||||
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string PayerEmail { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
public class PaymentResponseDto
|
||||
{
|
||||
public long PaymentId { get; set; }
|
||||
public string Status { get; set; } = string.Empty; // approved, rejected, in_process
|
||||
public string StatusDetail { get; set; } = string.Empty;
|
||||
public string OperationCode { get; set; } = string.Empty; // Nuestro ID interno (M2-...)
|
||||
}
|
||||
32
Backend/MotoresArgentinosV2.Core/DTOs/UserDtos.cs
Normal file
32
Backend/MotoresArgentinosV2.Core/DTOs/UserDtos.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace MotoresArgentinosV2.Core.DTOs;
|
||||
|
||||
public class UserDetailDto
|
||||
{
|
||||
public int UserID { get; set; }
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
public string? PhoneNumber { get; set; }
|
||||
public byte UserType { get; set; }
|
||||
public bool IsBlocked { get; set; }
|
||||
public byte MigrationStatus { get; set; }
|
||||
public bool IsEmailVerified { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class UserUpdateDto
|
||||
{
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
public string? PhoneNumber { get; set; }
|
||||
public byte UserType { get; set; }
|
||||
}
|
||||
|
||||
public class ProfileUpdateDto
|
||||
{
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
public string? PhoneNumber { get; set; }
|
||||
}
|
||||
41
Backend/MotoresArgentinosV2.Core/DTOs/UsuariosLegacyDtos.cs
Normal file
41
Backend/MotoresArgentinosV2.Core/DTOs/UsuariosLegacyDtos.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
namespace MotoresArgentinosV2.Core.DTOs;
|
||||
|
||||
public class UsuarioLegacyDto
|
||||
{
|
||||
// Mapeo tentativo basado en convenciones v_particulares
|
||||
public int Part_Id { get; set; }
|
||||
public string Part_Usu_Nombre { get; set; } = string.Empty;
|
||||
public string? Part_Nombre { get; set; } // O razon social
|
||||
public string? Part_Apellido { get; set; }
|
||||
public string? Part_Email { get; set; }
|
||||
public string? Part_Telefono { get; set; }
|
||||
|
||||
// Dirección
|
||||
public string? Part_Calle { get; set; }
|
||||
public string? Part_Nro { get; set; }
|
||||
public string? Part_Localidad { get; set; }
|
||||
public string? Part_CP { get; set; }
|
||||
|
||||
// Fiscal
|
||||
public int? Part_TipoDoc { get; set; }
|
||||
public string? Part_NroDoc { get; set; }
|
||||
public int? Part_IdIVA { get; set; }
|
||||
}
|
||||
|
||||
public class AgenciaLegacyDto
|
||||
{
|
||||
// Mapeo tentativo basado en convenciones v_agencias
|
||||
public int Agen_Id { get; set; }
|
||||
public string Agen_usuario { get; set; } = string.Empty;
|
||||
public string? Agen_Nombre { get; set; } // Razon social
|
||||
public string? Agen_Email { get; set; }
|
||||
public string? Agen_Telefono { get; set; }
|
||||
|
||||
// Dirección
|
||||
public string? Agen_Domicilio { get; set; }
|
||||
public string? Agen_Localidad { get; set; }
|
||||
|
||||
// Fiscal
|
||||
public string? Agen_Cuit { get; set; }
|
||||
public int? Agen_IdIVA { get; set; }
|
||||
}
|
||||
209
Backend/MotoresArgentinosV2.Core/Entities/AppEntities.cs
Normal file
209
Backend/MotoresArgentinosV2.Core/Entities/AppEntities.cs
Normal file
@@ -0,0 +1,209 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MotoresArgentinosV2.Core.Entities;
|
||||
|
||||
public class Brand
|
||||
{
|
||||
public int BrandID { get; set; }
|
||||
public int VehicleTypeID { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int? LegacyID { get; set; }
|
||||
}
|
||||
|
||||
public class Model
|
||||
{
|
||||
public int ModelID { get; set; }
|
||||
public int BrandID { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int? LegacyID { get; set; }
|
||||
}
|
||||
|
||||
public enum UserMigrationStatus : byte { LegacyPending = 0, MigratedActive = 1 }
|
||||
public enum AdStatusEnum
|
||||
{
|
||||
Draft = 1,
|
||||
PaymentPending = 2,
|
||||
ModerationPending = 3,
|
||||
Active = 4,
|
||||
Rejected = 5,
|
||||
Paused = 6,
|
||||
Sold = 7,
|
||||
Expired = 8,
|
||||
Deleted = 9,
|
||||
Reserved = 10
|
||||
}
|
||||
|
||||
public class User
|
||||
{
|
||||
public int UserID { get; set; }
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
public string? PasswordSalt { get; set; }
|
||||
public byte MigrationStatus { get; set; }
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
public string? PhoneNumber { get; set; }
|
||||
public byte UserType { get; set; } = 1; // 1: Particular, 3: Admin
|
||||
public string? MFASecret { get; set; }
|
||||
public bool IsMFAEnabled { get; set; }
|
||||
|
||||
// Email Verification
|
||||
public bool IsEmailVerified { get; set; }
|
||||
public string? VerificationToken { get; set; }
|
||||
public DateTime? VerificationTokenExpiresAt { get; set; }
|
||||
public DateTime? LastVerificationEmailSentAt { get; set; }
|
||||
|
||||
// Password Reset
|
||||
public string? PasswordResetToken { get; set; }
|
||||
public DateTime? PasswordResetTokenExpiresAt { get; set; }
|
||||
public DateTime? LastPasswordResetEmailSentAt { get; set; }
|
||||
|
||||
// Bloqueo de usuario
|
||||
public bool IsBlocked { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Relación con Refresh Tokens
|
||||
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
|
||||
|
||||
// Notificaciones de recordatorio de mensajes no leídos
|
||||
public DateTime? LastUnreadMessageReminderSentAt { get; set; }
|
||||
}
|
||||
|
||||
public class AuditLog
|
||||
{
|
||||
public int AuditLogID { get; set; }
|
||||
public string Action { get; set; } = string.Empty;
|
||||
public string Entity { get; set; } = string.Empty;
|
||||
public int EntityID { get; set; }
|
||||
public int UserID { get; set; }
|
||||
public string Details { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class Ad
|
||||
{
|
||||
public int AdID { get; set; }
|
||||
public int UserID { get; set; }
|
||||
public User User { get; set; } = null!;
|
||||
|
||||
public int VehicleTypeID { get; set; }
|
||||
public int BrandID { get; set; }
|
||||
public Brand Brand { get; set; } = null!;
|
||||
public int ModelID { get; set; }
|
||||
public Model Model { get; set; } = null!;
|
||||
|
||||
public string? VersionName { get; set; }
|
||||
public int Year { get; set; }
|
||||
public int KM { get; set; }
|
||||
|
||||
public string? FuelType { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public string? Segment { get; set; }
|
||||
|
||||
public decimal Price { get; set; }
|
||||
public string Currency { get; set; } = "USD";
|
||||
public string? Description { get; set; }
|
||||
|
||||
public string? Location { get; set; }
|
||||
public string? Condition { get; set; }
|
||||
public int? DoorCount { get; set; }
|
||||
public string? Transmission { get; set; }
|
||||
public string? Steering { get; set; }
|
||||
|
||||
public string? ContactPhone { get; set; }
|
||||
public string? ContactEmail { get; set; }
|
||||
public bool DisplayContactInfo { get; set; } = true;
|
||||
public bool IsFeatured { get; set; }
|
||||
|
||||
public int StatusID { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public int? LegacyAdID { get; set; }
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
public int ViewsCounter { get; set; }
|
||||
|
||||
public ICollection<AdPhoto> Photos { get; set; } = new List<AdPhoto>();
|
||||
public ICollection<AdFeature> Features { get; set; } = new List<AdFeature>();
|
||||
public ICollection<ChatMessage> Messages { get; set; } = new List<ChatMessage>();
|
||||
|
||||
// CAMPOS DE CONTROL DE NOTIFICACIONES
|
||||
public DateTime? LastPerformanceEmailSentAt { get; set; } // Para el resumen semanal
|
||||
public DateTime? PaymentReminderSentAt { get; set; } // Para carrito abandonado
|
||||
public bool ExpirationWarningSent { get; set; } // Para aviso por vencer
|
||||
}
|
||||
|
||||
public class AdPhoto
|
||||
{
|
||||
public int PhotoID { get; set; }
|
||||
public int AdID { get; set; }
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
public bool IsCover { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public Ad Ad { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class AdFeature
|
||||
{
|
||||
public int AdID { get; set; }
|
||||
public string FeatureKey { get; set; } = string.Empty;
|
||||
public string? FeatureValue { get; set; }
|
||||
}
|
||||
|
||||
public class TransactionRecord
|
||||
{
|
||||
public int TransactionID { get; set; }
|
||||
public int AdID { get; set; }
|
||||
public Ad Ad { get; set; } = null!;
|
||||
public string OperationCode { get; set; } = string.Empty;
|
||||
public int PaymentMethodID { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string Status { get; set; } = "PENDING";
|
||||
public string? ProviderPaymentId { get; set; }
|
||||
public string? ProviderResponse { get; set; }
|
||||
public string? SnapshotUserEmail { get; set; }
|
||||
public string? SnapshotUserName { get; set; }
|
||||
public string? SnapshotAdTitle { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
public class Favorite
|
||||
{
|
||||
public int UserID { get; set; }
|
||||
public int AdID { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class ChatMessage
|
||||
{
|
||||
[Key]
|
||||
public int MessageID { get; set; }
|
||||
public int AdID { get; set; }
|
||||
|
||||
[ForeignKey("AdID")]
|
||||
[JsonIgnore]
|
||||
public Ad? Ad { get; set; }
|
||||
|
||||
public int SenderID { get; set; }
|
||||
public int ReceiverID { get; set; }
|
||||
public string MessageText { get; set; } = string.Empty;
|
||||
public DateTime SentAt { get; set; } = DateTime.UtcNow;
|
||||
public bool IsRead { get; set; } = false;
|
||||
}
|
||||
|
||||
public class PaymentMethod
|
||||
{
|
||||
public int PaymentMethodID { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class AdViewLog
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int AdID { get; set; }
|
||||
public string IPAddress { get; set; } = string.Empty;
|
||||
public DateTime ViewDate { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
18
Backend/MotoresArgentinosV2.Core/Entities/MedioDePago.cs
Normal file
18
Backend/MotoresArgentinosV2.Core/Entities/MedioDePago.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace MotoresArgentinosV2.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Entidad que representa un medio de pago disponible
|
||||
/// Tabla legacy: mediodepago (DB: autos)
|
||||
/// </summary>
|
||||
public class MedioDePago
|
||||
{
|
||||
/// <summary>
|
||||
/// Identificador único del medio de pago
|
||||
/// </summary>
|
||||
public byte Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Nombre del medio de pago (ej: Visa, MasterCard, etc.)
|
||||
/// </summary>
|
||||
public string Mediodepago { get; set; } = string.Empty;
|
||||
}
|
||||
163
Backend/MotoresArgentinosV2.Core/Entities/Operacion.cs
Normal file
163
Backend/MotoresArgentinosV2.Core/Entities/Operacion.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
namespace MotoresArgentinosV2.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Entidad que representa una operación de pago
|
||||
/// Tabla legacy: operaciones (DB: autos)
|
||||
/// </summary>
|
||||
public class Operacion
|
||||
{
|
||||
/// <summary>
|
||||
/// Identificador único de la operación (IDENTITY)
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fecha de la operación
|
||||
/// </summary>
|
||||
public DateTime? Fecha { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Motivo de la operación
|
||||
/// </summary>
|
||||
public string? Motivo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Moneda utilizada
|
||||
/// </summary>
|
||||
public string? Moneda { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dirección de entrega
|
||||
/// </summary>
|
||||
public string? Direccionentrega { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Código de validación de domicilio
|
||||
/// </summary>
|
||||
public string? Validaciondomicilio { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Código de pedido
|
||||
/// </summary>
|
||||
public string? Codigopedido { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Nombre para la entrega
|
||||
/// </summary>
|
||||
public string? Nombreentrega { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fecha y hora de la transacción
|
||||
/// </summary>
|
||||
public string? Fechahora { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Teléfono del comprador
|
||||
/// </summary>
|
||||
public string? Telefonocomprador { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Barrio de entrega
|
||||
/// </summary>
|
||||
public string? Barrioentrega { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Código de autorización de la transacción
|
||||
/// </summary>
|
||||
public string? Codautorizacion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// País de entrega
|
||||
/// </summary>
|
||||
public string? Paisentrega { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cantidad de cuotas
|
||||
/// </summary>
|
||||
public string? Cuotas { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Validación de fecha de nacimiento
|
||||
/// </summary>
|
||||
public string? Validafechanac { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Validación de número de documento
|
||||
/// </summary>
|
||||
public string? Validanrodoc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Titular de la tarjeta
|
||||
/// </summary>
|
||||
public string? Titular { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Número de pedido
|
||||
/// </summary>
|
||||
public string? Pedido { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Código postal de entrega
|
||||
/// </summary>
|
||||
public string? Zipentrega { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Monto de la transacción
|
||||
/// </summary>
|
||||
public string? Monto { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tipo de tarjeta utilizada
|
||||
/// </summary>
|
||||
public string? Tarjeta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fecha de entrega
|
||||
/// </summary>
|
||||
public string? Fechaentrega { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Email del comprador
|
||||
/// </summary>
|
||||
public string? Emailcomprador { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Validación número de puerta
|
||||
/// </summary>
|
||||
public string? Validanropuerta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ciudad de entrega
|
||||
/// </summary>
|
||||
public string? Ciudadentrega { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Validación tipo de documento
|
||||
/// </summary>
|
||||
public string? Validatipodoc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Número de operación
|
||||
/// </summary>
|
||||
public string? Noperacion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Estado de la entrega
|
||||
/// </summary>
|
||||
public string? Estadoentrega { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Resultado de la operación (APROBADA/RECHAZADA)
|
||||
/// </summary>
|
||||
public string? Resultado { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Mensaje sobre la entrega
|
||||
/// </summary>
|
||||
public string? Mensajeentrega { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Precio neto de la operación
|
||||
/// </summary>
|
||||
public int? Precioneto { get; set; }
|
||||
}
|
||||
22
Backend/MotoresArgentinosV2.Core/Entities/RefreshToken.cs
Normal file
22
Backend/MotoresArgentinosV2.Core/Entities/RefreshToken.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MotoresArgentinosV2.Core.Entities;
|
||||
|
||||
public class RefreshToken
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public DateTime Expires { get; set; }
|
||||
public DateTime Created { get; set; } = DateTime.UtcNow;
|
||||
public string? CreatedByIp { get; set; }
|
||||
public DateTime? Revoked { get; set; }
|
||||
public string? RevokedByIp { get; set; }
|
||||
public string? ReplacedByToken { get; set; }
|
||||
public bool IsActive => Revoked == null && !IsExpired;
|
||||
public bool IsExpired => DateTime.UtcNow >= Expires;
|
||||
|
||||
public int UserId { get; set; }
|
||||
public User User { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
|
||||
namespace MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
public interface IAdSyncService
|
||||
{
|
||||
Task<bool> SyncAdToLegacyAsync(int adId);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
|
||||
namespace MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interfaz para servicios que interactúan con stored procedures legacy
|
||||
/// relacionados con avisos
|
||||
/// </summary>
|
||||
public interface IAvisosLegacyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Ejecuta el SP spDatosAvisos para obtener tarifas y configuración
|
||||
/// </summary>
|
||||
/// <param name="tarea">Tipo de tarea (EMOTORES, EREPUESTOS, EAUTOS, etc.)</param>
|
||||
/// <param name="paquete">ID del paquete (opcional)</param>
|
||||
/// <returns>Lista de configuraciones de avisos disponibles</returns>
|
||||
Task<List<DatosAvisoDto>> ObtenerDatosAvisosAsync(string tarea, int paquete = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Ejecuta el SP spInsertaAvisos para crear un nuevo aviso
|
||||
/// </summary>
|
||||
/// <param name="aviso">Datos del aviso a crear</param>
|
||||
/// <returns>True si se insertó correctamente</returns>
|
||||
Task<bool> InsertarAvisoAsync(InsertarAvisoDto aviso);
|
||||
|
||||
Task<List<DatosAvisoDto>> ObtenerTarifasAsync(string formulario, int paquete);
|
||||
Task<List<AvisoWebDto>> ObtenerAvisosPorClienteAsync(string nroDocumento);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
public interface IEmailService
|
||||
{
|
||||
Task SendEmailAsync(string to, string subject, string htmlBody);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
|
||||
namespace MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
public interface IIdentityService
|
||||
{
|
||||
Task<(User? User, string? MigrationMessage)> AuthenticateAsync(string username, string password);
|
||||
|
||||
Task<bool> MigratePasswordAsync(string username, string newPassword);
|
||||
|
||||
// métodos para registro
|
||||
Task<(bool Success, string Message)> RegisterUserAsync(RegisterRequest request);
|
||||
Task<(bool Success, string Message)> VerifyEmailAsync(string token);
|
||||
Task<(bool Success, string Message)> ResendVerificationEmailAsync(string email);
|
||||
Task<(bool Success, string Message)> ForgotPasswordAsync(string email);
|
||||
Task<(bool Success, string Message)> ResetPasswordAsync(string token, string newPassword);
|
||||
Task<(bool Success, string Message)> ChangePasswordAsync(int userId, string current, string newPwd);
|
||||
Task<User> CreateGhostUserAsync(string email, string firstName, string lastName, string phone);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
public record AdPriceResult(decimal BasePrice, decimal Tax, decimal TotalPrice, string Currency);
|
||||
|
||||
public interface ILegacyPaymentService
|
||||
{
|
||||
/// <summary>
|
||||
/// Consulta el precio en el sistema legacy (SPDATOSAVISOS)
|
||||
/// </summary>
|
||||
Task<AdPriceResult> GetAdPriceAsync(string category, bool isFeatured);
|
||||
|
||||
/// <summary>
|
||||
/// Finaliza la operación tras el pago (Simula callback de Decidir y ejecuta spInsertaAvisos)
|
||||
/// </summary>
|
||||
Task<bool> ProcessPaymentResponseAsync(string operationCode, string status, string providerData);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
public interface INotificationService
|
||||
{
|
||||
Task SendChatNotificationEmailAsync(string toEmail, string fromUser, string message, int adId);
|
||||
Task SendAdStatusChangedEmailAsync(string toEmail, string adTitle, string status, string? reason = null);
|
||||
Task SendSecurityAlertEmailAsync(string toEmail, string actionDescription);
|
||||
Task SendExpirationWarningEmailAsync(string toEmail, string userName, string adTitle, DateTime expirationDate);
|
||||
Task SendAdExpiredEmailAsync(string toEmail, string userName, string adTitle);
|
||||
Task SendWeeklyPerformanceEmailAsync(string toEmail, string userName, string adTitle, int views, int favorites);
|
||||
Task SendPaymentReminderEmailAsync(string toEmail, string userName, string adTitle, string link);
|
||||
Task SendPaymentReceiptEmailAsync(string toEmail, string userName, string adTitle, decimal amount, string operationCode);
|
||||
Task SendUnreadMessagesReminderEmailAsync(string toEmail, string userName, int unreadCount);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
|
||||
namespace MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interfaz para servicios que interactúan con stored procedures legacy
|
||||
/// relacionados con operaciones de pago
|
||||
/// </summary>
|
||||
public interface IOperacionesLegacyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Ejecuta el SP sp_inserta_operaciones para registrar una nueva operación
|
||||
/// </summary>
|
||||
/// <param name="operacion">Datos de la operación a registrar</param>
|
||||
/// <returns>True si se insertó correctamente</returns>
|
||||
Task<bool> InsertarOperacionAsync(Operacion operacion);
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene operaciones por número de operación
|
||||
/// </summary>
|
||||
/// <param name="noperacion">Número de operación a buscar</param>
|
||||
/// <returns>Lista de operaciones encontradas</returns>
|
||||
Task<List<Operacion>> ObtenerOperacionesPorNumeroAsync(string noperacion);
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene operaciones en un rango de fechas
|
||||
/// </summary>
|
||||
/// <param name="fechaInicio">Fecha inicial</param>
|
||||
/// <param name="fechaFin">Fecha final</param>
|
||||
/// <returns>Lista de operaciones en el rango</returns>
|
||||
Task<List<Operacion>> ObtenerOperacionesPorFechasAsync(DateTime fechaInicio, DateTime fechaFin);
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene todos los medios de pago disponibles
|
||||
/// </summary>
|
||||
/// <returns>Lista de medios de pago</returns>
|
||||
Task<List<MedioDePago>> ObtenerMediosDePagoAsync();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
public interface IPasswordService
|
||||
{
|
||||
string HashPassword(string password);
|
||||
bool VerifyPassword(string password, string hash, string? salt, bool isLegacy);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
|
||||
namespace MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
public interface IPaymentService
|
||||
{
|
||||
Task<PaymentResponseDto> ProcessPaymentAsync(CreatePaymentRequestDto request, int userId);
|
||||
Task ProcessWebhookAsync(string topic, string id);
|
||||
Task<PaymentResponseDto> CheckPaymentStatusAsync(int adId);
|
||||
}
|
||||
15
Backend/MotoresArgentinosV2.Core/Interfaces/ITokenService.cs
Normal file
15
Backend/MotoresArgentinosV2.Core/Interfaces/ITokenService.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
|
||||
namespace MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
string GenerateJwtToken(User user);
|
||||
RefreshToken GenerateRefreshToken(string ipAddress);
|
||||
string GenerateMFACode(); // Legacy email code
|
||||
|
||||
// TOTP (Google Authenticator)
|
||||
string GenerateBase32Secret();
|
||||
string GetQrCodeUri(string userEmail, string secret);
|
||||
bool ValidateTOTP(string secret, string code);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
|
||||
namespace MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
public interface IUsuariosLegacyService
|
||||
{
|
||||
Task<UsuarioLegacyDto?> ObtenerParticularPorUsuarioAsync(string nombreUsuario);
|
||||
Task<AgenciaLegacyDto?> ObtenerAgenciaPorUsuarioAsync(string nombreUsuario);
|
||||
}
|
||||
11
Backend/MotoresArgentinosV2.Core/Models/MailSettings.cs
Normal file
11
Backend/MotoresArgentinosV2.Core/Models/MailSettings.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace MotoresArgentinosV2.Core.Models;
|
||||
|
||||
public class MailSettings
|
||||
{
|
||||
public string SmtpHost { get; set; } = string.Empty;
|
||||
public int SmtpPort { get; set; }
|
||||
public string SmtpUser { get; set; } = string.Empty;
|
||||
public string SmtpPass { get; set; } = string.Empty;
|
||||
public string SenderEmail { get; set; } = string.Empty;
|
||||
public string SenderName { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Contexto de Entity Framework para la base de datos Autos (legacy)
|
||||
/// Servidor: TECNICA3
|
||||
/// Base de Datos: autos
|
||||
/// Propósito: Acceso a operaciones de pago y medios de pago
|
||||
/// </summary>
|
||||
public class AutosDbContext : DbContext
|
||||
{
|
||||
public AutosDbContext(DbContextOptions<AutosDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tabla de operaciones de pago
|
||||
/// </summary>
|
||||
public DbSet<Operacion> Operaciones { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tabla de medios de pago disponibles
|
||||
/// </summary>
|
||||
public DbSet<MedioDePago> MediosDePago { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Configuración para la tabla operaciones
|
||||
modelBuilder.Entity<Operacion>(entity =>
|
||||
{
|
||||
entity.ToTable("operaciones");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.Fecha).HasColumnName("fecha");
|
||||
entity.Property(e => e.Motivo).HasColumnName("motivo").HasMaxLength(50);
|
||||
entity.Property(e => e.Moneda).HasColumnName("moneda").HasMaxLength(50);
|
||||
entity.Property(e => e.Direccionentrega).HasColumnName("direccionentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Validaciondomicilio).HasColumnName("validaciondomicilio").HasMaxLength(50);
|
||||
entity.Property(e => e.Codigopedido).HasColumnName("codigopedido").HasMaxLength(50);
|
||||
entity.Property(e => e.Nombreentrega).HasColumnName("nombreentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Fechahora).HasColumnName("fechahora").HasMaxLength(50);
|
||||
entity.Property(e => e.Telefonocomprador).HasColumnName("telefonocomprador").HasMaxLength(50);
|
||||
entity.Property(e => e.Barrioentrega).HasColumnName("barrioentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Codautorizacion).HasColumnName("codautorizacion").HasMaxLength(50);
|
||||
entity.Property(e => e.Paisentrega).HasColumnName("paisentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Cuotas).HasColumnName("cuotas").HasMaxLength(50);
|
||||
entity.Property(e => e.Validafechanac).HasColumnName("validafechanac").HasMaxLength(50);
|
||||
entity.Property(e => e.Validanrodoc).HasColumnName("validanrodoc").HasMaxLength(50);
|
||||
entity.Property(e => e.Titular).HasColumnName("titular").HasMaxLength(50);
|
||||
entity.Property(e => e.Pedido).HasColumnName("pedido").HasMaxLength(50);
|
||||
entity.Property(e => e.Zipentrega).HasColumnName("zipentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Monto).HasColumnName("monto").HasMaxLength(50);
|
||||
entity.Property(e => e.Tarjeta).HasColumnName("tarjeta").HasMaxLength(50);
|
||||
entity.Property(e => e.Fechaentrega).HasColumnName("fechaentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Emailcomprador).HasColumnName("emailcomprador").HasMaxLength(50);
|
||||
entity.Property(e => e.Validanropuerta).HasColumnName("validanropuerta").HasMaxLength(50);
|
||||
entity.Property(e => e.Ciudadentrega).HasColumnName("ciudadentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Validatipodoc).HasColumnName("validatipodoc").HasMaxLength(50);
|
||||
entity.Property(e => e.Noperacion).HasColumnName("noperacion").HasMaxLength(50);
|
||||
entity.Property(e => e.Estadoentrega).HasColumnName("estadoentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Resultado).HasColumnName("resultado").HasMaxLength(50);
|
||||
entity.Property(e => e.Mensajeentrega).HasColumnName("mensajeentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Precioneto).HasColumnName("precioneto");
|
||||
});
|
||||
|
||||
// Configuración para la tabla mediodepago
|
||||
modelBuilder.Entity<MedioDePago>(entity =>
|
||||
{
|
||||
entity.ToTable("mediodepago");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.Mediodepago).HasColumnName("mediodepago").HasMaxLength(20);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Backend/MotoresArgentinosV2.Infrastructure/Data/InternetDbContext.cs
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Contexto de Entity Framework para la base de datos Internet (legacy)
|
||||
/// Servidor: ...
|
||||
/// Base de Datos: internet
|
||||
/// Propósito: Acceso a datos de avisos web
|
||||
/// </summary>
|
||||
public class InternetDbContext : DbContext
|
||||
{
|
||||
public InternetDbContext(DbContextOptions<InternetDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Registrar el DTO como entidad sin llave (Keyless) para que SqlQueryRaw funcione bien
|
||||
modelBuilder.Entity<DatosAvisoDto>(e =>
|
||||
{
|
||||
e.HasNoKey();
|
||||
e.ToView(null); // No mapea a tabla
|
||||
|
||||
// Configurar precisión de decimales para silenciar warnings
|
||||
e.Property(p => p.ImporteSiniva).HasColumnType("decimal(18,2)");
|
||||
e.Property(p => p.ImporteTotsiniva).HasColumnType("decimal(18,2)");
|
||||
e.Property(p => p.PorcentajeCombinado).HasColumnType("decimal(18,2)");
|
||||
e.Property(p => p.Centimetros).HasColumnType("decimal(18,2)");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Data;
|
||||
|
||||
public class MotoresV2DbContext : DbContext
|
||||
{
|
||||
public MotoresV2DbContext(DbContextOptions<MotoresV2DbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<User> Users { get; set; }
|
||||
public DbSet<Ad> Ads { get; set; }
|
||||
public DbSet<AdPhoto> AdPhotos { get; set; }
|
||||
public DbSet<AdFeature> AdFeatures { get; set; }
|
||||
public DbSet<Brand> Brands { get; set; }
|
||||
public DbSet<Model> Models { get; set; }
|
||||
public DbSet<TransactionRecord> Transactions { get; set; }
|
||||
public DbSet<Favorite> Favorites { get; set; }
|
||||
public DbSet<ChatMessage> ChatMessages { get; set; }
|
||||
public DbSet<AuditLog> AuditLogs { get; set; }
|
||||
public DbSet<PaymentMethod> PaymentMethods { get; set; }
|
||||
public DbSet<RefreshToken> RefreshTokens { get; set; }
|
||||
public DbSet<AdViewLog> AdViewLogs { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<PaymentMethod>().HasKey(p => p.PaymentMethodID);
|
||||
modelBuilder.Entity<ChatMessage>().HasKey(m => m.MessageID);
|
||||
modelBuilder.Entity<ChatMessage>().ToTable("ChatMessages");
|
||||
|
||||
// Configuración de Cascada para Mensajes
|
||||
modelBuilder.Entity<ChatMessage>()
|
||||
.HasOne(m => m.Ad)
|
||||
.WithMany(a => a.Messages)
|
||||
.HasForeignKey(m => m.AdID)
|
||||
.OnDelete(DeleteBehavior.Cascade); // Esto asegura que EF intente borrar los mensajes
|
||||
|
||||
// Configuración de Favorites (Clave compuesta)
|
||||
modelBuilder.Entity<Favorite>()
|
||||
.HasKey(f => new { f.UserID, f.AdID });
|
||||
|
||||
// Nombres de tablas exactos
|
||||
modelBuilder.Entity<Favorite>().ToTable("Favorites");
|
||||
|
||||
// Configuración de AdFeatures (Clave compuesta)
|
||||
modelBuilder.Entity<AdFeature>()
|
||||
.HasKey(af => new { af.AdID, af.FeatureKey });
|
||||
|
||||
// Configuración de Identificadores (Claves Primarias)
|
||||
modelBuilder.Entity<AdPhoto>().HasKey(p => p.PhotoID);
|
||||
modelBuilder.Entity<Brand>().HasKey(b => b.BrandID);
|
||||
modelBuilder.Entity<Model>().HasKey(m => m.ModelID);
|
||||
modelBuilder.Entity<User>().HasKey(u => u.UserID);
|
||||
modelBuilder.Entity<Ad>().HasKey(a => a.AdID);
|
||||
modelBuilder.Entity<TransactionRecord>().HasKey(t => t.TransactionID);
|
||||
modelBuilder.Entity<Ad>().Property(a => a.Price).HasColumnType("decimal(18,2)");
|
||||
modelBuilder.Entity<TransactionRecord>().Property(t => t.Amount).HasColumnType("decimal(18,2)");
|
||||
|
||||
// Nombres de tablas exactos para coincidir con el Roadmap
|
||||
modelBuilder.Entity<User>().ToTable("Users");
|
||||
modelBuilder.Entity<Ad>().ToTable("Ads");
|
||||
modelBuilder.Entity<AdPhoto>().ToTable("AdPhotos");
|
||||
modelBuilder.Entity<AdFeature>().ToTable("AdFeatures");
|
||||
modelBuilder.Entity<TransactionRecord>().ToTable("Transactions");
|
||||
|
||||
// Configuración de AdViewLog
|
||||
modelBuilder.Entity<AdViewLog>().ToTable("AdViewLogs");
|
||||
modelBuilder.Entity<AdViewLog>().HasIndex(l => new { l.AdID, l.IPAddress, l.ViewDate });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MotoresArgentinosV2.Core\MotoresArgentinosV2.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||
<PackageReference Include="MailKit" Version="4.14.1" />
|
||||
<PackageReference Include="mercadopago-sdk" Version="2.11.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MimeKit" Version="4.14.0" />
|
||||
<PackageReference Include="Otp.NET" Version="1.3.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,448 @@
|
||||
// backend/MotoresArgentinosV2.Infrastructure/Services/AdExpirationService.cs
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MotoresArgentinosV2.Infrastructure.Data;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Services;
|
||||
|
||||
public class AdExpirationService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<AdExpirationService> _logger;
|
||||
|
||||
public AdExpirationService(IServiceProvider serviceProvider, ILogger<AdExpirationService> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Servicio de Mantenimiento Integral iniciado.");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. Vencimientos
|
||||
await CheckExpiredAdsAsync();
|
||||
await ProcessExpirationWarningsAsync();
|
||||
|
||||
// 2. Limpiezas
|
||||
await CleanupUnpaidDraftsAsync();
|
||||
await PermanentDeleteOldDeletedAdsAsync();
|
||||
await CleanupOldRefreshTokensAsync();
|
||||
await CleanupAdViewLogsAsync();
|
||||
|
||||
// 3. Marketing y Retención
|
||||
await ProcessWeeklyStatsAsync();
|
||||
await ProcessPaymentRemindersAsync();
|
||||
await ProcessUnreadMessagesRemindersAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error CRÍTICO en ciclo de mantenimiento.");
|
||||
}
|
||||
|
||||
// Ejecutar cada 1 hora
|
||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanupAdViewLogsAsync()
|
||||
{
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdExpirationService>>();
|
||||
|
||||
// Borrar logs de visitas de más de 30 días de antigüedad
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-30);
|
||||
|
||||
var deletedCount = await context.AdViewLogs
|
||||
.Where(l => l.ViewDate < cutoffDate)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
if (deletedCount > 0)
|
||||
{
|
||||
logger.LogInformation("Mantenimiento: Se eliminaron {Count} registros de visitas antiguos.", deletedCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckExpiredAdsAsync()
|
||||
{
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-30);
|
||||
|
||||
var expiredAds = await context.Ads
|
||||
.Include(a => a.User)
|
||||
.Include(a => a.Brand)
|
||||
.Where(a =>
|
||||
// Regla 1: Aviso activo
|
||||
a.StatusID == (int)AdStatusEnum.Active &&
|
||||
// Regla 2: Publicado hace más de 30 días
|
||||
a.PublishedAt.HasValue && a.PublishedAt.Value < cutoffDate &&
|
||||
// --- CAMBIO AQUÍ: Excluimos avisos de administradores ---
|
||||
a.User != null && a.User.UserType != 3
|
||||
)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var ad in expiredAds)
|
||||
{
|
||||
ad.StatusID = (int)AdStatusEnum.Expired;
|
||||
|
||||
if (ad.User != null && !string.IsNullOrEmpty(ad.User.Email))
|
||||
{
|
||||
var title = $"{ad.Brand?.Name} {ad.VersionName}";
|
||||
await notifService.SendAdExpiredEmailAsync(ad.User.Email, ad.User.FirstName ?? "Usuario", title);
|
||||
}
|
||||
|
||||
context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "SYSTEM_AD_EXPIRED",
|
||||
Entity = "Ad",
|
||||
EntityID = ad.AdID,
|
||||
UserID = 0,
|
||||
Details = $"Aviso ID {ad.AdID} vencido. Email enviado a usuario no-admin."
|
||||
});
|
||||
}
|
||||
if (expiredAds.Any()) await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessExpirationWarningsAsync()
|
||||
{
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||
|
||||
var warningThreshold = DateTime.UtcNow.AddDays(-25);
|
||||
|
||||
var adsToWarn = await context.Ads
|
||||
.Include(a => a.User)
|
||||
.Include(a => a.Brand)
|
||||
.Where(a =>
|
||||
a.StatusID == (int)AdStatusEnum.Active &&
|
||||
a.PublishedAt.HasValue && a.PublishedAt.Value <= warningThreshold &&
|
||||
!a.ExpirationWarningSent &&
|
||||
// --- CAMBIO AQUÍ: Excluimos avisos de administradores ---
|
||||
a.User != null && a.User.UserType != 3
|
||||
)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var ad in adsToWarn)
|
||||
{
|
||||
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
|
||||
|
||||
var title = $"{ad.Brand?.Name} {ad.VersionName}";
|
||||
var expDate = ad.PublishedAt!.Value.AddDays(30);
|
||||
|
||||
try
|
||||
{
|
||||
await notifService.SendExpirationWarningEmailAsync(ad.User.Email, ad.User.FirstName ?? "Usuario", title, expDate);
|
||||
ad.ExpirationWarningSent = true;
|
||||
}
|
||||
catch { /* Log error pero continuar */ }
|
||||
}
|
||||
|
||||
if (adsToWarn.Any()) await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessWeeklyStatsAsync()
|
||||
{
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||
|
||||
var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
|
||||
|
||||
var adsForStats = await context.Ads
|
||||
.Include(a => a.User)
|
||||
.Include(a => a.Brand)
|
||||
.Where(a =>
|
||||
a.StatusID == (int)AdStatusEnum.Active &&
|
||||
a.User != null && a.User.UserType != 3 && // Ya estaba excluido aquí
|
||||
a.PublishedAt.HasValue && a.PublishedAt.Value <= sevenDaysAgo &&
|
||||
(a.LastPerformanceEmailSentAt == null || a.LastPerformanceEmailSentAt <= sevenDaysAgo)
|
||||
)
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var ad in adsForStats)
|
||||
{
|
||||
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
|
||||
|
||||
var favCount = await context.Favorites.CountAsync(f => f.AdID == ad.AdID);
|
||||
var title = $"{ad.Brand?.Name} {ad.VersionName}";
|
||||
|
||||
await notifService.SendWeeklyPerformanceEmailAsync(
|
||||
ad.User.Email,
|
||||
ad.User.FirstName ?? "Usuario",
|
||||
title,
|
||||
ad.ViewsCounter,
|
||||
favCount
|
||||
);
|
||||
|
||||
ad.LastPerformanceEmailSentAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
if (adsForStats.Any()) await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessPaymentRemindersAsync()
|
||||
{
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||
|
||||
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||
var frontendUrl = config["AppSettings:FrontendUrl"] ?? "http://localhost:5173";
|
||||
|
||||
var cutoff = DateTime.UtcNow.AddHours(-24);
|
||||
|
||||
var abandonedCarts = await context.Ads
|
||||
.Include(a => a.User)
|
||||
.Include(a => a.Brand)
|
||||
.Where(a =>
|
||||
(a.StatusID == 1 || a.StatusID == 2) &&
|
||||
a.CreatedAt < cutoff &&
|
||||
a.PaymentReminderSentAt == null &&
|
||||
a.User != null && a.User.UserType != 3
|
||||
)
|
||||
.Take(20)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var ad in abandonedCarts)
|
||||
{
|
||||
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
|
||||
|
||||
var title = $"{ad.Brand?.Name} {ad.VersionName}";
|
||||
var link = $"{frontendUrl}/publicar?edit={ad.AdID}";
|
||||
|
||||
await notifService.SendPaymentReminderEmailAsync(ad.User.Email, ad.User.FirstName ?? "Usuario", title, link);
|
||||
|
||||
ad.PaymentReminderSentAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
if (abandonedCarts.Any()) await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessUnreadMessagesRemindersAsync()
|
||||
{
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||
|
||||
// Buscar usuarios que tengan mensajes no leídos viejos (> 4 horas)
|
||||
// y que no hayan sido notificados en las últimas 24 horas.
|
||||
|
||||
var messageThreshold = DateTime.UtcNow.AddHours(-4);
|
||||
var notificationThreshold = DateTime.UtcNow.AddHours(-24);
|
||||
|
||||
// 1. Obtener IDs de usuarios con mensajes sin leer viejos
|
||||
var usersWithUnread = await context.ChatMessages
|
||||
.Where(m => !m.IsRead && m.SentAt < messageThreshold)
|
||||
.Select(m => m.ReceiverID)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var userId in usersWithUnread)
|
||||
{
|
||||
var user = await context.Users.FindAsync(userId);
|
||||
|
||||
// Verificar si ya le avisamos hoy
|
||||
if (user == null || string.IsNullOrEmpty(user.Email) ||
|
||||
(user.LastUnreadMessageReminderSentAt != null && user.LastUnreadMessageReminderSentAt > notificationThreshold))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Contar total no leídos
|
||||
var totalUnread = await context.ChatMessages.CountAsync(m => m.ReceiverID == userId && !m.IsRead);
|
||||
|
||||
await notifService.SendUnreadMessagesReminderEmailAsync(user.Email, user.FirstName ?? "Usuario", totalUnread);
|
||||
|
||||
user.LastUnreadMessageReminderSentAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
if (usersWithUnread.Any()) await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanupUnpaidDraftsAsync()
|
||||
{
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||
var imageService = scope.ServiceProvider.GetRequiredService<IImageStorageService>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdExpirationService>>();
|
||||
|
||||
// Regla: Eliminar avisos IMPAGOS (Borrador = 1) con más de 30 días de antigüedad (CreatedAt).
|
||||
// No se tocan los que están en Moderación (3) ni los Rechazados (5) a menos que se especifique.
|
||||
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-30);
|
||||
|
||||
var oldDrafts = await context.Ads
|
||||
.Include(a => a.Photos)
|
||||
.Where(a => a.StatusID == 1 && a.CreatedAt < cutoffDate) // 1 = Borrador/Impago
|
||||
.ToListAsync();
|
||||
|
||||
if (oldDrafts.Any())
|
||||
{
|
||||
logger.LogInformation("Eliminando {Count} avisos impagos (borradores) de más de 30 días de antigüedad...", oldDrafts.Count);
|
||||
|
||||
foreach (var draft in oldDrafts)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. Borrar fotos del disco físico
|
||||
if (draft.Photos != null)
|
||||
{
|
||||
foreach (var photo in draft.Photos)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(photo.FilePath))
|
||||
imageService.DeleteAdImage(photo.FilePath);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Eliminar registro de la DB
|
||||
context.Ads.Remove(draft);
|
||||
|
||||
// 📝 AUDITORÍA
|
||||
context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "SYSTEM_DRAFT_CLEANED",
|
||||
Entity = "Ad",
|
||||
EntityID = draft.AdID,
|
||||
UserID = 0, // Sistema
|
||||
Details = $"Borrador impago ID {draft.AdID} eliminado por antigüedad."
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error eliminando borrador ID {AdId}", draft.AdID);
|
||||
}
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
logger.LogInformation("Limpieza de borradores antiguos completada.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PermanentDeleteOldDeletedAdsAsync()
|
||||
{
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||
var imageService = scope.ServiceProvider.GetRequiredService<IImageStorageService>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdExpirationService>>();
|
||||
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-60);
|
||||
|
||||
var adsToRemove = await context.Ads
|
||||
.Include(a => a.Photos)
|
||||
.Include(a => a.Features)
|
||||
.Include(a => a.Messages)
|
||||
.Where(a => a.StatusID == (int)AdStatusEnum.Deleted
|
||||
&& a.DeletedAt.HasValue
|
||||
&& a.DeletedAt.Value < cutoffDate)
|
||||
.ToListAsync();
|
||||
|
||||
if (adsToRemove.Any())
|
||||
{
|
||||
logger.LogInformation("Eliminando permanentemente {Count} avisos...", adsToRemove.Count);
|
||||
|
||||
foreach (var ad in adsToRemove)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. Borrar fotos del disco físico
|
||||
if (ad.Photos != null)
|
||||
{
|
||||
foreach (var photo in ad.Photos)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(photo.FilePath))
|
||||
imageService.DeleteAdImage(photo.FilePath);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Info para el log antes de borrar
|
||||
int msgCount = ad.Messages?.Count ?? 0;
|
||||
|
||||
// 3. Eliminar registro de la DB
|
||||
// Al tener Cascade configurado en EF y SQL, esto borrará:
|
||||
// - El Aviso (Ad)
|
||||
// - Sus Fotos (AdPhotos)
|
||||
// - Sus Características (AdFeatures)
|
||||
// - Sus Mensajes (ChatMessages)
|
||||
context.Ads.Remove(ad);
|
||||
|
||||
// 📝 AUDITORÍA
|
||||
context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "SYSTEM_HARD_DELETE",
|
||||
Entity = "Ad",
|
||||
EntityID = ad.AdID,
|
||||
UserID = 0, // Sistema
|
||||
Details = $"Aviso ID {ad.AdID} eliminado permanentemente. Se eliminaron {msgCount} mensajes de chat asociados."
|
||||
});
|
||||
|
||||
logger.LogInformation("Hard Delete AdID {AdId} completado. Mensajes eliminados: {MsgCount}", ad.AdID, msgCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error en Hard Delete del aviso ID {AdId}", ad.AdID);
|
||||
}
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
private async Task CleanupOldRefreshTokensAsync()
|
||||
{
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdExpirationService>>();
|
||||
|
||||
// Política: Eliminar tokens que:
|
||||
// 1. Ya expiraron hace más de 30 días OR
|
||||
// 2. Fueron revocados hace más de 30 días
|
||||
// (Mantenemos un historial de 30 días por seguridad/auditoría, luego se borra)
|
||||
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-30);
|
||||
|
||||
// Usamos ExecuteDeleteAsync para borrado masivo eficiente (EF Core 7+)
|
||||
var deletedCount = await context.RefreshTokens
|
||||
.Where(t => t.Expires < cutoffDate || (t.Revoked != null && t.Revoked < cutoffDate))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
if (deletedCount > 0)
|
||||
{
|
||||
logger.LogInformation("Limpieza de Tokens: Se eliminaron {Count} refresh tokens obsoletos.", deletedCount);
|
||||
|
||||
// No hace falta guardar AuditLog para esto, es mantenimiento técnico puro,
|
||||
// pero se podría agregar.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using MotoresArgentinosV2.Infrastructure.Data;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Services;
|
||||
|
||||
public class AdSyncService : IAdSyncService
|
||||
{
|
||||
private readonly MotoresV2DbContext _context;
|
||||
private readonly IAvisosLegacyService _legacyService;
|
||||
private readonly ILogger<AdSyncService> _logger;
|
||||
|
||||
public AdSyncService(MotoresV2DbContext context, IAvisosLegacyService legacyService, ILogger<AdSyncService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_legacyService = legacyService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> SyncAdToLegacyAsync(int adId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ad = await _context.Ads
|
||||
.Include(a => a.User)
|
||||
.Include(a => a.Photos)
|
||||
.Include(a => a.Features)
|
||||
.FirstOrDefaultAsync(a => a.AdID == adId);
|
||||
|
||||
if (ad == null) return false;
|
||||
|
||||
// Mapeo básico a InsertarAvisoDto
|
||||
var dto = new InsertarAvisoDto
|
||||
{
|
||||
Tipo = "V", // Vehículo
|
||||
NroOperacion = ad.AdID,
|
||||
IdCliente = ad.UserID,
|
||||
NroDoc = ad.User?.Email ?? string.Empty,
|
||||
Razon = ad.User != null ? $"{ad.User.FirstName} {ad.User.LastName}" : "Usuario Desconocido",
|
||||
Email = ad.User?.Email ?? string.Empty,
|
||||
Telefono = ad.ContactPhone ?? string.Empty,
|
||||
Nombreaviso = ad.VersionName ?? "Vehículo sin nombre",
|
||||
IdRubro = 1, // Autos por defecto
|
||||
IdSubrubro = 1,
|
||||
FechaInicio = DateTime.Now,
|
||||
CantDias = 30,
|
||||
ImporteAviso = ad.Price,
|
||||
Tarifa = ad.Price,
|
||||
Destacado = ad.IsFeatured
|
||||
};
|
||||
|
||||
// Ejecutar inserción en legacy
|
||||
var result = await _legacyService.InsertarAvisoAsync(dto);
|
||||
|
||||
if (result)
|
||||
{
|
||||
_logger.LogInformation("Sincronización exitosa del aviso {AdId} al sistema legacy.", adId);
|
||||
|
||||
// Marcamos el aviso en V2 con la referencia legacy
|
||||
ad.LegacyAdID = ad.AdID; // O el ID real que devuelva el SP si fuera el caso
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error sincronizando aviso {AdId} a legacy", adId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using MotoresArgentinosV2.Infrastructure.Data;
|
||||
using System.Data;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Services;
|
||||
|
||||
public class AvisosLegacyService : IAvisosLegacyService
|
||||
{
|
||||
private readonly InternetDbContext _context;
|
||||
private readonly ILogger<AvisosLegacyService> _logger;
|
||||
|
||||
public AvisosLegacyService(InternetDbContext context, ILogger<AvisosLegacyService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<DatosAvisoDto>> ObtenerDatosAvisosAsync(string tarea, int paquete = 0)
|
||||
{
|
||||
var resultados = new List<DatosAvisoDto>();
|
||||
|
||||
try
|
||||
{
|
||||
// Usamos ADO.NET manual para tener control total sobre columnas faltantes
|
||||
using (var command = _context.Database.GetDbConnection().CreateCommand())
|
||||
{
|
||||
command.CommandText = "dbo.spDatosAvisos";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
var p1 = command.CreateParameter();
|
||||
p1.ParameterName = "@tarea";
|
||||
|
||||
string tareaSafe = tarea ?? string.Empty;
|
||||
|
||||
if (tareaSafe.Length > 20)
|
||||
{
|
||||
tareaSafe = tareaSafe.Substring(0, 20);
|
||||
}
|
||||
|
||||
p1.Value = tareaSafe;
|
||||
command.Parameters.Add(p1);
|
||||
|
||||
// Segundo parámetro
|
||||
var p2 = command.CreateParameter();
|
||||
p2.ParameterName = "@paquete";
|
||||
p2.Value = paquete;
|
||||
command.Parameters.Add(p2);
|
||||
|
||||
await _context.Database.OpenConnectionAsync();
|
||||
|
||||
using (var reader = await command.ExecuteReaderAsync())
|
||||
{
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
var dto = new DatosAvisoDto();
|
||||
|
||||
// Usamos helpers seguros que devuelven 0 o "" si la columna no existe o es null
|
||||
|
||||
// IDs y Básicos
|
||||
dto.IdTipoavi = GetByte(reader, "ID_TIPOAVI");
|
||||
dto.IdRubro = GetShort(reader, "ID_RUBRO");
|
||||
dto.IdSubrubro = GetByte(reader, "ID_SUBRUBRO");
|
||||
|
||||
// Strings
|
||||
dto.Nomavi = GetString(reader, "NOMAVI");
|
||||
dto.Textoavi = GetString(reader, "TEXTOAVI");
|
||||
dto.Descripcion = GetString(reader, "DESCRIPCION");
|
||||
|
||||
// Integers (Configuración)
|
||||
dto.IdCombinado = GetInt(reader, "ID_COMBINADO");
|
||||
dto.PorcentajeCombinado = GetInt(reader, "PORCENTAJE_COMBINADO");
|
||||
dto.CantidadDias = GetInt(reader, "CANTIDAD_DIAS");
|
||||
dto.DiasCorridos = GetInt(reader, "DIAS_CORRIDOS");
|
||||
dto.Palabras = GetInt(reader, "PALABRAS");
|
||||
dto.Centimetros = GetInt(reader, "CENTIMETROS");
|
||||
dto.Columnas = GetInt(reader, "COLUMNAS");
|
||||
dto.TotalAvisos = GetInt(reader, "TOTAL_AVISOS");
|
||||
dto.Destacado = GetInt(reader, "DESTACADO");
|
||||
dto.Paquete = GetInt(reader, "PAQUETE");
|
||||
|
||||
// Decimales (Precios)
|
||||
dto.ImporteSiniva = GetDecimal(reader, "IMPORTE_SINIVA");
|
||||
|
||||
// AQUÍ ESTABA EL PROBLEMA:
|
||||
// Si EMOTOS no trae esta columna, el helper devolverá 0 y no fallará.
|
||||
dto.ImporteTotsiniva = GetDecimal(reader, "IMPORTE_TOTSINIVA");
|
||||
|
||||
// Lógica de Negocio Fallback:
|
||||
// Si el SP no calculó el total (columna faltante o 0), y tenemos el neto, lo calculamos nosotros.
|
||||
if (dto.ImporteTotsiniva == 0 && dto.ImporteSiniva > 0)
|
||||
{
|
||||
// Asumimos recargo del 30% si es destacado (según lógica del SP leída)
|
||||
// O simplemente tomamos el neto si es simple.
|
||||
// Esto es un parche seguro para visualización.
|
||||
dto.ImporteTotsiniva = dto.ImporteSiniva;
|
||||
}
|
||||
|
||||
resultados.Add(dto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resultados;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error crítico ejecutando spDatosAvisos Manual. Tarea: {Tarea}", tarea);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers de Lectura Tolerante a Fallos ---
|
||||
// Intentan leer la columna. Si no existe (IndexOutOfRange) o es DBNull, devuelven valor default.
|
||||
|
||||
private byte GetByte(System.Data.Common.DbDataReader reader, string col)
|
||||
{
|
||||
try { return Convert.ToByte(reader[col]); } catch { return 0; }
|
||||
}
|
||||
|
||||
private short GetShort(System.Data.Common.DbDataReader reader, string col)
|
||||
{
|
||||
try { return Convert.ToInt16(reader[col]); } catch { return 0; }
|
||||
}
|
||||
|
||||
private int GetInt(System.Data.Common.DbDataReader reader, string col)
|
||||
{
|
||||
try { return Convert.ToInt32(reader[col]); } catch { return 0; }
|
||||
}
|
||||
|
||||
private decimal GetDecimal(System.Data.Common.DbDataReader reader, string col)
|
||||
{
|
||||
try { return Convert.ToDecimal(reader[col]); } catch { return 0; }
|
||||
}
|
||||
|
||||
private string GetString(System.Data.Common.DbDataReader reader, string col)
|
||||
{
|
||||
try { return reader[col]?.ToString() ?? ""; } catch { return ""; }
|
||||
}
|
||||
|
||||
|
||||
// --- Resto de métodos (Mantener igual) ---
|
||||
|
||||
public async Task<bool> InsertarAvisoAsync(InsertarAvisoDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parameters = new[]
|
||||
{
|
||||
new SqlParameter("@tipo", dto.Tipo),
|
||||
new SqlParameter("@nro_operacion", dto.NroOperacion),
|
||||
new SqlParameter("@id_cliente", dto.IdCliente),
|
||||
new SqlParameter("@tipodoc", dto.Tipodoc),
|
||||
new SqlParameter("@nro_doc", dto.NroDoc),
|
||||
new SqlParameter("@razon", dto.Razon),
|
||||
new SqlParameter("@calle", dto.Calle),
|
||||
new SqlParameter("@numero", dto.Numero),
|
||||
new SqlParameter("@localidad", dto.Localidad),
|
||||
new SqlParameter("@codigopostal", dto.CodigoPostal),
|
||||
new SqlParameter("@telefono", dto.Telefono),
|
||||
new SqlParameter("@email", dto.Email),
|
||||
new SqlParameter("@id_tipoiva", dto.IdTipoiva),
|
||||
new SqlParameter("@porcentaje_iva1", dto.PorcentajeIva1),
|
||||
new SqlParameter("@porcentaje_iva2", dto.PorcentajeIva2),
|
||||
new SqlParameter("@porcentaje_percepcion", dto.PorcentajePercepcion),
|
||||
new SqlParameter("@id_tipoaviso", dto.IdTipoaviso),
|
||||
new SqlParameter("@nombreaviso", dto.Nombreaviso),
|
||||
new SqlParameter("@id_rubro", dto.IdRubro),
|
||||
new SqlParameter("@id_subrubro", dto.IdSubrubro),
|
||||
new SqlParameter("@id_combinado", dto.IdCombinado),
|
||||
new SqlParameter("@porcentaje_combinado", dto.PorcentajeCombinado),
|
||||
new SqlParameter("@fecha_inicio", dto.FechaInicio),
|
||||
new SqlParameter("@cant_dias", dto.CantDias),
|
||||
new SqlParameter("@dias_corridos", dto.DiasCorridos),
|
||||
new SqlParameter("@palabras", dto.Palabras),
|
||||
new SqlParameter("@centimetros", dto.Centimetros),
|
||||
new SqlParameter("@columnas", dto.Columnas),
|
||||
new SqlParameter("@id_tarjeta", dto.IdTarjeta),
|
||||
new SqlParameter("@nro_tarjeta", dto.NroTarjeta),
|
||||
new SqlParameter("@cvc_tarjeta", dto.CvcTarjeta),
|
||||
new SqlParameter("@vencimiento", dto.Vencimiento),
|
||||
new SqlParameter("@calle_envio", dto.CalleEnvio),
|
||||
new SqlParameter("@numero_envio", dto.NumeroEnvio),
|
||||
new SqlParameter("@localidad_envio", dto.LocalidadEnvio),
|
||||
new SqlParameter("@tarifa", dto.Tarifa),
|
||||
new SqlParameter("@importe_aviso", dto.ImporteAviso),
|
||||
new SqlParameter("@importe_iva1", dto.ImporteIva1),
|
||||
new SqlParameter("@importe_iva2", dto.ImporteIva2),
|
||||
new SqlParameter("@importe_percepcion", dto.ImportePercepcion),
|
||||
new SqlParameter("@cantavi", dto.Cantavi),
|
||||
new SqlParameter("@paquete", dto.Paquete),
|
||||
new SqlParameter("@destacado", dto.Destacado)
|
||||
};
|
||||
|
||||
await _context.Database.ExecuteSqlRawAsync(
|
||||
"EXEC dbo.spInsertaAvisos @tipo, @nro_operacion, @id_cliente, @tipodoc, @nro_doc, @razon, " +
|
||||
"@calle, @numero, @localidad, @codigopostal, @telefono, @email, @id_tipoiva, @porcentaje_iva1, " +
|
||||
"@porcentaje_iva2, @porcentaje_percepcion, @id_tipoaviso, @nombreaviso, @id_rubro, @id_subrubro, " +
|
||||
"@id_combinado, @porcentaje_combinado, @fecha_inicio, @cant_dias, @dias_corridos, @palabras, " +
|
||||
"@centimetros, @columnas, @id_tarjeta, @nro_tarjeta, @cvc_tarjeta, @vencimiento, @calle_envio, " +
|
||||
"@numero_envio, @localidad_envio, @tarifa, @importe_aviso, @importe_iva1, @importe_iva2, " +
|
||||
"@importe_percepcion, @cantavi, @paquete, @destacado",
|
||||
parameters);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al ejecutar spInsertaAvisos");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<DatosAvisoDto>> ObtenerTarifasAsync(string formulario, int paquete)
|
||||
{
|
||||
return await ObtenerDatosAvisosAsync(formulario, paquete);
|
||||
}
|
||||
|
||||
public async Task<List<AvisoWebDto>> ObtenerAvisosPorClienteAsync(string nroDoc)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sql = @"
|
||||
SELECT
|
||||
nombreaviso as NombreAviso,
|
||||
fecha_inicio as FechaInicio,
|
||||
importe_aviso as ImporteAviso,
|
||||
estado as Estado,
|
||||
nro_operacion as NroOperacion
|
||||
FROM dbo.AVISOSWEB
|
||||
WHERE nro_doc = @nroDoc
|
||||
ORDER BY fecha_inicio DESC";
|
||||
|
||||
var paramDoc = new SqlParameter("@nroDoc", nroDoc);
|
||||
|
||||
var resultado = await _context.Database
|
||||
.SqlQueryRaw<AvisoWebDto>(sql, paramDoc)
|
||||
.ToListAsync();
|
||||
|
||||
return resultado;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener avisos para el documento: {NroDocumento}", nroDoc);
|
||||
return new List<AvisoWebDto>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
using MotoresArgentinosV2.Infrastructure.Data;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Services;
|
||||
|
||||
public class IdentityService : IIdentityService
|
||||
{
|
||||
private readonly MotoresV2DbContext _v2Context;
|
||||
private readonly IPasswordService _passwordService;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public IdentityService(
|
||||
MotoresV2DbContext v2Context,
|
||||
IPasswordService passwordService,
|
||||
IEmailService emailService,
|
||||
IConfiguration config)
|
||||
{
|
||||
_v2Context = v2Context;
|
||||
_passwordService = passwordService;
|
||||
_emailService = emailService;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Message)> RegisterUserAsync(RegisterRequest request)
|
||||
{
|
||||
// 1. Normalización
|
||||
request.Username = request.Username.ToLowerInvariant().Trim();
|
||||
request.Email = request.Email.ToLowerInvariant().Trim();
|
||||
|
||||
if (!Regex.IsMatch(request.Username, "^[a-z0-9]{4,20}$"))
|
||||
return (false, "El usuario debe tener entre 4 y 20 caracteres, solo letras y números.");
|
||||
|
||||
// 2. Verificar Existencia
|
||||
var existingUser = await _v2Context.Users.FirstOrDefaultAsync(u => u.Email == request.Email);
|
||||
|
||||
// CASO ESPECIAL: Usuario Fantasma (MigrationStatus = 1 pero sin password válido y no verificado)
|
||||
// Si el email existe, le decimos al usuario que use "Recuperar Contraseña".
|
||||
|
||||
if (existingUser != null)
|
||||
{
|
||||
// Si es un usuario fantasma (sin password útil o marcado como tal),
|
||||
// lo ideal es que el usuario haga el flujo de "Olvidé mi contraseña" para setearla y verificar el mail.
|
||||
return (false, "Este correo ya está registrado. Si te pertenece, usa 'Olvidé mi contraseña' para activar tu cuenta.");
|
||||
}
|
||||
|
||||
var userExists = await _v2Context.Users.AnyAsync(u => u.UserName == request.Username);
|
||||
if (userExists) return (false, "Este nombre de usuario ya está en uso.");
|
||||
|
||||
// 3. Crear Token
|
||||
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
// 4. Crear Usuario
|
||||
var newUser = new User
|
||||
{
|
||||
UserName = request.Username,
|
||||
Email = request.Email,
|
||||
FirstName = request.FirstName,
|
||||
LastName = request.LastName,
|
||||
PhoneNumber = request.PhoneNumber,
|
||||
PasswordHash = _passwordService.HashPassword(request.Password),
|
||||
MigrationStatus = 1,
|
||||
UserType = 1,
|
||||
IsEmailVerified = false,
|
||||
VerificationToken = token,
|
||||
VerificationTokenExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_v2Context.Users.Add(newUser);
|
||||
await _v2Context.SaveChangesAsync();
|
||||
|
||||
// 4. Enviar Email REAL
|
||||
var frontendUrl = _config["AppSettings:FrontendUrl"] ?? "http://localhost:5173";
|
||||
var verifyLink = $"{frontendUrl}/verificar-email?token={token}";
|
||||
|
||||
var emailBody = $@"
|
||||
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;'>
|
||||
<h2 style='color: #0051ff; text-align: center;'>Bienvenido a Motores Argentinos</h2>
|
||||
<p>Hola <strong>{request.FirstName}</strong>,</p>
|
||||
<p>Gracias por registrarte. Para activar tu cuenta y comenzar a publicar, por favor confirma tu correo electrónico haciendo clic en el siguiente botón:</p>
|
||||
<div style='text-align: center; margin: 30px 0;'>
|
||||
<a href='{verifyLink}' style='background-color: #0051ff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;'>VERIFICAR MI CUENTA</a>
|
||||
</div>
|
||||
<p style='font-size: 12px; color: #666;'>Si no puedes hacer clic en el botón, copia y pega este enlace en tu navegador:</p>
|
||||
<p style='font-size: 12px; color: #0051ff; word-break: break-all;'>{verifyLink}</p>
|
||||
<hr style='border: 0; border-top: 1px solid #eee; margin: 20px 0;' />
|
||||
<p style='font-size: 10px; color: #999; text-align: center;'>© 2026 Motores Argentinos. Este es un mensaje automático, por favor no respondas.</p>
|
||||
</div>";
|
||||
|
||||
try
|
||||
{
|
||||
await _emailService.SendEmailAsync(request.Email, "Activa tu cuenta - Motores Argentinos", emailBody);
|
||||
return (true, "Usuario registrado. Hemos enviado un correo de verificación a tu casilla.");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Logueamos error pero retornamos true para no bloquear UX, el usuario pedirá reenvío luego.
|
||||
return (true, "Usuario creado. Hubo un problema enviando el correo, intente ingresar para reenviarlo.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Message)> VerifyEmailAsync(string token)
|
||||
{
|
||||
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.VerificationToken == token);
|
||||
if (user == null) return (false, "Token inválido.");
|
||||
if (user.VerificationTokenExpiresAt < DateTime.UtcNow) return (false, "El enlace ha expirado.");
|
||||
|
||||
user.IsEmailVerified = true;
|
||||
user.VerificationToken = null;
|
||||
user.VerificationTokenExpiresAt = null;
|
||||
|
||||
await _v2Context.SaveChangesAsync();
|
||||
return (true, "Email verificado correctamente.");
|
||||
}
|
||||
|
||||
public async Task<(User? User, string? MigrationMessage)> AuthenticateAsync(string username, string password)
|
||||
{
|
||||
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.UserName == username);
|
||||
if (user == null) return (null, null);
|
||||
|
||||
// Validar Bloqueo
|
||||
if (user.IsBlocked) return (null, "USER_BLOCKED");
|
||||
|
||||
// Validar Verificación de Email (Solo para usuarios modernos o ya migrados)
|
||||
if (!user.IsEmailVerified && user.MigrationStatus == 1) return (null, "EMAIL_NOT_VERIFIED");
|
||||
|
||||
bool isLegacy = user.MigrationStatus == 0;
|
||||
bool isValid = _passwordService.VerifyPassword(password, user.PasswordHash, user.PasswordSalt, isLegacy);
|
||||
|
||||
if (!isValid) return (null, null);
|
||||
if (isLegacy) return (user, "FORCE_PASSWORD_CHANGE");
|
||||
|
||||
return (user, null);
|
||||
}
|
||||
|
||||
public async Task<bool> MigratePasswordAsync(string username, string newPassword)
|
||||
{
|
||||
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.UserName == username);
|
||||
if (user == null) return false;
|
||||
|
||||
user.PasswordHash = _passwordService.HashPassword(newPassword);
|
||||
user.PasswordSalt = null;
|
||||
user.MigrationStatus = 1;
|
||||
user.IsEmailVerified = true; // Asumimos verificado al migrar
|
||||
|
||||
await _v2Context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Message)> ResendVerificationEmailAsync(string emailOrUsername)
|
||||
{
|
||||
// Buscar por Email O Username para mayor flexibilidad
|
||||
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.Email == emailOrUsername || u.UserName == emailOrUsername);
|
||||
|
||||
if (user == null) return (false, "No se encontró una cuenta con ese dato.");
|
||||
|
||||
if (user.IsEmailVerified) return (false, "Esta cuenta ya está verificada. Puede iniciar sesión.");
|
||||
|
||||
// --- RATE LIMITING ---
|
||||
var cooldown = TimeSpan.FromMinutes(5);
|
||||
if (user.LastVerificationEmailSentAt.HasValue)
|
||||
{
|
||||
var timeSinceLastSend = DateTime.UtcNow - user.LastVerificationEmailSentAt.Value;
|
||||
if (timeSinceLastSend < cooldown)
|
||||
{
|
||||
var remaining = Math.Ceiling((cooldown - timeSinceLastSend).TotalMinutes);
|
||||
return (false, $"Por seguridad, debe esperar {remaining} minutos antes de solicitar un nuevo correo.");
|
||||
}
|
||||
}
|
||||
|
||||
// Nuevo Token
|
||||
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||
user.VerificationToken = token;
|
||||
user.VerificationTokenExpiresAt = DateTime.UtcNow.AddHours(24);
|
||||
user.LastVerificationEmailSentAt = DateTime.UtcNow;
|
||||
|
||||
await _v2Context.SaveChangesAsync();
|
||||
|
||||
// Email
|
||||
var frontendUrl = _config["AppSettings:FrontendUrl"] ?? "http://localhost:5173";
|
||||
var verifyLink = $"{frontendUrl}/verificar-email?token={token}";
|
||||
|
||||
var emailBody = $@"
|
||||
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;'>
|
||||
<h2 style='color: #0051ff; text-align: center;'>Verifica tu cuenta</h2>
|
||||
<p>Hola <strong>{user.FirstName}</strong>,</p>
|
||||
<p>Has solicitado un nuevo enlace de verificación. Haz clic abajo para activar tu cuenta:</p>
|
||||
<div style='text-align: center; margin: 30px 0;'>
|
||||
<a href='{verifyLink}' style='background-color: #0051ff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;'>VERIFICAR AHORA</a>
|
||||
</div>
|
||||
<p style='font-size: 12px; color: #666;'>Si no solicitaste este correo, ignóralo.</p>
|
||||
</div>";
|
||||
|
||||
try
|
||||
{
|
||||
await _emailService.SendEmailAsync(user.Email, "Verificación de Cuenta - Reenvío", emailBody);
|
||||
return (true, "Correo de verificación reenviado. Revise su bandeja de entrada.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (false, "Error al enviar el correo. Intente más tarde.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Message)> ForgotPasswordAsync(string emailOrUsername)
|
||||
{
|
||||
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.Email == emailOrUsername || u.UserName == emailOrUsername);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
await Task.Delay(new Random().Next(100, 300));
|
||||
return (true, "Si el correo existe en nuestro sistema, recibirás las instrucciones.");
|
||||
}
|
||||
|
||||
// --- RATE LIMITING ---
|
||||
var cooldown = TimeSpan.FromMinutes(5);
|
||||
if (user.LastPasswordResetEmailSentAt.HasValue)
|
||||
{
|
||||
var timeSinceLastSend = DateTime.UtcNow - user.LastPasswordResetEmailSentAt.Value;
|
||||
if (timeSinceLastSend < cooldown)
|
||||
{
|
||||
var remaining = Math.Ceiling((cooldown - timeSinceLastSend).TotalMinutes);
|
||||
return (false, $"Por favor, espera {remaining} minutos antes de solicitar otro correo.");
|
||||
}
|
||||
}
|
||||
|
||||
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||
user.PasswordResetToken = token;
|
||||
user.PasswordResetTokenExpiresAt = DateTime.UtcNow.AddHours(1);
|
||||
user.LastPasswordResetEmailSentAt = DateTime.UtcNow;
|
||||
|
||||
await _v2Context.SaveChangesAsync();
|
||||
|
||||
var frontendUrl = _config["AppSettings:FrontendUrl"] ?? "http://localhost:5173";
|
||||
var resetLink = $"{frontendUrl}/restablecer-clave?token={token}";
|
||||
|
||||
var emailBody = $@"
|
||||
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;'>
|
||||
<h2 style='color: #0051ff; text-align: center;'>Recuperación de Contraseña</h2>
|
||||
<p>Hola <strong>{user.FirstName}</strong>,</p>
|
||||
<p>Recibimos una solicitud para restablecer tu contraseña en Motores Argentinos.</p>
|
||||
<div style='text-align: center; margin: 30px 0;'>
|
||||
<a href='{resetLink}' style='background-color: #0051ff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;'>RESTABLECER CLAVE</a>
|
||||
</div>
|
||||
<p style='font-size: 12px; color: #666;'>Este enlace expirará en 1 hora.</p>
|
||||
</div>";
|
||||
|
||||
try
|
||||
{
|
||||
await _emailService.SendEmailAsync(user.Email, "Restablecer Contraseña - Motores Argentinos", emailBody);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (false, "Hubo un error técnico enviando el correo. Intenta más tarde.");
|
||||
}
|
||||
|
||||
return (true, "Si el correo existe en nuestro sistema, recibirás las instrucciones.");
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Message)> ResetPasswordAsync(string token, string newPassword)
|
||||
{
|
||||
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.PasswordResetToken == token);
|
||||
|
||||
if (user == null) return (false, "El enlace es inválido.");
|
||||
if (user.PasswordResetTokenExpiresAt < DateTime.UtcNow) return (false, "El enlace ha expirado. Solicita uno nuevo.");
|
||||
|
||||
user.PasswordHash = _passwordService.HashPassword(newPassword);
|
||||
user.PasswordResetToken = null;
|
||||
user.PasswordResetTokenExpiresAt = null;
|
||||
user.PasswordSalt = null;
|
||||
user.MigrationStatus = 1;
|
||||
|
||||
await _v2Context.SaveChangesAsync();
|
||||
return (true, "Tu contraseña ha sido actualizada correctamente.");
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Message)> ChangePasswordAsync(int userId, string current, string newPwd)
|
||||
{
|
||||
var user = await _v2Context.Users.FindAsync(userId);
|
||||
if (user == null) return (false, "Usuario no encontrado");
|
||||
|
||||
if (!_passwordService.VerifyPassword(current, user.PasswordHash, user.PasswordSalt, user.MigrationStatus == 0))
|
||||
return (false, "La contraseña actual es incorrecta.");
|
||||
|
||||
user.PasswordHash = _passwordService.HashPassword(newPwd);
|
||||
user.PasswordSalt = null;
|
||||
user.MigrationStatus = 1;
|
||||
|
||||
await _v2Context.SaveChangesAsync();
|
||||
return (true, "Contraseña actualizada.");
|
||||
}
|
||||
|
||||
// Implementación del método de creación de usuario fantasma para Admin
|
||||
public async Task<User> CreateGhostUserAsync(string email, string firstName, string lastName, string phone)
|
||||
{
|
||||
var existing = await _v2Context.Users.FirstOrDefaultAsync(u => u.Email == email);
|
||||
if (existing != null) return existing;
|
||||
|
||||
// Generar username base desde el email (parte izquierda)
|
||||
string baseUsername = email.Split('@')[0].ToLowerInvariant();
|
||||
baseUsername = Regex.Replace(baseUsername, "[^a-z0-9]", "");
|
||||
|
||||
// Asegurar unicidad simple
|
||||
string finalUsername = baseUsername;
|
||||
int count = 1;
|
||||
while (await _v2Context.Users.AnyAsync(u => u.UserName == finalUsername))
|
||||
{
|
||||
finalUsername = $"{baseUsername}{count++}";
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
UserName = finalUsername,
|
||||
Email = email,
|
||||
FirstName = firstName,
|
||||
LastName = lastName,
|
||||
PhoneNumber = phone,
|
||||
PasswordHash = _passwordService.HashPassword(Guid.NewGuid().ToString()),
|
||||
MigrationStatus = 1,
|
||||
UserType = 1,
|
||||
IsEmailVerified = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_v2Context.Users.Add(user);
|
||||
await _v2Context.SaveChangesAsync();
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.IO;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Services;
|
||||
|
||||
public interface IImageStorageService
|
||||
{
|
||||
Task<string> SaveAdImageAsync(int adId, IFormFile file);
|
||||
void DeleteAdImage(string relativePath);
|
||||
}
|
||||
|
||||
public class ImageStorageService : IImageStorageService
|
||||
{
|
||||
private readonly IWebHostEnvironment _env;
|
||||
private readonly ILogger<ImageStorageService> _logger;
|
||||
|
||||
public ImageStorageService(IWebHostEnvironment env, ILogger<ImageStorageService> logger)
|
||||
{
|
||||
_env = env;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// Firmas de archivos (Magic Numbers) para JPG, PNG, WEBP
|
||||
private static readonly Dictionary<string, List<byte[]>> _fileSignatures = new Dictionary<string, List<byte[]>>
|
||||
{
|
||||
{ ".jpeg", new List<byte[]> { new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }, new byte[] { 0xFF, 0xD8, 0xFF, 0xE2 }, new byte[] { 0xFF, 0xD8, 0xFF, 0xE3 } } },
|
||||
{ ".jpg", new List<byte[]> { new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }, new byte[] { 0xFF, 0xD8, 0xFF, 0xE1 }, new byte[] { 0xFF, 0xD8, 0xFF, 0xE8 } } },
|
||||
{ ".png", new List<byte[]> { new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } } },
|
||||
{ ".webp", new List<byte[]> { new byte[] { 0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50 } } }
|
||||
};
|
||||
|
||||
public async Task<string> SaveAdImageAsync(int adId, IFormFile file)
|
||||
{
|
||||
// 1. Validación de Extensión Básica
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(ext) || !_fileSignatures.ContainsKey(ext))
|
||||
{
|
||||
throw new Exception("Formato de archivo no permitido. Solo JPG, PNG y WEBP.");
|
||||
}
|
||||
|
||||
// 2. Validación de Tamaño (Max 3MB)
|
||||
if (file.Length > 3 * 1024 * 1024)
|
||||
{
|
||||
throw new Exception("El archivo excede los 3MB permitidos.");
|
||||
}
|
||||
|
||||
// 3. Validación de Magic Numbers (Leer cabecera real)
|
||||
using (var stream = file.OpenReadStream())
|
||||
using (var reader = new BinaryReader(stream))
|
||||
{
|
||||
// Leemos hasta 12 bytes que cubren nuestras firmas soportadas
|
||||
var headerBytes = reader.ReadBytes(12);
|
||||
bool isRealImage = false;
|
||||
|
||||
// A. JPG (Flexible: FF D8)
|
||||
if (headerBytes.Length >= 2 && headerBytes[0] == 0xFF && headerBytes[1] == 0xD8)
|
||||
{
|
||||
isRealImage = true;
|
||||
}
|
||||
// B. PNG (Sello estricto)
|
||||
else if (headerBytes.Length >= 8 && headerBytes.Take(8).SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }))
|
||||
{
|
||||
isRealImage = true;
|
||||
}
|
||||
// C. WEBP (RIFF [4 bytes] WEBP)
|
||||
else if (headerBytes.Length >= 12 &&
|
||||
headerBytes.Take(4).SequenceEqual(new byte[] { 0x52, 0x49, 0x46, 0x46 }) &&
|
||||
headerBytes.Skip(8).Take(4).SequenceEqual(new byte[] { 0x57, 0x45, 0x42, 0x50 }))
|
||||
{
|
||||
isRealImage = true;
|
||||
}
|
||||
|
||||
if (!isRealImage)
|
||||
{
|
||||
string hex = BitConverter.ToString(headerBytes.Take(8).ToArray());
|
||||
_logger.LogWarning("Firma de archivo inválida para {Extension}: {HexBytes}", ext, hex);
|
||||
throw new Exception($"El archivo parece corrupto o tiene una firma inválida ({hex}). El sistema acepta JPG, PNG y WEBP reales.");
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
// 1. Definir rutas
|
||||
var uploadFolder = Path.Combine(_env.WebRootPath, "uploads", "ads", adId.ToString());
|
||||
if (!Directory.Exists(uploadFolder)) Directory.CreateDirectory(uploadFolder);
|
||||
|
||||
var uniqueName = Guid.NewGuid().ToString();
|
||||
var fileName = $"{uniqueName}.jpg";
|
||||
var thumbName = $"{uniqueName}_thumb.jpg";
|
||||
|
||||
var filePath = Path.Combine(uploadFolder, fileName);
|
||||
var thumbPath = Path.Combine(uploadFolder, thumbName);
|
||||
|
||||
// 2. Cargar y Procesar con ImageSharp
|
||||
using (var image = await Image.LoadAsync(file.OpenReadStream()))
|
||||
{
|
||||
// A. Guardar imagen principal (Optimized: Max width 1280px)
|
||||
if (image.Width > 1280)
|
||||
{
|
||||
image.Mutate(x => x.Resize(new ResizeOptions
|
||||
{
|
||||
Size = new Size(1280, 0), // 0 mantiene aspect ratio
|
||||
Mode = ResizeMode.Max
|
||||
}));
|
||||
}
|
||||
await image.SaveAsJpegAsync(filePath);
|
||||
|
||||
// B. Generar Thumbnail (Max width 400px para grillas)
|
||||
image.Mutate(x => x.Resize(new ResizeOptions
|
||||
{
|
||||
Size = new Size(400, 300),
|
||||
Mode = ResizeMode.Crop // Recorte inteligente para que queden parejitas
|
||||
}));
|
||||
await image.SaveAsJpegAsync(thumbPath);
|
||||
}
|
||||
|
||||
// Retornar ruta relativa web de la imagen principal
|
||||
return $"/uploads/ads/{adId}/{fileName}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error procesando imagen para el aviso {AdId}", adId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteAdImage(string relativePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(relativePath)) return;
|
||||
|
||||
try
|
||||
{
|
||||
// Eliminar archivo principal
|
||||
var fullPath = Path.Combine(_env.WebRootPath, relativePath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(fullPath)) File.Delete(fullPath);
|
||||
|
||||
// Eliminar thumbnail asociado
|
||||
var thumbPath = fullPath.Replace(".jpg", "_thumb.jpg");
|
||||
if (File.Exists(thumbPath)) File.Delete(thumbPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error eliminando imagen {Path}", relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using System.Data;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Dapper;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using MotoresArgentinosV2.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Services;
|
||||
|
||||
public class LegacyPaymentService : ILegacyPaymentService
|
||||
{
|
||||
private readonly string _internetConn;
|
||||
private readonly MotoresV2DbContext _v2Context;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<LegacyPaymentService> _logger;
|
||||
|
||||
public LegacyPaymentService(IConfiguration config, MotoresV2DbContext v2Context, ILogger<LegacyPaymentService> logger)
|
||||
{
|
||||
_internetConn = config.GetConnectionString("Internet") ?? "";
|
||||
_v2Context = v2Context;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AdPriceResult> GetAdPriceAsync(string category, bool isFeatured)
|
||||
{
|
||||
// Consulta real al Legacy para obtener precio
|
||||
using IDbConnection db = new SqlConnection(_internetConn);
|
||||
|
||||
var parametros = new { tarea = category, paquete = isFeatured ? 1 : 0 };
|
||||
|
||||
try
|
||||
{
|
||||
var result = await db.QueryFirstOrDefaultAsync<dynamic>(
|
||||
"SPDATOSAVISOS",
|
||||
parametros,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
// El SP devuelve IMPORTE_TOTSINIVA que incluye el recargo destacado pero SIN IVA
|
||||
// El IVA está hardcodeado al 10.5% en la lógica legacy
|
||||
decimal neto = (decimal)result.IMPORTE_TOTSINIVA;
|
||||
decimal iva = neto * 0.105m;
|
||||
return new AdPriceResult(neto, iva, neto + iva, "ARS");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception("El SP legacy devolvió null para los parámetros dados.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error crítico consultando SPDATOSAVISOS legacy. No se puede determinar precio.");
|
||||
throw; // Re-throw to prevent hardcoded fallback
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ProcessPaymentResponseAsync(string operationCode, string status, string providerData)
|
||||
{
|
||||
_logger.LogInformation("Procesando pago Legacy. Op: {OpCode}, Status: {Status}", operationCode, status);
|
||||
|
||||
// 1. Buscamos la transacción en V2 para obtener los datos necesarios.
|
||||
var tx = await _v2Context.Transactions
|
||||
.Include(t => t.Ad)
|
||||
.ThenInclude(a => a.User)
|
||||
.FirstOrDefaultAsync(t => t.OperationCode == operationCode);
|
||||
|
||||
// Si no encontramos la transacción, no podemos continuar.
|
||||
if (tx == null)
|
||||
{
|
||||
_logger.LogError("No se encontró la transacción V2 con OperationCode {OpCode} para sincronizar con Legacy.", operationCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Si el pago no fue aprobado, no hay nada que insertar en legacy.
|
||||
if (!status.Equals("APPROVED", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true; // La operación no falló, simplemente no aplica.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var ad = tx.Ad;
|
||||
if (ad == null || ad.User == null)
|
||||
{
|
||||
_logger.LogError("La transacción {OpCode} no tiene un aviso o usuario asociado.", operationCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
decimal importeNeto = Math.Round(tx.Amount / 1.105m, 2);
|
||||
decimal importeIva = Math.Round(tx.Amount - importeNeto, 2);
|
||||
|
||||
var p = new DynamicParameters();
|
||||
p.Add("@tipo", ad.VehicleTypeID == 1 ? "A" : "M");
|
||||
p.Add("@nro_operacion", tx.TransactionID);
|
||||
p.Add("@id_cliente", ad.UserID);
|
||||
p.Add("@tipodoc", 96);
|
||||
p.Add("@nro_doc", "0");
|
||||
p.Add("@razon", $"{ad.User.FirstName} {ad.User.LastName}".Trim().ToUpper());
|
||||
p.Add("@calle", "");
|
||||
p.Add("@numero", "");
|
||||
p.Add("@localidad", "LA PLATA");
|
||||
p.Add("@codigopostal", "1900");
|
||||
p.Add("@telefono", ad.ContactPhone ?? "");
|
||||
p.Add("@email", ad.User.Email);
|
||||
p.Add("@id_tipoiva", 1);
|
||||
p.Add("@porcentaje_iva1", 10.5m);
|
||||
p.Add("@porcentaje_iva2", 0);
|
||||
p.Add("@porcentaje_percepcion", 0);
|
||||
p.Add("@id_tipoaviso", 16);
|
||||
p.Add("@nombreaviso", "INTERNET");
|
||||
p.Add("@id_rubro", 193);
|
||||
p.Add("@id_subrubro", 0);
|
||||
p.Add("@id_combinado", 0);
|
||||
p.Add("@porcentaje_combinado", 0);
|
||||
p.Add("@fecha_inicio", DateTime.Now.Date, DbType.DateTime);
|
||||
p.Add("@cant_dias", 30);
|
||||
p.Add("@dias_corridos", true);
|
||||
p.Add("@palabras", 20);
|
||||
p.Add("@centimetros", 0);
|
||||
p.Add("@columnas", 0);
|
||||
p.Add("@id_tarjeta", 1);
|
||||
p.Add("@nro_tarjeta", "0000000000000000");
|
||||
p.Add("@cvc_tarjeta", 111);
|
||||
p.Add("@vencimiento", DateTime.Now.AddDays(1), DbType.DateTime);
|
||||
p.Add("@calle_envio", "");
|
||||
p.Add("@numero_envio", "");
|
||||
p.Add("@localidad_envio", "");
|
||||
p.Add("@tarifa", importeNeto);
|
||||
p.Add("@importe_aviso", importeNeto);
|
||||
p.Add("@importe_iva1", importeIva);
|
||||
p.Add("@importe_iva2", 0);
|
||||
p.Add("@importe_percepcion", 0);
|
||||
p.Add("@cantavi", 1);
|
||||
p.Add("@paquete", 1);
|
||||
p.Add("@destacado", ad.IsFeatured);
|
||||
|
||||
using (var dbInternet = new SqlConnection(_internetConn))
|
||||
{
|
||||
// El SP legacy convierte internamente @fecha_inicio a un string 'dd/mm/yyyy'.
|
||||
// Esto falla en servidores con formato 'mdy'.
|
||||
// Al anteponer 'SET DATEFORMAT dmy;', forzamos a la sesión a interpretar
|
||||
// correctamente el formato 'dd/mm/yyyy' y se soluciona el error.
|
||||
var sql = @"
|
||||
SET DATEFORMAT dmy;
|
||||
EXEC spInsertaAvisos
|
||||
@tipo, @nro_operacion, @id_cliente, @tipodoc, @nro_doc, @razon,
|
||||
@calle, @numero, @localidad, @codigopostal, @telefono, @email,
|
||||
@id_tipoiva, @porcentaje_iva1, @porcentaje_iva2, @porcentaje_percepcion,
|
||||
@id_tipoaviso, @nombreaviso, @id_rubro, @id_subrubro, @id_combinado,
|
||||
@porcentaje_combinado, @fecha_inicio, @cant_dias, @dias_corridos,
|
||||
@palabras, @centimetros, @columnas, @id_tarjeta, @nro_tarjeta,
|
||||
@cvc_tarjeta, @vencimiento, @calle_envio, @numero_envio,
|
||||
@localidad_envio, @tarifa, @importe_aviso, @importe_iva1,
|
||||
@importe_iva2, @importe_percepcion, @cantavi, @paquete, @destacado;
|
||||
";
|
||||
|
||||
await dbInternet.ExecuteAsync(sql, p);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Aviso insertado en Legacy AvisosWeb correctamente para Op: {OpCode}", operationCode);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error crítico insertando en Legacy AvisosWeb. Op: {OpCode}", operationCode);
|
||||
// Devolvemos false para que el servicio que lo llamó sepa que la integración falló.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
// backend/MotoresArgentinosV2.Infrastructure/Services/MercadoPagoService.cs
|
||||
using MercadoPago.Client;
|
||||
using MercadoPago.Client.Payment;
|
||||
using MercadoPago.Config;
|
||||
using MercadoPago.Resource.Payment;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MotoresArgentinosV2.Infrastructure.Data;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Services;
|
||||
|
||||
public class MercadoPagoService : IPaymentService
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly MotoresV2DbContext _context;
|
||||
private readonly ILegacyPaymentService _legacyService;
|
||||
private readonly IAvisosLegacyService _legacyAdsService;
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly IAdSyncService _syncService;
|
||||
private readonly ILogger<MercadoPagoService> _logger;
|
||||
|
||||
public MercadoPagoService(
|
||||
IConfiguration config,
|
||||
MotoresV2DbContext context,
|
||||
ILegacyPaymentService legacyService,
|
||||
IAvisosLegacyService legacyAdsService,
|
||||
INotificationService notificationService,
|
||||
IAdSyncService syncService,
|
||||
ILogger<MercadoPagoService> logger)
|
||||
{
|
||||
_config = config;
|
||||
_context = context;
|
||||
_legacyService = legacyService;
|
||||
_legacyAdsService = legacyAdsService;
|
||||
_notificationService = notificationService;
|
||||
_syncService = syncService;
|
||||
_logger = logger;
|
||||
|
||||
var token = _config["MercadoPago:AccessToken"] ?? throw new Exception("MP AccessToken no configurado");
|
||||
MercadoPagoConfig.AccessToken = token;
|
||||
}
|
||||
|
||||
public async Task<PaymentResponseDto> ProcessPaymentAsync(CreatePaymentRequestDto request, int userId)
|
||||
{
|
||||
// INTEGRIDAD DE PRECIOS Y PROPIEDAD
|
||||
var ad = await _context.Ads
|
||||
.Include(a => a.User)
|
||||
.Include(a => a.Brand)
|
||||
.FirstOrDefaultAsync(a => a.AdID == request.AdId);
|
||||
|
||||
if (ad == null) throw new Exception("El aviso no existe.");
|
||||
if (ad.UserID != userId) throw new Exception("No tienes permiso para pagar este aviso.");
|
||||
|
||||
// VALIDACIÓN DE PRECIO CONTRA LEGACY (Integridad Corregida)
|
||||
// El SP spDatosAvisos usa ("EMOTORES", 1) para destacados y ("EMOTORES", 0) para normales (Autos/Motos)
|
||||
int paquete = ad.IsFeatured ? 1 : 0;
|
||||
|
||||
var tarifas = await _legacyAdsService.ObtenerDatosAvisosAsync("EMOTORES", paquete);
|
||||
var tarifaOficial = tarifas.FirstOrDefault();
|
||||
|
||||
if (tarifaOficial == null)
|
||||
{
|
||||
_logger.LogWarning("No se encontró tarifa en Legacy para (EMOTORES, {Paquete}). Aplicando validación básica.", paquete);
|
||||
if (request.TransactionAmount <= 0) throw new Exception("Monto de transacción inválido.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calcular precio final con IVA y Redondear igual que en el Frontend
|
||||
// La lógica de negocio es: (Neto * 1.105) redondeado al entero más cercano.
|
||||
decimal precioCalculado = Math.Round(tarifaOficial.ImporteTotsiniva * 1.105m, 0);
|
||||
|
||||
// Comparamos el monto que viene del front con nuestro cálculo redondeado
|
||||
if (request.TransactionAmount != precioCalculado)
|
||||
{
|
||||
_logger.LogCritical("¡ALERTA DE SEGURIDAD! Intento de manipulación de precio. AdID: {AdId}, Calculado: {Expected}, Recibido: {Actual}",
|
||||
ad.AdID, precioCalculado, request.TransactionAmount);
|
||||
|
||||
// Mensaje genérico para el usuario, logueando el detalle real
|
||||
throw new Exception($"Integridad de precio fallida. El monto solicitado no coincide con la tarifa oficial vigente.");
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Generar ID de Operación Único
|
||||
string operationCode = $"M2-{request.AdId}-{DateTime.Now.Ticks % 10000000}";
|
||||
|
||||
// 2. Crear Request
|
||||
var paymentRequest = new PaymentCreateRequest
|
||||
{
|
||||
TransactionAmount = request.TransactionAmount,
|
||||
Token = request.Token,
|
||||
Description = request.Description ?? $"Publicación Aviso #{request.AdId}",
|
||||
Installments = request.Installments,
|
||||
PaymentMethodId = request.PaymentMethodId,
|
||||
IssuerId = request.IssuerId,
|
||||
Payer = new PaymentPayerRequest
|
||||
{
|
||||
Email = request.PayerEmail,
|
||||
FirstName = "Usuario",
|
||||
LastName = "Motores"
|
||||
},
|
||||
ExternalReference = operationCode,
|
||||
StatementDescriptor = "MOTORESARG" // Aparece en el resumen de la tarjeta
|
||||
};
|
||||
|
||||
// 🛡️ SEGURIDAD: IDEMPOTENCIA
|
||||
var requestOptions = new RequestOptions();
|
||||
requestOptions.CustomHeaders.Add("X-Idempotency-Key", operationCode);
|
||||
|
||||
var client = new PaymentClient();
|
||||
Payment payment;
|
||||
|
||||
try
|
||||
{
|
||||
// 3. Procesar Pago con Idempotencia
|
||||
payment = await client.CreateAsync(paymentRequest, requestOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error comunicándose con Mercado Pago");
|
||||
throw new Exception("Error al procesar el pago con el proveedor. Por favor intente nuevamente.");
|
||||
}
|
||||
|
||||
// 4. Guardar Transacción en V2
|
||||
var transaction = new TransactionRecord
|
||||
{
|
||||
AdID = request.AdId,
|
||||
OperationCode = operationCode,
|
||||
Amount = request.TransactionAmount,
|
||||
Status = MapStatus(payment.Status),
|
||||
PaymentMethodID = 1,
|
||||
|
||||
ProviderPaymentId = payment.Id?.ToString(),
|
||||
ProviderResponse = System.Text.Json.JsonSerializer.Serialize(payment),
|
||||
|
||||
SnapshotUserEmail = ad.User?.Email ?? request.PayerEmail,
|
||||
SnapshotUserName = ad.User?.UserName ?? "Usuario",
|
||||
SnapshotAdTitle = ad.Brand != null ? $"{ad.Brand.Name} {ad.VersionName}" : ad.VersionName,
|
||||
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.Transactions.Add(transaction);
|
||||
|
||||
// Actualizar estado del aviso si queda PENDIENTE (in_process)
|
||||
// Esto evita que el usuario vea el botón "Continuar Pago" y pague doble.
|
||||
if (payment.Status == PaymentStatus.InProcess || payment.Status == PaymentStatus.Pending)
|
||||
{
|
||||
ad.StatusID = 2; // PaymentPending -> Habilita botón "Verificar Ahora" en el front
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// 5. Impactar en Legacy y V2 si está aprobado
|
||||
if (payment.Status == PaymentStatus.Approved)
|
||||
{
|
||||
await FinalizeApprovedPayment(ad, payment.Id?.ToString() ?? "0", operationCode, request.PaymentMethodId, request.PayerEmail, request.TransactionAmount);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 📝 AUDITORÍA (Pago no aprobado inmediatamente)
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "PAYMENT_INITIATED",
|
||||
Entity = "Transaction",
|
||||
EntityID = transaction.TransactionID,
|
||||
UserID = userId,
|
||||
Details = $"Pago iniciado para AdID {request.AdId}. Estado: {payment.Status}"
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return new PaymentResponseDto
|
||||
{
|
||||
PaymentId = payment.Id ?? 0,
|
||||
Status = payment.Status,
|
||||
StatusDetail = payment.StatusDetail,
|
||||
OperationCode = operationCode
|
||||
};
|
||||
}
|
||||
|
||||
public async Task ProcessWebhookAsync(string topic, string id)
|
||||
{
|
||||
if (topic != "payment") return;
|
||||
|
||||
var client = new PaymentClient();
|
||||
var payment = await client.GetAsync(long.Parse(id));
|
||||
|
||||
if (payment == null) return;
|
||||
|
||||
// Buscar la transacción por el ID de pago del proveedor (ProviderPaymentId)
|
||||
var transaction = await _context.Transactions
|
||||
.FirstOrDefaultAsync(t => t.ProviderPaymentId == id);
|
||||
|
||||
if (transaction == null)
|
||||
{
|
||||
_logger.LogWarning("Webhook recibido para un ID de pago no encontrado en la DB: {PaymentId}", id);
|
||||
return; // No encontramos la transacción, no hay nada que hacer.
|
||||
}
|
||||
|
||||
// Si ya está aprobada, no hacemos nada para evitar procesar dos veces.
|
||||
if (transaction.Status == "APPROVED") return;
|
||||
|
||||
// Actualizar estado en V2 con la información REAL de Mercado Pago
|
||||
transaction.Status = MapStatus(payment.Status);
|
||||
transaction.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// 📝 AUDITORÍA (Webhook)
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "PAYMENT_WEBHOOK_RECEIVED",
|
||||
Entity = "Transaction",
|
||||
EntityID = transaction.TransactionID,
|
||||
UserID = 0, // Sistema
|
||||
Details = $"Webhook recibido para MP_ID {id}. Nuevo estado: {transaction.Status}"
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Si el estado REAL que obtuvimos de MP es "approved", finalizamos el pago.
|
||||
if (payment.Status == PaymentStatus.Approved)
|
||||
{
|
||||
var ad = await _context.Ads.Include(a => a.User).Include(a => a.Brand).FirstOrDefaultAsync(a => a.AdID == transaction.AdID);
|
||||
if (ad != null)
|
||||
{
|
||||
await FinalizeApprovedPayment(ad, id, transaction.OperationCode, payment.PaymentMethodId, payment.Payer.Email, payment.TransactionAmount ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FinalizeApprovedPayment(Ad ad, string mpPaymentId, string operationCode, string paymentMethod, string payerEmail, decimal paidAmount)
|
||||
{
|
||||
// A. Actualizar estado del Aviso y Auditoría en V2
|
||||
ad.StatusID = (int)AdStatusEnum.ModerationPending;
|
||||
ad.ExpirationWarningSent = false;
|
||||
ad.PaymentReminderSentAt = null;
|
||||
ad.LastPerformanceEmailSentAt = DateTime.UtcNow;
|
||||
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Action = "PAYMENT_APPROVED",
|
||||
Entity = "Ad",
|
||||
EntityID = ad.AdID,
|
||||
UserID = ad.UserID,
|
||||
Details = $"Pago aprobado para AdID {ad.AdID}. Operación: {operationCode}. MP_ID: {mpPaymentId}"
|
||||
});
|
||||
|
||||
// Guardamos los cambios iniciales en la base de datos V2
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// B. Impactar Legacy
|
||||
var providerResponseForLegacy = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
tarjeta = paymentMethod,
|
||||
mediopago = "1",
|
||||
titular = "MERCADO PAGO USER",
|
||||
emailcomprador = payerEmail,
|
||||
noperacion = operationCode,
|
||||
mp_id = mpPaymentId
|
||||
});
|
||||
|
||||
// Llamamos al servicio legacy y verificamos si tuvo éxito
|
||||
var legacySuccess = await _legacyService.ProcessPaymentResponseAsync(operationCode, "APPROVED", providerResponseForLegacy);
|
||||
|
||||
// C. Si la sincronización legacy fue exitosa, actualizamos el LegacyAdID
|
||||
if (legacySuccess)
|
||||
{
|
||||
// Volvemos a buscar la transacción para asegurarnos de tener el ID correcto
|
||||
var transaction = await _context.Transactions.FirstOrDefaultAsync(t => t.OperationCode == operationCode);
|
||||
if (transaction != null)
|
||||
{
|
||||
ad.LegacyAdID = transaction.TransactionID;
|
||||
await _context.SaveChangesAsync(); // Guardamos el ID legacy en el aviso
|
||||
}
|
||||
}
|
||||
// Si legacySuccess es false, el error ya fue logueado por el LegacyPaymentService.
|
||||
|
||||
// D. Notificar al Usuario (RECIBO DE PAGO) - Se ejecuta independientemente del éxito de legacy
|
||||
try
|
||||
{
|
||||
var title = $"{ad.Brand?.Name} {ad.VersionName}";
|
||||
var userName = ad.User?.FirstName ?? "Usuario";
|
||||
|
||||
await _notificationService.SendPaymentReceiptEmailAsync(
|
||||
ad.User?.Email ?? payerEmail,
|
||||
userName,
|
||||
title,
|
||||
paidAmount,
|
||||
operationCode
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Falló el envío de email de recibo para Op: {OpCode}", operationCode);
|
||||
/* No bloqueamos el flujo si falla el mail */
|
||||
}
|
||||
}
|
||||
|
||||
private string MapStatus(string mpStatus)
|
||||
{
|
||||
return mpStatus switch
|
||||
{
|
||||
PaymentStatus.Approved => "APPROVED",
|
||||
PaymentStatus.Rejected => "REJECTED",
|
||||
PaymentStatus.InProcess => "PENDING",
|
||||
_ => "PENDING"
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PaymentResponseDto> CheckPaymentStatusAsync(int adId)
|
||||
{
|
||||
// 1. Buscar la última transacción PENDING para este aviso
|
||||
var transaction = await _context.Transactions
|
||||
.Where(t => t.AdID == adId && t.Status == "PENDING")
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (transaction == null)
|
||||
{
|
||||
// Si no hay pendientes, buscamos la última aprobada para devolver estado OK
|
||||
var approved = await _context.Transactions
|
||||
.Where(t => t.AdID == adId && t.Status == "APPROVED")
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (approved != null) return new PaymentResponseDto { Status = "approved", PaymentId = 0 };
|
||||
|
||||
throw new Exception("No se encontraron transacciones pendientes para verificar.");
|
||||
}
|
||||
|
||||
long mpId = 0;
|
||||
if (long.TryParse(transaction.ProviderResponse, out long simpleId))
|
||||
{
|
||||
mpId = simpleId;
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(transaction.ProviderResponse ?? "{}");
|
||||
if (doc.RootElement.TryGetProperty("mp_id", out var el)) mpId = long.Parse(el.GetString()!);
|
||||
else if (doc.RootElement.TryGetProperty("id", out var el2)) mpId = el2.GetInt64();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
if (mpId == 0) throw new Exception("No se pudo recuperar el ID de Mercado Pago.");
|
||||
|
||||
var client = new PaymentClient();
|
||||
var payment = await client.GetAsync(mpId);
|
||||
|
||||
// 3. Actualizar DB Local si cambió el estado
|
||||
var newStatus = MapStatus(payment.Status);
|
||||
|
||||
if (newStatus != transaction.Status)
|
||||
{
|
||||
transaction.Status = newStatus;
|
||||
transaction.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Si se aprobó ahora, ejecutamos la lógica de finalización
|
||||
if (newStatus == "APPROVED")
|
||||
{
|
||||
var ad = await _context.Ads.Include(a => a.User).FirstOrDefaultAsync(a => a.AdID == adId);
|
||||
if (ad != null)
|
||||
{
|
||||
await FinalizeApprovedPayment(ad, payment.Id?.ToString() ?? "", transaction.OperationCode, payment.PaymentMethodId, payment.Payer.Email, transaction.Amount);
|
||||
}
|
||||
}
|
||||
|
||||
// Si se rechazó o canceló
|
||||
if (newStatus == "REJECTED")
|
||||
{
|
||||
// Liberar el aviso para intentar pagar de nuevo
|
||||
var ad = await _context.Ads.FindAsync(adId);
|
||||
if (ad != null) ad.StatusID = 1; // Volver a Borrador
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return new PaymentResponseDto
|
||||
{
|
||||
PaymentId = payment.Id ?? 0,
|
||||
Status = payment.Status,
|
||||
StatusDetail = payment.StatusDetail,
|
||||
OperationCode = transaction.OperationCode
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Services;
|
||||
|
||||
public class NotificationService : INotificationService
|
||||
{
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly ILogger<NotificationService> _logger;
|
||||
private readonly string _frontendUrl;
|
||||
|
||||
public NotificationService(IEmailService emailService, ILogger<NotificationService> logger, IConfiguration config)
|
||||
{
|
||||
_emailService = emailService;
|
||||
_logger = logger;
|
||||
// Leemos la URL del appsettings o usamos localhost como fallback
|
||||
_frontendUrl = config["AppSettings:FrontendUrl"] ?? "http://localhost:5173";
|
||||
}
|
||||
|
||||
private string GetEmailShell(string title, string content)
|
||||
{
|
||||
return $@"
|
||||
<div style='background-color: #0a0c10; color: #e5e7eb; font-family: sans-serif; padding: 40px; line-height: 1.6;'>
|
||||
<div style='max-width: 600px; margin: 0 auto; background-color: #12141a; border: 1px solid #1f2937; border-radius: 24px; overflow: hidden; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);'>
|
||||
<div style='background: linear-gradient(to right, #2563eb, #22d3ee); padding: 30px; text-align: center;'>
|
||||
<h1 style='color: white; margin: 0; font-size: 24px; text-transform: uppercase; letter-spacing: 2px; font-weight: 900;'>Motores <span style='color: #bfdbfe;'>Argentinos</span></h1>
|
||||
</div>
|
||||
<div style='padding: 40px;'>
|
||||
<h2 style='color: white; font-size: 20px; font-weight: 800; margin-top: 0; text-transform: uppercase;'>{title}</h2>
|
||||
<div style='color: #9ca3af; font-size: 14px;'>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
<div style='padding: 20px; border-top: 1px solid #1f2937; text-align: center; background-color: #0d0f14;'>
|
||||
<p style='color: #4b5563; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; margin: 0;'>Motores Argentinos - La Plata, Buenos Aires, Argentina</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>";
|
||||
}
|
||||
|
||||
public async Task SendChatNotificationEmailAsync(string toEmail, string fromUser, string message, int adId)
|
||||
{
|
||||
string subject = "Tienes un nuevo mensaje - Motores Argentinos";
|
||||
string content = $@"
|
||||
<p>Hola,</p>
|
||||
<p><strong>{fromUser}</strong> te ha enviado un mensaje sobre el aviso #{adId}:</p>
|
||||
<blockquote style='background: #1a1d24; border-left: 4px solid #2563eb; padding: 15px; margin: 20px 0; font-style: italic; color: #d1d5db;'>
|
||||
""{message}""
|
||||
</blockquote>
|
||||
<p style='margin-top: 20px;'>Ingresa a tu cuenta para responder.</p>
|
||||
<div style='text-align: center; margin: 30px 0;'>
|
||||
<a href='{_frontendUrl}/mis-avisos' style='background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>VER MENSAJES</a>
|
||||
</div>";
|
||||
|
||||
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Nuevo Mensaje", content));
|
||||
}
|
||||
|
||||
public async Task SendAdStatusChangedEmailAsync(string toEmail, string adTitle, string status, string? reason = null)
|
||||
{
|
||||
string subject = "Estado de tu aviso - Motores Argentinos";
|
||||
string color = status.ToUpper() == "APROBADO" ? "#10b981" : "#ef4444";
|
||||
|
||||
string content = $@"
|
||||
<p>Hola,</p>
|
||||
<p>Te informamos que el estado de tu aviso <strong>""{adTitle}""</strong> ha cambiado a:</p>
|
||||
<div style='background: {color}20; border: 1px solid {color}; color: {color}; padding: 15px; border-radius: 8px; text-align: center; font-weight: 900; text-transform: uppercase; margin: 20px 0; font-size: 16px;'>
|
||||
{status}
|
||||
</div>
|
||||
{(string.IsNullOrEmpty(reason) ? "" : $"<p style='background: #1f2937; padding: 15px; border-radius: 8px; border-left: 4px solid #ef4444;'><strong>Motivo:</strong> {reason}</p>")}
|
||||
<p style='margin-top: 20px;'>Gracias por confiar en nosotros.</p>";
|
||||
|
||||
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Cambio de Estado", content));
|
||||
}
|
||||
|
||||
public async Task SendSecurityAlertEmailAsync(string toEmail, string actionDescription)
|
||||
{
|
||||
string subject = "Alerta de Seguridad - Motores Argentinos";
|
||||
string content = $@"
|
||||
<p style='color: #f87171; font-weight: bold; font-size: 16px;'>¡Alerta de Seguridad!</p>
|
||||
<p>Te informamos que se ha realizado la siguiente acción crítica en tu cuenta:</p>
|
||||
<div style='background: #1a1d24; border: 1px solid #374151; padding: 15px; border-radius: 8px; margin: 20px 0;'>
|
||||
<span style='color: white; font-weight: bold;'>Acción:</span> {actionDescription}
|
||||
</div>
|
||||
<p>Si no fuiste tú, te recomendamos cambiar tu contraseña inmediatamente y contactar a nuestro equipo de soporte.</p>
|
||||
<p style='margin-top: 20px;'>Atentamente,<br>Equipo de Seguridad.</p>";
|
||||
|
||||
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Alerta de Seguridad", content));
|
||||
}
|
||||
|
||||
public async Task SendExpirationWarningEmailAsync(string toEmail, string userName, string adTitle, DateTime expirationDate)
|
||||
{
|
||||
string subject = "Tu aviso está por vencer - Motores Argentinos";
|
||||
string content = $@"
|
||||
<p>Hola <strong>{userName}</strong>,</p>
|
||||
<p>Te recordamos que tu publicación <strong>""{adTitle}""</strong> finalizará el día <strong>{expirationDate:dd/MM/yyyy}</strong>.</p>
|
||||
<p>Para asegurar que tu vehículo siga visible y no pierdas potenciales compradores, te recomendamos renovarlo ahora.</p>
|
||||
<div style='text-align: center; margin: 30px 0;'>
|
||||
<a href='{_frontendUrl}/mis-avisos' style='background-color: #f59e0b; color: #000; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>RENOVAR AVISO</a>
|
||||
</div>";
|
||||
|
||||
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Aviso por Vencer", content));
|
||||
}
|
||||
|
||||
public async Task SendAdExpiredEmailAsync(string toEmail, string userName, string adTitle)
|
||||
{
|
||||
string subject = "Tu aviso ha finalizado - Motores Argentinos";
|
||||
string content = $@"
|
||||
<p>Hola <strong>{userName}</strong>,</p>
|
||||
<p>Tu aviso <strong>""{adTitle}""</strong> ha llegado al fin de su vigencia y ya no es visible en los listados.</p>
|
||||
<p>Si aún no vendiste tu vehículo, puedes republicarlo fácilmente desde tu panel de gestión.</p>
|
||||
<div style='text-align: center; margin: 30px 0;'>
|
||||
<a href='{_frontendUrl}/mis-avisos' style='background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>REPUBLICAR AHORA</a>
|
||||
</div>";
|
||||
|
||||
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Aviso Finalizado", content));
|
||||
}
|
||||
|
||||
public async Task SendWeeklyPerformanceEmailAsync(string toEmail, string userName, string adTitle, int views, int favorites)
|
||||
{
|
||||
string subject = "Resumen semanal de tu aviso - Motores Argentinos";
|
||||
|
||||
string content = $@"
|
||||
<p>Hola <strong>{userName}</strong>,</p>
|
||||
<p>Aquí tienes el rendimiento de tu aviso <strong>""{adTitle}""</strong> en los últimos 7 días:</p>
|
||||
|
||||
<!-- Contenedor Principal Centrado -->
|
||||
<div style='text-align: center; margin: 30px 0; font-size: 0;'>
|
||||
<!--[if mso]>
|
||||
<table role='presentation' width='100%'>
|
||||
<tr>
|
||||
<td style='width:50%; padding: 5px;' valign='top'>
|
||||
<![endif]-->
|
||||
|
||||
<!-- Caja Visitas -->
|
||||
<div style='display: inline-block; width: 46%; min-width: 140px; vertical-align: top; margin: 1%; background: #1f2937; border-radius: 12px; padding: 25px 10px; box-sizing: border-box; border: 1px solid #374151;'>
|
||||
<span style='font-size: 28px; display: block; margin-bottom: 5px;'>👁️</span>
|
||||
<strong style='font-size: 24px; color: #ffffff; display: block; font-family: sans-serif;'>{views}</strong>
|
||||
<span style='font-size: 11px; color: #9ca3af; text-transform: uppercase; font-weight: bold; letter-spacing: 1px; font-family: sans-serif;'>Visitas</span>
|
||||
</div>
|
||||
|
||||
<!--[if mso]>
|
||||
</td>
|
||||
<td style='width:50%; padding: 5px;' valign='top'>
|
||||
<![endif]-->
|
||||
|
||||
<!-- Caja Favoritos -->
|
||||
<div style='display: inline-block; width: 46%; min-width: 140px; vertical-align: top; margin: 1%; background: #1f2937; border-radius: 12px; padding: 25px 10px; box-sizing: border-box; border: 1px solid #374151;'>
|
||||
<span style='font-size: 28px; display: block; margin-bottom: 5px;'>⭐</span>
|
||||
<strong style='font-size: 24px; color: #ffffff; display: block; font-family: sans-serif;'>{favorites}</strong>
|
||||
<span style='font-size: 11px; color: #9ca3af; text-transform: uppercase; font-weight: bold; letter-spacing: 1px; font-family: sans-serif;'>Favoritos</span>
|
||||
</div>
|
||||
|
||||
<!--[if mso]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</div>";
|
||||
|
||||
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Rendimiento Semanal", content));
|
||||
}
|
||||
|
||||
public async Task SendPaymentReminderEmailAsync(string toEmail, string userName, string adTitle, string link)
|
||||
{
|
||||
string subject = "Finaliza la publicación de tu aviso - Motores Argentinos";
|
||||
string content = $@"
|
||||
<p>Hola <strong>{userName}</strong>,</p>
|
||||
<p>Tu aviso del <strong>""{adTitle}""</strong> está casi listo, pero aún no es visible para los compradores.</p>
|
||||
<p>Solo falta confirmar el pago para activarlo. ¡No pierdas oportunidades de venta!</p>
|
||||
<div style='text-align: center; margin: 30px 0;'>
|
||||
<a href='{link}' style='background-color: #10b981; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>FINALIZAR PUBLICACIÓN</a>
|
||||
</div>";
|
||||
|
||||
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Acción Requerida", content));
|
||||
}
|
||||
|
||||
public async Task SendPaymentReceiptEmailAsync(string toEmail, string userName, string adTitle, decimal amount, string operationCode)
|
||||
{
|
||||
string subject = "Comprobante de Pago - Motores Argentinos";
|
||||
string content = $@"
|
||||
<p>Hola <strong>{userName}</strong>,</p>
|
||||
<p>Hemos recibido tu pago correctamente. Aquí tienes el detalle de la operación:</p>
|
||||
<div style='background: #1f2937; padding: 20px; border-radius: 8px; margin: 20px 0; border: 1px solid #374151;'>
|
||||
<p style='margin: 5px 0;'><strong>Concepto:</strong> Publicación Aviso Clasificado</p>
|
||||
<p style='margin: 5px 0;'><strong>Vehículo:</strong> {adTitle}</p>
|
||||
<p style='margin: 5px 0;'><strong>Operación:</strong> {operationCode}</p>
|
||||
<p style='margin: 5px 0;'><strong>Fecha:</strong> {DateTime.Now:dd/MM/yyyy HH:mm}</p>
|
||||
<hr style='border: 0; border-top: 1px solid #374151; margin: 15px 0;'>
|
||||
<p style='margin: 5px 0; font-size: 18px; text-align: right; color: #10b981;'><strong>Total: ${amount:N2}</strong></p>
|
||||
</div>
|
||||
<p>Tu aviso ha pasado a la etapa de moderación y será activado a la brevedad.</p>";
|
||||
|
||||
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Recibo de Pago", content));
|
||||
}
|
||||
|
||||
public async Task SendUnreadMessagesReminderEmailAsync(string toEmail, string userName, int unreadCount)
|
||||
{
|
||||
string subject = "Tienes mensajes sin leer - Motores Argentinos";
|
||||
string content = $@"
|
||||
<p>Hola <strong>{userName}</strong>,</p>
|
||||
<p>Tienes <strong>{unreadCount} mensaje{(unreadCount > 1 ? "s" : "")} sin leer</strong> en tu bandeja de entrada.</p>
|
||||
<p>Es importante responder a los interesados o moderadores para mantener la actividad de tu cuenta.</p>
|
||||
<div style='text-align: center; margin: 30px 0;'>
|
||||
<a href='{_frontendUrl}/mis-avisos' style='background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>IR A MIS MENSAJES</a>
|
||||
</div>";
|
||||
|
||||
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Mensajes Pendientes", content));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using MotoresArgentinosV2.Infrastructure.Data;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implementación del servicio para interactuar con datos legacy de operaciones
|
||||
/// Utiliza AutosDbContext para acceder a tablas y ejecutar SPs de la DB 'autos'
|
||||
/// </summary>
|
||||
public class OperacionesLegacyService : IOperacionesLegacyService
|
||||
{
|
||||
private readonly AutosDbContext _context;
|
||||
private readonly ILogger<OperacionesLegacyService> _logger;
|
||||
|
||||
public OperacionesLegacyService(AutosDbContext context, ILogger<OperacionesLegacyService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ejecuta el SP sp_inserta_operaciones para registrar un pago
|
||||
/// </summary>
|
||||
public async Task<bool> InsertarOperacionAsync(Operacion operacion)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Ejecutando sp_inserta_operaciones para operación: {Noperacion}", operacion.Noperacion);
|
||||
|
||||
// Preparar parámetros asegurando manejo de nulos
|
||||
var parameters = new[]
|
||||
{
|
||||
new SqlParameter("@fecha", operacion.Fecha ?? (object)DBNull.Value),
|
||||
new SqlParameter("@Motivo", operacion.Motivo ?? (object)DBNull.Value),
|
||||
new SqlParameter("@Moneda", operacion.Moneda ?? (object)DBNull.Value),
|
||||
new SqlParameter("@Direccionentrega", operacion.Direccionentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@Validaciondomicilio", operacion.Validaciondomicilio ?? (object)DBNull.Value),
|
||||
new SqlParameter("@codigopedido", operacion.Codigopedido ?? (object)DBNull.Value),
|
||||
new SqlParameter("@nombreentrega", operacion.Nombreentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@fechahora", operacion.Fechahora ?? (object)DBNull.Value),
|
||||
new SqlParameter("@telefonocomprador", operacion.Telefonocomprador ?? (object)DBNull.Value),
|
||||
new SqlParameter("@barrioentrega", operacion.Barrioentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@codautorizacion", operacion.Codautorizacion ?? (object)DBNull.Value),
|
||||
new SqlParameter("@paisentrega", operacion.Paisentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@cuotas", operacion.Cuotas ?? (object)DBNull.Value),
|
||||
new SqlParameter("@validafechanac", operacion.Validafechanac ?? (object)DBNull.Value),
|
||||
new SqlParameter("@validanrodoc", operacion.Validanrodoc ?? (object)DBNull.Value),
|
||||
new SqlParameter("@titular", operacion.Titular ?? (object)DBNull.Value),
|
||||
new SqlParameter("@pedido", operacion.Pedido ?? (object)DBNull.Value),
|
||||
new SqlParameter("@zipentrega", operacion.Zipentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@monto", operacion.Monto ?? (object)DBNull.Value),
|
||||
new SqlParameter("@tarjeta", operacion.Tarjeta ?? (object)DBNull.Value),
|
||||
new SqlParameter("@fechaentrega", operacion.Fechaentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@emailcomprador", operacion.Emailcomprador ?? (object)DBNull.Value),
|
||||
new SqlParameter("@validanropuerta", operacion.Validanropuerta ?? (object)DBNull.Value),
|
||||
new SqlParameter("@ciudadentrega", operacion.Ciudadentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@validatipodoc", operacion.Validatipodoc ?? (object)DBNull.Value),
|
||||
new SqlParameter("@noperacion", operacion.Noperacion ?? (object)DBNull.Value),
|
||||
new SqlParameter("@estadoentrega", operacion.Estadoentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@resultado", operacion.Resultado ?? (object)DBNull.Value),
|
||||
new SqlParameter("@mensajeentrega", operacion.Mensajeentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@precio", operacion.Precioneto ?? 0) // El SP espera int
|
||||
};
|
||||
|
||||
await _context.Database.ExecuteSqlRawAsync(
|
||||
"EXEC dbo.sp_inserta_operaciones @fecha, @Motivo, @Moneda, @Direccionentrega, " +
|
||||
"@Validaciondomicilio, @codigopedido, @nombreentrega, @fechahora, @telefonocomprador, " +
|
||||
"@barrioentrega, @codautorizacion, @paisentrega, @cuotas, @validafechanac, @validanrodoc, " +
|
||||
"@titular, @pedido, @zipentrega, @monto, @tarjeta, @fechaentrega, @emailcomprador, " +
|
||||
"@validanropuerta, @ciudadentrega, @validatipodoc, @noperacion, @estadoentrega, " +
|
||||
"@resultado, @mensajeentrega, @precio",
|
||||
parameters);
|
||||
|
||||
_logger.LogInformation("Operación registrada correctamente: {Noperacion}", operacion.Noperacion);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al insertar operación: {Noperacion}", operacion.Noperacion);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Operacion>> ObtenerOperacionesPorNumeroAsync(string noperacion)
|
||||
{
|
||||
return await _context.Operaciones
|
||||
.AsNoTracking()
|
||||
.Where(o => o.Noperacion == noperacion)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Operacion>> ObtenerOperacionesPorFechasAsync(DateTime fechaInicio, DateTime fechaFin)
|
||||
{
|
||||
return await _context.Operaciones
|
||||
.AsNoTracking()
|
||||
.Where(o => o.Fecha >= fechaInicio && o.Fecha <= fechaFin)
|
||||
.OrderByDescending(o => o.Fecha)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<MedioDePago>> ObtenerMediosDePagoAsync()
|
||||
{
|
||||
return await _context.MediosDePago
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using BC = BCrypt.Net.BCrypt;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Services;
|
||||
|
||||
public class PasswordService : IPasswordService
|
||||
{
|
||||
public string HashPassword(string password)
|
||||
{
|
||||
return BC.HashPassword(password);
|
||||
}
|
||||
|
||||
public bool VerifyPassword(string password, string hash, string? salt, bool isLegacy)
|
||||
{
|
||||
if (isLegacy)
|
||||
{
|
||||
return VerifyLegacyHash(password, hash, salt);
|
||||
}
|
||||
|
||||
return BC.Verify(password, hash);
|
||||
}
|
||||
|
||||
private bool VerifyLegacyHash(string password, string storedHash, string? salt)
|
||||
{
|
||||
// Lógica típica de ASP.NET Membership Provider (SHA1)
|
||||
// El formato común es Base64(SHA1(Salt + Password))
|
||||
if (string.IsNullOrEmpty(salt)) return false;
|
||||
|
||||
byte[] passwordBytes = Encoding.Unicode.GetBytes(password);
|
||||
byte[] saltBytes = Convert.FromBase64String(salt);
|
||||
byte[] allBytes = new byte[saltBytes.Length + passwordBytes.Length];
|
||||
|
||||
Buffer.BlockCopy(saltBytes, 0, allBytes, 0, saltBytes.Length);
|
||||
Buffer.BlockCopy(passwordBytes, 0, allBytes, saltBytes.Length, passwordBytes.Length);
|
||||
|
||||
using (var sha1 = SHA1.Create())
|
||||
{
|
||||
byte[] hashBytes = sha1.ComputeHash(allBytes);
|
||||
string computedHash = Convert.ToBase64String(hashBytes);
|
||||
return computedHash == storedHash;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using MotoresArgentinosV2.Core.Models;
|
||||
using System.Net.Security;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Services;
|
||||
|
||||
public class SmtpEmailService : IEmailService
|
||||
{
|
||||
private readonly MailSettings _mailSettings;
|
||||
private readonly ILogger<SmtpEmailService> _logger;
|
||||
|
||||
public SmtpEmailService(IOptions<MailSettings> mailSettings, ILogger<SmtpEmailService> logger)
|
||||
{
|
||||
_mailSettings = mailSettings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SendEmailAsync(string to, string subject, string htmlBody)
|
||||
{
|
||||
var email = new MimeMessage();
|
||||
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
|
||||
email.From.Add(email.Sender);
|
||||
email.To.Add(new MailboxAddress(to, to)); // Usamos el email como nombre si no lo tenemos
|
||||
email.Subject = subject;
|
||||
|
||||
var builder = new BodyBuilder { HtmlBody = htmlBody };
|
||||
email.Body = builder.ToMessageBody();
|
||||
|
||||
using var smtp = new SmtpClient();
|
||||
try
|
||||
{
|
||||
// Bypass de SSL para red interna / certificados autofirmados
|
||||
smtp.ServerCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true;
|
||||
|
||||
// Conectar
|
||||
// SecureSocketOptions.StartTls es lo estándar para puerto 587
|
||||
// Si el servidor es muy viejo y no soporta TLS, usa SecureSocketOptions.None
|
||||
await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls);
|
||||
|
||||
// Autenticar
|
||||
if (!string.IsNullOrEmpty(_mailSettings.SmtpUser))
|
||||
{
|
||||
await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass);
|
||||
}
|
||||
|
||||
// Enviar
|
||||
await smtp.SendAsync(email);
|
||||
|
||||
_logger.LogInformation("Email enviado exitosamente a {To}", to);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error crítico enviando email a {To} mediante MailKit", to);
|
||||
// Re-lanzar para que el IdentityService sepa que falló
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (smtp.IsConnected)
|
||||
{
|
||||
await smtp.DisconnectAsync(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using OtpNet;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Services;
|
||||
|
||||
public class TokenService : ITokenService
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public TokenService(IConfiguration config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public string GenerateJwtToken(User user)
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var key = Encoding.ASCII.GetBytes(_config["Jwt:Key"] ?? "SUPER_SECRET_KEY_FOR_MOTORES_ARGENTINOS_V2_2026");
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.UserID.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.UserName),
|
||||
new Claim(ClaimTypes.Email, user.Email),
|
||||
new Claim(ClaimTypes.Role, user.UserType == 3 ? "Admin" : "User")
|
||||
}),
|
||||
Expires = DateTime.UtcNow.AddHours(8),
|
||||
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature),
|
||||
Issuer = _config["Jwt:Issuer"],
|
||||
Audience = _config["Jwt:Audience"]
|
||||
};
|
||||
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
return tokenHandler.WriteToken(token);
|
||||
}
|
||||
|
||||
public RefreshToken GenerateRefreshToken(string ipAddress)
|
||||
{
|
||||
var refreshToken = new RefreshToken
|
||||
{
|
||||
Token = Convert.ToHexString(RandomNumberGenerator.GetBytes(64)),
|
||||
Expires = DateTime.UtcNow.AddDays(7), // Dura 7 días
|
||||
Created = DateTime.UtcNow,
|
||||
CreatedByIp = ipAddress
|
||||
};
|
||||
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
public string GenerateMFACode()
|
||||
{
|
||||
var random = new Random();
|
||||
return random.Next(100000, 999999).ToString();
|
||||
}
|
||||
|
||||
public string GenerateBase32Secret()
|
||||
{
|
||||
var key = KeyGeneration.GenerateRandomKey(20);
|
||||
return Base32Encoding.ToString(key);
|
||||
}
|
||||
|
||||
public string GetQrCodeUri(string userEmail, string secret)
|
||||
{
|
||||
return $"otpauth://totp/MotoresV2:{userEmail}?secret={secret}&issuer=MotoresArgentinosV2";
|
||||
}
|
||||
|
||||
public bool ValidateTOTP(string secret, string code)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = Base32Encoding.ToBytes(secret);
|
||||
var totp = new Totp(bytes);
|
||||
return totp.VerifyTotp(code, out _, new VerificationWindow(1, 1));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using MotoresArgentinosV2.Infrastructure.Data;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Services;
|
||||
|
||||
public class UsuariosLegacyService : IUsuariosLegacyService
|
||||
{
|
||||
private readonly InternetDbContext _context;
|
||||
private readonly ILogger<UsuariosLegacyService> _logger;
|
||||
|
||||
public UsuariosLegacyService(InternetDbContext context, ILogger<UsuariosLegacyService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<UsuarioLegacyDto?> ObtenerParticularPorUsuarioAsync(string nombreUsuario)
|
||||
{
|
||||
try
|
||||
{
|
||||
var paramUsuario = new SqlParameter("@usuario_nom", nombreUsuario);
|
||||
|
||||
// Usamos SqlQueryRaw para mapear a DTO directamente (EF Core feature moderna)
|
||||
// Nota: Si las columnas no coinciden exactamente, EF no llenará las propiedades.
|
||||
// Para robustez en legacy, a veces conviene un mapeo manual si los nombres de columna son muy crípticos.
|
||||
var resultado = await _context.Database
|
||||
.SqlQueryRaw<UsuarioLegacyDto>("EXEC dbo.sp_VerDatosUsuario @usuario_nom", paramUsuario)
|
||||
.ToListAsync();
|
||||
|
||||
return resultado.FirstOrDefault();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener datos de particular legacy para usuario: {Usuario}", nombreUsuario);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AgenciaLegacyDto?> ObtenerAgenciaPorUsuarioAsync(string nombreUsuario)
|
||||
{
|
||||
try
|
||||
{
|
||||
var paramUsuario = new SqlParameter("@usuario_nom", nombreUsuario);
|
||||
|
||||
var resultado = await _context.Database
|
||||
.SqlQueryRaw<AgenciaLegacyDto>("EXEC dbo.sp_VerDatosAgencia @usuario_nom", paramUsuario)
|
||||
.ToListAsync();
|
||||
|
||||
return resultado.FirstOrDefault();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener datos de agencia legacy para usuario: {Usuario}", nombreUsuario);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
386
Backend/MotoresArgentinosV2.MigrationTool/AdMigrator.cs
Normal file
386
Backend/MotoresArgentinosV2.MigrationTool/AdMigrator.cs
Normal file
@@ -0,0 +1,386 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using MotoresArgentinosV2.MigrationTool.Models;
|
||||
|
||||
namespace MotoresArgentinosV2.MigrationTool;
|
||||
|
||||
public class AdMigrator
|
||||
{
|
||||
private readonly string _connStringAutos;
|
||||
private readonly string _connStringV2;
|
||||
|
||||
public AdMigrator(string connAutos, string connV2)
|
||||
{
|
||||
_connStringAutos = connAutos;
|
||||
_connStringV2 = connV2;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
Console.WriteLine("🚗 INICIANDO MIGRACIÓN DE AUTOS...");
|
||||
|
||||
using var dbAutos = new SqlConnection(_connStringAutos);
|
||||
using var dbV2 = new SqlConnection(_connStringV2);
|
||||
|
||||
// 1. CARGAR CACHÉS DE V2
|
||||
Console.WriteLine(" 📥 Cargando diccionarios de Marcas, Modelos y Usuarios...");
|
||||
|
||||
var brandsV2 = (await dbV2.QueryAsync("SELECT BrandID, LegacyID, Name FROM Brands WHERE VehicleTypeID = 1")).ToList();
|
||||
var usersV2 = (await dbV2.QueryAsync("SELECT UserID, Email, PhoneNumber FROM Users")).ToDictionary(u => (string)u.Email, u => u);
|
||||
|
||||
// 2. OBTENER AVISOS LEGACY (QUERY MEJORADA PARA ADMINS)
|
||||
Console.WriteLine(" 📥 Leyendo avisos activos del Legacy...");
|
||||
|
||||
// Esta query intenta obtener el email de dos fuentes:
|
||||
// 1. De la tabla Particulares (prioridad)
|
||||
// 2. De la tabla de autenticación (aspnet_Membership) si no está en Particulares
|
||||
var queryAds = @"
|
||||
SELECT
|
||||
A.Auto_Id,
|
||||
A.Auto_Fecha_Publicacion,
|
||||
A.Auto_Cant_Visitas,
|
||||
COALESCE(P.Part_Mail, Mem.Email) as EmailVendedor,
|
||||
M.Marca_Detalle,
|
||||
Mo.Modelo_Detalle,
|
||||
A.Auto_Version,
|
||||
An.Año_Detalle,
|
||||
A.Auto_precio,
|
||||
Mon.Mone_Detalle,
|
||||
A.Auto_Kilometros,
|
||||
A.Auto_Detalle as Descripcion,
|
||||
C.Comb_Detalle,
|
||||
Col.Color_detalle,
|
||||
Car.Carroceria_Nombre,
|
||||
CASE WHEN D.DetAut_PapelesALDia = 1 THEN 1 ELSE 0 END as PapelesAlDia,
|
||||
COALESCE(P.Part_Telefono, '') as Part_Telefono,
|
||||
COALESCE(P.Part_Celular, '') as Part_Celular
|
||||
FROM Autos A
|
||||
-- Join original con particulares
|
||||
LEFT JOIN Particulares P ON A.Auto_Usuario_Id = P.Part_Usu_Nombre
|
||||
-- Join adicional para rescatar usuarios admin/internos sin perfil particular
|
||||
LEFT JOIN aspnet_Users U ON A.Auto_Usuario_Id = U.UserName
|
||||
LEFT JOIN aspnet_Membership Mem ON U.UserId = Mem.UserId
|
||||
|
||||
-- Joins de datos técnicos
|
||||
LEFT JOIN Marca M ON A.Auto_Marca_Id = M.Marca_Id
|
||||
LEFT JOIN Modelo Mo ON A.Auto_Modelo_Id = Mo.Modelo_Id
|
||||
LEFT JOIN Año An ON A.Auto_Año = An.Año_Id
|
||||
LEFT JOIN Combustible C ON A.Auto_Comb_Id = C.Comb_Id
|
||||
LEFT JOIN Color Col ON A.Auto_Color = Col.Color_Id
|
||||
LEFT JOIN Carroceria Car ON A.Auto_Carroceria_Id = Car.Carroceria_Id
|
||||
LEFT JOIN Moneda Mon ON A.Auto_Moneda_Id = Mon.Mone_id
|
||||
LEFT JOIN Detalle_Auto D ON A.Auto_Id = D.DetAut_Auto_Id
|
||||
|
||||
WHERE A.Auto_Estado = 0
|
||||
AND A.Auto_Tipo_Id = 1
|
||||
AND A.Auto_Fecha_Finalizacion >= GETDATE()";
|
||||
|
||||
var adsLegacy = (await dbAutos.QueryAsync<LegacyAdData>(queryAds)).ToList();
|
||||
Console.WriteLine($" ✅ Encontrados {adsLegacy.Count} avisos candidatos.");
|
||||
|
||||
// 3. OBTENER FOTOS
|
||||
var idsString = string.Join(",", adsLegacy.Select(a => a.Auto_Id));
|
||||
var photosLegacy = new List<LegacyAdPhoto>();
|
||||
if (adsLegacy.Any())
|
||||
{
|
||||
photosLegacy = (await dbAutos.QueryAsync<LegacyAdPhoto>(
|
||||
$"SELECT Foto_Id, Foto_Auto_Id, Foto_Ruta FROM Fotos_Autos WHERE Foto_Auto_Id IN ({idsString})")).ToList();
|
||||
}
|
||||
|
||||
int migrados = 0;
|
||||
int omitidosUsuario = 0;
|
||||
|
||||
foreach (var ad in adsLegacy)
|
||||
{
|
||||
// A. VALIDAR USUARIO
|
||||
string emailKey = ad.EmailVendedor?.ToLower().Trim() ?? "";
|
||||
|
||||
if (!usersV2.TryGetValue(emailKey, out var userV2))
|
||||
{
|
||||
omitidosUsuario++;
|
||||
// Console.WriteLine($" ⚠️ Aviso {ad.Auto_Id} omitido: Usuario {emailKey} no existe en V2.");
|
||||
continue;
|
||||
}
|
||||
|
||||
// B. RESOLVER MARCA Y MODELO
|
||||
var brandV2 = brandsV2.FirstOrDefault(b => b.Name.ToLower() == (ad.Marca_Detalle ?? "").ToLower());
|
||||
if (brandV2 == null)
|
||||
{
|
||||
// Si la marca no existe, saltamos por seguridad o podríamos asignar "Otros"
|
||||
continue;
|
||||
}
|
||||
|
||||
int modelId = await GetOrCreateModelAsync(dbV2, (int)brandV2.BrandID, ad.Modelo_Detalle);
|
||||
|
||||
// C. CONSTRUIR DATOS
|
||||
int.TryParse(ad.Año_Detalle, out int year);
|
||||
|
||||
string versionName = $"{ad.Modelo_Detalle} {ad.Auto_Version}".Trim();
|
||||
if (versionName.Length > 100) versionName = versionName.Substring(0, 100);
|
||||
|
||||
string desc = ad.Descripcion ?? "";
|
||||
if (ad.PapelesAlDia == 1) desc += "\n\n✔️ Papeles al día.";
|
||||
|
||||
// Contacto: Si venía de Particulares, usamos eso. Si no (admins), usamos el del usuario V2
|
||||
string contactPhone = !string.IsNullOrWhiteSpace(ad.Part_Celular) ? ad.Part_Celular : ad.Part_Telefono;
|
||||
if (string.IsNullOrWhiteSpace(contactPhone)) contactPhone = userV2.PhoneNumber;
|
||||
|
||||
string currency = (ad.Mone_Detalle != null && ad.Mone_Detalle.Contains("US")) ? "USD" : "ARS";
|
||||
|
||||
// D. INSERTAR AVISO
|
||||
var insertAdSql = @"
|
||||
INSERT INTO Ads (
|
||||
UserID, VehicleTypeID, BrandID, ModelID, VersionName, Year, KM,
|
||||
Price, Currency, Description, IsFeatured, StatusID, CreatedAt, PublishedAt, ExpiresAt,
|
||||
FuelType, Color, Segment, Location, Condition, Transmission, Steering,
|
||||
ContactPhone, ContactEmail, DisplayContactInfo, LegacyAdID,
|
||||
ViewsCounter
|
||||
) VALUES (
|
||||
@UserID, @VehicleTypeID, @BrandID, @ModelID, @VersionName, @Year, @KM,
|
||||
@Price, @Currency, @Description, 0, 4, @CreatedAt, @PublishedAt, DATEADD(day, 30, @PublishedAt),
|
||||
@FuelType, @Color, @Segment, 'La Plata', 'No Especificado', 'No Especificado', 'No Especificado',
|
||||
@ContactPhone, @ContactEmail, 1, @LegacyAdID,
|
||||
@ViewsCounter
|
||||
);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
int newAdId = await dbV2.ExecuteScalarAsync<int>(insertAdSql, new
|
||||
{
|
||||
UserID = userV2.UserID,
|
||||
VehicleTypeID = 1,
|
||||
BrandID = brandV2.BrandID,
|
||||
ModelID = modelId,
|
||||
VersionName = versionName,
|
||||
Year = year,
|
||||
KM = ad.Auto_Kilometros,
|
||||
Price = ad.Auto_precio,
|
||||
Currency = currency,
|
||||
Description = desc,
|
||||
FuelType = ad.Comb_Detalle,
|
||||
Color = ad.Color_detalle,
|
||||
Segment = ad.Carroceria_Nombre,
|
||||
ContactPhone = contactPhone,
|
||||
ContactEmail = userV2.Email,
|
||||
LegacyAdID = ad.Auto_Id,
|
||||
ViewsCounter = ad.Auto_Cant_Visitas,
|
||||
CreatedAt = ad.Auto_Fecha_Publicacion,
|
||||
PublishedAt = ad.Auto_Fecha_Publicacion
|
||||
});
|
||||
|
||||
// E. INSERTAR FOTOS
|
||||
var misFotos = photosLegacy
|
||||
.Where(p => p.Foto_Auto_Id == ad.Auto_Id && !p.Foto_Ruta.Contains("nofoto"))
|
||||
.ToList();
|
||||
|
||||
int order = 0;
|
||||
foreach (var foto in misFotos)
|
||||
{
|
||||
string fileName = Path.GetFileName(foto.Foto_Ruta);
|
||||
// Aquí podrías agregar la lógica para copiar el archivo físico si tienes acceso
|
||||
// Por ahora solo guardamos la referencia en DB
|
||||
string v2Path = $"/uploads/legacy/{fileName}";
|
||||
|
||||
await dbV2.ExecuteAsync(@"
|
||||
INSERT INTO AdPhotos (AdID, FilePath, IsCover, SortOrder)
|
||||
VALUES (@AdID, @FilePath, @IsCover, @SortOrder)",
|
||||
new { AdID = newAdId, FilePath = v2Path, IsCover = (order == 0), SortOrder = order });
|
||||
|
||||
order++;
|
||||
}
|
||||
|
||||
migrados++;
|
||||
}
|
||||
|
||||
Console.WriteLine("==================================================");
|
||||
Console.WriteLine($"🏁 MIGRACIÓN DE AUTOS FINALIZADA");
|
||||
Console.WriteLine($"✅ Insertados: {migrados}");
|
||||
Console.WriteLine($"⏩ Omitidos (Sin usuario/marca): {omitidosUsuario}");
|
||||
Console.WriteLine("==================================================");
|
||||
}
|
||||
|
||||
public async Task MigrateMotosAsync()
|
||||
{
|
||||
Console.WriteLine("\n🏍️ INICIANDO MIGRACIÓN DE MOTOS...");
|
||||
|
||||
using var dbAutos = new SqlConnection(_connStringAutos);
|
||||
using var dbV2 = new SqlConnection(_connStringV2);
|
||||
|
||||
// 1. CARGAR CACHÉS (Solo Marcas de Motos)
|
||||
Console.WriteLine(" 📥 Cargando diccionarios...");
|
||||
var brandsV2 = (await dbV2.QueryAsync("SELECT BrandID, LegacyID, Name FROM Brands WHERE VehicleTypeID = 2")).ToList(); // 2 = Motos
|
||||
var usersV2 = (await dbV2.QueryAsync("SELECT UserID, Email, PhoneNumber FROM Users")).ToDictionary(u => (string)u.Email, u => u);
|
||||
|
||||
// 2. QUERY MOTOS
|
||||
var query = @"
|
||||
SELECT
|
||||
M.Moto_Id,
|
||||
M.Moto_Fecha_Publicacion,
|
||||
M.Moto_Cant_Visitas,
|
||||
COALESCE(P.Part_Mail, Mem.Email) as EmailVendedor,
|
||||
|
||||
Ma.Marca_Moto_Detalle as Marca_Detalle,
|
||||
Mo.Modelo_Moto_Detalle as Modelo_Detalle,
|
||||
|
||||
An.Año_Detalle,
|
||||
M.Moto_Precio,
|
||||
Mon.Mone_Detalle,
|
||||
M.Moto_Kilometraje,
|
||||
M.Moto_Detalle as Descripcion,
|
||||
M.Moto_Cilindrada,
|
||||
M.Moto_Tipo_Cuatri_Id,
|
||||
|
||||
COALESCE(P.Part_Telefono, '') as Part_Telefono,
|
||||
COALESCE(P.Part_Celular, '') as Part_Celular
|
||||
FROM Motos M
|
||||
LEFT JOIN Particulares P ON M.Moto_Usuario_Id = P.Part_Usu_Nombre
|
||||
LEFT JOIN aspnet_Users U ON M.Moto_Usuario_Id = U.UserName
|
||||
LEFT JOIN aspnet_Membership Mem ON U.UserId = Mem.UserId
|
||||
|
||||
-- JOINS TÉCNICOS
|
||||
LEFT JOIN Marca_Moto Ma ON M.Moto_Marca_Id = Ma.Marca_Moto_Id
|
||||
LEFT JOIN Modelo_Moto Mo ON M.Moto_Modelo_Id = Mo.Modelo_Moto_Id
|
||||
LEFT JOIN Año An ON M.Moto_Año = An.Año_Id
|
||||
LEFT JOIN Moneda Mon ON M.Moto_Moneda_Id = Mon.Mone_id
|
||||
|
||||
WHERE M.Moto_Estado = 0
|
||||
AND M.Moto_Fecha_Finalizacion >= GETDATE()";
|
||||
|
||||
var adsLegacy = (await dbAutos.QueryAsync<LegacyMotoData>(query)).ToList();
|
||||
Console.WriteLine($" ✅ Encontradas {adsLegacy.Count} motos candidatas.");
|
||||
|
||||
// 3. FOTOS
|
||||
var idsString = string.Join(",", adsLegacy.Select(a => a.Moto_Id));
|
||||
var photosLegacy = new List<LegacyMotoPhoto>();
|
||||
if (adsLegacy.Any())
|
||||
{
|
||||
photosLegacy = (await dbAutos.QueryAsync<LegacyMotoPhoto>(
|
||||
$"SELECT Foto_Id, Foto_Moto_Id, Foto_Ruta FROM Fotos_Motos WHERE Foto_Moto_Id IN ({idsString})")).ToList();
|
||||
}
|
||||
|
||||
int migrados = 0;
|
||||
int omitidos = 0;
|
||||
|
||||
foreach (var ad in adsLegacy)
|
||||
{
|
||||
// A. Validar Usuario
|
||||
string emailKey = ad.EmailVendedor?.ToLower().Trim() ?? "";
|
||||
if (!usersV2.TryGetValue(emailKey, out var userV2))
|
||||
{
|
||||
omitidos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// B. Resolver Marca y Modelo
|
||||
var brandV2 = brandsV2.FirstOrDefault(b => b.Name.ToLower() == (ad.Marca_Detalle ?? "").ToLower());
|
||||
if (brandV2 == null) continue; // Marca no mapeada, saltar
|
||||
|
||||
int modelId = await GetOrCreateModelAsync(dbV2, (int)brandV2.BrandID, ad.Modelo_Detalle);
|
||||
|
||||
// C. Datos
|
||||
int.TryParse(ad.Año_Detalle, out int year);
|
||||
string currency = (ad.Mone_Detalle != null && ad.Mone_Detalle.Contains("US")) ? "USD" : "ARS";
|
||||
|
||||
// Nombre Versión: Concatenamos Modelo + Cilindrada si existe
|
||||
string versionName = ad.Modelo_Detalle;
|
||||
// La cilindrada la ponemos en la descripción como pediste, pero a veces queda bien en el titulo.
|
||||
// Lo dejamos en el título solo si es muy corto, si no, description.
|
||||
|
||||
// Descripción: Concatenar Cilindrada
|
||||
string desc = ad.Descripcion ?? "";
|
||||
if (!string.IsNullOrEmpty(ad.Moto_Cilindrada) && ad.Moto_Cilindrada != "0")
|
||||
{
|
||||
desc += $"\n\nCilindrada: {ad.Moto_Cilindrada}cc";
|
||||
}
|
||||
|
||||
// Segmento: Lógica de Cuatriciclo
|
||||
string segment = ad.Moto_Tipo_Cuatri_Id > 0 ? "Cuatriciclo" : "No Especificado";
|
||||
|
||||
// Contacto
|
||||
string contactPhone = !string.IsNullOrWhiteSpace(ad.Part_Celular) ? ad.Part_Celular : ad.Part_Telefono;
|
||||
if (string.IsNullOrWhiteSpace(contactPhone)) contactPhone = userV2.PhoneNumber;
|
||||
|
||||
// D. Insertar
|
||||
var insertSql = @"
|
||||
INSERT INTO Ads (
|
||||
UserID, VehicleTypeID, BrandID, ModelID, VersionName, Year, KM,
|
||||
Price, Currency, Description, IsFeatured, StatusID, CreatedAt, PublishedAt, ExpiresAt,
|
||||
FuelType, Color, Segment, Location, Condition, Transmission, Steering,
|
||||
ContactPhone, ContactEmail, DisplayContactInfo, LegacyAdID, ViewsCounter
|
||||
) VALUES (
|
||||
@UserID, 2, @BrandID, @ModelID, @VersionName, @Year, @KM,
|
||||
@Price, @Currency, @Description, 0, 4, @CreatedAt, @PublishedAt, DATEADD(day, 30, @PublishedAt),
|
||||
'Nafta', 'No Especificado', @Segment, 'La Plata', 'No Especificado', 'Manual', 'No Especificado',
|
||||
@ContactPhone, @ContactEmail, 1, @LegacyAdID, @ViewsCounter
|
||||
);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
int newAdId = await dbV2.ExecuteScalarAsync<int>(insertSql, new
|
||||
{
|
||||
UserID = userV2.UserID,
|
||||
BrandID = brandV2.BrandID,
|
||||
ModelID = modelId,
|
||||
VersionName = versionName,
|
||||
Year = year,
|
||||
KM = ad.Moto_Kilometraje,
|
||||
Price = ad.Moto_Precio,
|
||||
Currency = currency,
|
||||
Description = desc,
|
||||
Segment = segment,
|
||||
ContactPhone = contactPhone,
|
||||
ContactEmail = userV2.Email,
|
||||
LegacyAdID = ad.Moto_Id,
|
||||
ViewsCounter = ad.Moto_Cant_Visitas,
|
||||
CreatedAt = ad.Moto_Fecha_Publicacion,
|
||||
PublishedAt = ad.Moto_Fecha_Publicacion
|
||||
});
|
||||
|
||||
// E. Fotos
|
||||
var misFotos = photosLegacy
|
||||
.Where(p => p.Foto_Moto_Id == ad.Moto_Id && !p.Foto_Ruta.Contains("nofoto"))
|
||||
.ToList();
|
||||
|
||||
int order = 0;
|
||||
foreach (var foto in misFotos)
|
||||
{
|
||||
string fileName = Path.GetFileName(foto.Foto_Ruta);
|
||||
string v2Path = $"/uploads/legacy/{fileName}";
|
||||
|
||||
await dbV2.ExecuteAsync(@"
|
||||
INSERT INTO AdPhotos (AdID, FilePath, IsCover, SortOrder)
|
||||
VALUES (@AdID, @FilePath, @IsCover, @SortOrder)",
|
||||
new { AdID = newAdId, FilePath = v2Path, IsCover = (order == 0), SortOrder = order });
|
||||
|
||||
order++;
|
||||
}
|
||||
|
||||
migrados++;
|
||||
}
|
||||
|
||||
Console.WriteLine("==================================================");
|
||||
Console.WriteLine($"🏁 MIGRACIÓN MOTOS FINALIZADA: {migrados} insertados.");
|
||||
Console.WriteLine("==================================================");
|
||||
}
|
||||
|
||||
private async Task<int> GetOrCreateModelAsync(SqlConnection db, int brandId, string modelName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(modelName)) modelName = "Modelo Genérico";
|
||||
|
||||
var existing = await db.QueryFirstOrDefaultAsync<int?>(
|
||||
"SELECT ModelID FROM Models WHERE BrandID = @brandId AND Name = @modelName",
|
||||
new { brandId, modelName });
|
||||
|
||||
if (existing.HasValue) return existing.Value;
|
||||
|
||||
var newId = await db.ExecuteScalarAsync<int>(
|
||||
"INSERT INTO Models (BrandID, Name) VALUES (@brandId, @modelName); SELECT CAST(SCOPE_IDENTITY() as int);",
|
||||
new { brandId, modelName });
|
||||
|
||||
return newId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace MotoresArgentinosV2.MigrationTool.Models;
|
||||
|
||||
public class LegacyAdData
|
||||
{
|
||||
public int Auto_Id { get; set; }
|
||||
public string EmailVendedor { get; set; } = string.Empty;
|
||||
public string Marca_Detalle { get; set; } = string.Empty;
|
||||
public string Modelo_Detalle { get; set; } = string.Empty;
|
||||
public string Auto_Version { get; set; } = string.Empty;
|
||||
public string Año_Detalle { get; set; } = string.Empty;
|
||||
public decimal Auto_precio { get; set; }
|
||||
public string Mone_Detalle { get; set; } = string.Empty;
|
||||
public int Auto_Kilometros { get; set; }
|
||||
public string Descripcion { get; set; } = string.Empty;
|
||||
public string Comb_Detalle { get; set; } = string.Empty;
|
||||
public string Color_detalle { get; set; } = string.Empty;
|
||||
public string Carroceria_Nombre { get; set; } = string.Empty;
|
||||
public int PapelesAlDia { get; set; }
|
||||
public string Part_Telefono { get; set; } = string.Empty;
|
||||
public string Part_Celular { get; set; } = string.Empty;
|
||||
public int Auto_Cant_Visitas { get; set; }
|
||||
public DateTime Auto_Fecha_Publicacion { get; set; }
|
||||
}
|
||||
|
||||
public class LegacyAdPhoto
|
||||
{
|
||||
public int Foto_Id { get; set; }
|
||||
public int Foto_Auto_Id { get; set; }
|
||||
public string Foto_Ruta { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Backend/MotoresArgentinosV2.MigrationTool/Models/LegacyMotoData.cs
|
||||
|
||||
namespace MotoresArgentinosV2.MigrationTool.Models;
|
||||
|
||||
public class LegacyMotoData
|
||||
{
|
||||
public int Moto_Id { get; set; }
|
||||
public DateTime Moto_Fecha_Publicacion { get; set; }
|
||||
public int Moto_Cant_Visitas { get; set; }
|
||||
public string EmailVendedor { get; set; } = string.Empty;
|
||||
|
||||
public string Marca_Detalle { get; set; } = string.Empty;
|
||||
public string Modelo_Detalle { get; set; } = string.Empty;
|
||||
public string Año_Detalle { get; set; } = string.Empty;
|
||||
|
||||
public decimal Moto_Precio { get; set; }
|
||||
public string Mone_Detalle { get; set; } = string.Empty;
|
||||
public int Moto_Kilometraje { get; set; }
|
||||
public string Descripcion { get; set; } = string.Empty;
|
||||
|
||||
public string Moto_Cilindrada { get; set; } = string.Empty;
|
||||
public int Moto_Tipo_Cuatri_Id { get; set; } // > 0 es Cuatriciclo
|
||||
|
||||
public string Part_Telefono { get; set; } = string.Empty;
|
||||
public string Part_Celular { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class LegacyMotoPhoto
|
||||
{
|
||||
public int Foto_Id { get; set; }
|
||||
public int Foto_Moto_Id { get; set; }
|
||||
public string Foto_Ruta { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
211
Backend/MotoresArgentinosV2.MigrationTool/Program.cs
Normal file
211
Backend/MotoresArgentinosV2.MigrationTool/Program.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Dapper;
|
||||
|
||||
namespace MotoresArgentinosV2.MigrationTool;
|
||||
|
||||
class Program
|
||||
{
|
||||
// --- CONFIGURACIÓN ---
|
||||
public const string ConnectionStringAutos = "Server=localhost;Database=autos;Trusted_Connection=True;TrustServerCertificate=True;";
|
||||
public const string ConnectionStringV2 = "Server=localhost;Database=MotoresV2;Trusted_Connection=True;TrustServerCertificate=True;";
|
||||
|
||||
// Fecha de corte para usuarios inactivos
|
||||
static readonly DateTime FechaCorte = new DateTime(2021, 01, 01);
|
||||
|
||||
static async Task Main(string[] args)
|
||||
{
|
||||
Console.OutputEncoding = Encoding.UTF8;
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Clear();
|
||||
Console.WriteLine("==================================================");
|
||||
Console.WriteLine("🚀 HERRAMIENTA DE MIGRACIÓN MOTORES V2");
|
||||
Console.WriteLine("==================================================");
|
||||
Console.WriteLine("1. Migrar USUARIOS (Desde 2021)");
|
||||
Console.WriteLine("2. Migrar AVISOS DE AUTOS (Activos)");
|
||||
Console.WriteLine("3. Migrar AVISOS DE MOTOS (Activos)");
|
||||
Console.WriteLine("4. Salir");
|
||||
Console.Write("\nSeleccione una opción: ");
|
||||
|
||||
var key = Console.ReadLine();
|
||||
|
||||
if (key == "1")
|
||||
{
|
||||
await RunUserMigration();
|
||||
Console.WriteLine("\nPresione Enter para volver...");
|
||||
Console.ReadLine();
|
||||
}
|
||||
else if (key == "2")
|
||||
{
|
||||
var adMigrator = new AdMigrator(ConnectionStringAutos, ConnectionStringV2);
|
||||
await adMigrator.ExecuteAsync();
|
||||
Console.WriteLine("\nPresione Enter para volver...");
|
||||
Console.ReadLine();
|
||||
}
|
||||
else if (key == "3")
|
||||
{
|
||||
var adMigrator = new AdMigrator(ConnectionStringAutos, ConnectionStringV2);
|
||||
await adMigrator.MigrateMotosAsync();
|
||||
Console.WriteLine("\nPresione Enter para volver...");
|
||||
Console.ReadLine();
|
||||
}
|
||||
else if (key == "4")
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- LÓGICA DE MIGRACIÓN DE USUARIOS (Encapsulada) ---
|
||||
static async Task RunUserMigration()
|
||||
{
|
||||
Console.WriteLine("\n🔍 Iniciando migración de usuarios...");
|
||||
|
||||
try
|
||||
{
|
||||
using var dbAutos = new SqlConnection(ConnectionStringAutos);
|
||||
using var dbV2 = new SqlConnection(ConnectionStringV2);
|
||||
|
||||
var queryLegacy = @"
|
||||
SELECT
|
||||
u.UserName,
|
||||
m.Email,
|
||||
m.Password as PasswordHash,
|
||||
m.PasswordSalt,
|
||||
u.LastActivityDate,
|
||||
1 as UserType
|
||||
FROM aspnet_Users u
|
||||
INNER JOIN aspnet_Membership m ON u.UserId = m.UserId
|
||||
WHERE u.LastActivityDate >= @FechaCorte";
|
||||
|
||||
var usersLegacy = (await dbAutos.QueryAsync<UserLegacy>(queryLegacy, new { FechaCorte })).ToList();
|
||||
|
||||
Console.WriteLine($"✅ Encontrados {usersLegacy.Count} usuarios activos para procesar.");
|
||||
|
||||
var processedUsernames = new HashSet<string>();
|
||||
int migrados = 0;
|
||||
int saltados = 0;
|
||||
int passReset = 0;
|
||||
|
||||
foreach (var user in usersLegacy)
|
||||
{
|
||||
// A. NORMALIZACIÓN
|
||||
string cleanUsername = NormalizeUsername(user.UserName);
|
||||
if (cleanUsername.Contains("@"))
|
||||
{
|
||||
cleanUsername = cleanUsername.Split('@')[0];
|
||||
cleanUsername = NormalizeUsername(cleanUsername);
|
||||
}
|
||||
|
||||
string finalUsername = cleanUsername;
|
||||
int counter = 1;
|
||||
|
||||
while (processedUsernames.Contains(finalUsername) || await UserExistsInDb(dbV2, finalUsername))
|
||||
{
|
||||
finalUsername = $"{cleanUsername}{counter}";
|
||||
counter++;
|
||||
}
|
||||
|
||||
processedUsernames.Add(finalUsername);
|
||||
|
||||
// B. LÓGICA DE CONTRASEÑAS
|
||||
bool isPlainText = user.PasswordHash.Length < 20;
|
||||
string finalHash = user.PasswordHash;
|
||||
string? finalSalt = user.PasswordSalt;
|
||||
byte migrationStatus = 0;
|
||||
|
||||
if (isPlainText)
|
||||
{
|
||||
finalHash = "INVALID_PASSWORD_NEEDS_RESET";
|
||||
finalSalt = null;
|
||||
passReset++;
|
||||
}
|
||||
|
||||
// C. INSERCIÓN
|
||||
var existeEmail = await dbV2.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(1) FROM Users WHERE Email = @Email", new { user.Email });
|
||||
|
||||
if (existeEmail == 0)
|
||||
{
|
||||
var insertQuery = @"
|
||||
INSERT INTO Users (
|
||||
UserName, Email, PasswordHash, PasswordSalt, MigrationStatus,
|
||||
UserType, CreatedAt, IsMFAEnabled, FirstName, LastName
|
||||
)
|
||||
VALUES (
|
||||
@UserName, @Email, @PasswordHash, @PasswordSalt, @MigrationStatus,
|
||||
@UserType, @CreatedAt, 0, NULL, NULL
|
||||
)";
|
||||
|
||||
await dbV2.ExecuteAsync(insertQuery, new
|
||||
{
|
||||
UserName = finalUsername,
|
||||
user.Email,
|
||||
PasswordHash = finalHash,
|
||||
PasswordSalt = finalSalt,
|
||||
MigrationStatus = migrationStatus,
|
||||
user.UserType,
|
||||
CreatedAt = user.LastActivityDate
|
||||
});
|
||||
|
||||
migrados++;
|
||||
}
|
||||
else
|
||||
{
|
||||
saltados++;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"🏁 FIN: {migrados} migrados, {saltados} saltados (email duplicado).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"❌ ERROR: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<bool> UserExistsInDb(SqlConnection db, string username)
|
||||
{
|
||||
var count = await db.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(1) FROM Users WHERE UserName = @username", new { username });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
static string NormalizeUsername(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return "usuario_desconocido";
|
||||
text = text.ToLowerInvariant();
|
||||
var normalizedString = text.Normalize(NormalizationForm.FormD);
|
||||
var stringBuilder = new StringBuilder();
|
||||
|
||||
foreach (var c in normalizedString)
|
||||
{
|
||||
if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark)
|
||||
stringBuilder.Append(c);
|
||||
}
|
||||
|
||||
text = stringBuilder.ToString().Normalize(NormalizationForm.FormC);
|
||||
text = Regex.Replace(text, "[^a-z0-9]", "");
|
||||
if (string.IsNullOrEmpty(text)) return "usuario";
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
public class UserLegacy
|
||||
{
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
public string PasswordSalt { get; set; } = string.Empty;
|
||||
public DateTime LastActivityDate { get; set; }
|
||||
public int UserType { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user