Init Commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user