Init Commit

This commit is contained in:
2026-01-29 13:43:44 -03:00
commit b9aa8478db
126 changed files with 20649 additions and 0 deletions

View File

@@ -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);
});
}
}

View File

@@ -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)");
});
}
}

View File

@@ -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 });
}
}

View File

@@ -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>

View File

@@ -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.
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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>();
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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
};
}
}

View File

@@ -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));
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}