Feat Varios 3

This commit is contained in:
2026-01-06 10:34:06 -03:00
parent 0fa77e4a98
commit 9fa21ebec3
65 changed files with 2897 additions and 373 deletions

View File

@@ -116,9 +116,181 @@ BEGIN
);
END
";
// Ejecutar creación de tablas
// Ejecutar creación de tablas base
await connection.ExecuteAsync(schemaSql);
// --- MIGRACIONES (Schema Update) ---
var migrationSql = @"
-- Listings Columns
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'PublicationStartDate' AND Object_ID = Object_ID(N'Listings'))
ALTER TABLE Listings ADD PublicationStartDate DATETIME2 NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'ApprovedAt' AND Object_ID = Object_ID(N'Listings'))
ALTER TABLE Listings ADD ApprovedAt DATETIME2 NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'IsFeatured' AND Object_ID = Object_ID(N'Listings'))
ALTER TABLE Listings ADD IsFeatured BIT NOT NULL DEFAULT 0;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'FeaturedExpiry' AND Object_ID = Object_ID(N'Listings'))
ALTER TABLE Listings ADD FeaturedExpiry DATETIME2 NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'AllowContact' AND Object_ID = Object_ID(N'Listings'))
ALTER TABLE Listings ADD AllowContact BIT NOT NULL DEFAULT 1;
-- Users Columns (Security & Profiles)
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'BillingName' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD BillingName NVARCHAR(200) NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'BillingAddress' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD BillingAddress NVARCHAR(500) NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'BillingTaxId' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD BillingTaxId NVARCHAR(50) NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'BillingTaxType' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD BillingTaxType NVARCHAR(50) NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'ClientId' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD ClientId INT NULL;
-- Audit & Security Columns for Users
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'FailedLoginAttempts' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD FailedLoginAttempts INT NOT NULL DEFAULT 0;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'LockoutEnd' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD LockoutEnd DATETIME NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'MustChangePassword' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD MustChangePassword BIT NOT NULL DEFAULT 1;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'IsActive' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD IsActive BIT NOT NULL DEFAULT 1;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'LastLogin' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD LastLogin DATETIME NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'GoogleId' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD GoogleId NVARCHAR(255) NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'IsMfaEnabled' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD IsMfaEnabled BIT NOT NULL DEFAULT 0;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'MfaSecret' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD MfaSecret NVARCHAR(255) NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'Phone' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD Phone NVARCHAR(50) NULL;
-- Coupons Table
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Coupons')
BEGIN
CREATE TABLE Coupons (
Id INT IDENTITY(1,1) PRIMARY KEY,
Code NVARCHAR(50) NOT NULL,
DiscountType NVARCHAR(20) NOT NULL,
DiscountValue DECIMAL(18,2) NOT NULL,
ExpiryDate DATETIME2 NULL,
UsageCount INT NOT NULL DEFAULT 0,
MaxUsages INT NULL,
IsActive BIT NOT NULL DEFAULT 1,
CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE()
);
CREATE UNIQUE INDEX IX_Coupons_Code ON Coupons(Code);
-- Seed Default Coupon
INSERT INTO Coupons (Code, DiscountType, DiscountValue) VALUES ('WELCOME2025', 'Percentage', 10.00);
END
-- Listings CouponCode
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'CouponCode' AND Object_ID = Object_ID(N'Listings'))
ALTER TABLE Listings ADD CouponCode NVARCHAR(50) NULL;
-- Coupons MaxUsagesPerUser
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'MaxUsagesPerUser' AND Object_ID = Object_ID(N'Coupons'))
ALTER TABLE Coupons ADD MaxUsagesPerUser INT NULL;
-- Tabla de Notas/Mensajes de Avisos
IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'ListingNotes') AND type in (N'U'))
BEGIN
CREATE TABLE ListingNotes (
Id INT IDENTITY(1,1) PRIMARY KEY,
ListingId INT NOT NULL,
SenderId INT NOT NULL,
IsFromModerator BIT NOT NULL DEFAULT 1,
Message NVARCHAR(MAX) NOT NULL,
CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
IsRead BIT NOT NULL DEFAULT 0,
FOREIGN KEY (ListingId) REFERENCES Listings(Id) ON DELETE CASCADE,
FOREIGN KEY (SenderId) REFERENCES Users(Id)
);
END
ELSE
BEGIN
-- Migración gradual si la tabla ya existía con ModeratorId
IF EXISTS(SELECT * FROM sys.columns WHERE Name = N'ModeratorId' AND Object_ID = Object_ID(N'ListingNotes'))
BEGIN
EXEC sp_rename 'ListingNotes.ModeratorId', 'SenderId', 'COLUMN';
END
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'IsFromModerator' AND Object_ID = Object_ID(N'ListingNotes'))
BEGIN
ALTER TABLE ListingNotes ADD IsFromModerator BIT NOT NULL DEFAULT 1;
END
END
";
await connection.ExecuteAsync(migrationSql);
// --- MIGRACIÓN DE DATOS (Post-Schema Update) ---
var dataMigrationSql = @"
-- Data Migration: Clients to Users
IF EXISTS (SELECT * FROM sys.tables WHERE name = 'Clients')
BEGIN
-- Verificamos si la tabla Clients tiene la columna Phone para evitar errores
DECLARE @phoneColumnExists BIT = 0;
IF EXISTS(SELECT * FROM sys.columns WHERE Name = N'Phone' AND Object_ID = Object_ID(N'Clients'))
SET @phoneColumnExists = 1;
IF @phoneColumnExists = 1
BEGIN
INSERT INTO Users (Username, PasswordHash, Role, Email, BillingName, BillingTaxId, BillingAddress, Phone, MustChangePassword, IsActive)
SELECT
ISNULL(DniOrCuit, Name) as Username,
'N/A' as PasswordHash,
'Client' as Role,
Email,
Name as BillingName,
DniOrCuit as BillingTaxId,
Address as BillingAddress,
Phone,
0 as MustChangePassword,
1 as IsActive
FROM Clients
WHERE DniOrCuit NOT IN (SELECT BillingTaxId FROM Users WHERE BillingTaxId IS NOT NULL);
END
ELSE
BEGIN
INSERT INTO Users (Username, PasswordHash, Role, Email, BillingName, BillingTaxId, BillingAddress, MustChangePassword, IsActive)
SELECT
ISNULL(DniOrCuit, Name) as Username,
'N/A' as PasswordHash,
'Client' as Role,
Email,
Name as BillingName,
DniOrCuit as BillingTaxId,
Address as BillingAddress,
0 as MustChangePassword,
1 as IsActive
FROM Clients
WHERE DniOrCuit NOT IN (SELECT BillingTaxId FROM Users WHERE BillingTaxId IS NOT NULL);
END
-- 1. Actualizar Listings para que apunten a Users antes de romper nada (opcional pero recomendado si hay datos)
-- Solo lo hacemos para los que ya existen en Users por BillingTaxId
UPDATE l
SET l.ClientId = u.Id
FROM Listings l
JOIN Clients c ON l.ClientId = c.Id
JOIN Users u ON c.DniOrCuit = u.BillingTaxId;
-- 2. Eliminar todas las llaves foráneas que apuntan a Clients
DECLARE @sql NVARCHAR(MAX) = N'';
SELECT @sql += 'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(parent_object_id)) + '.' + QUOTENAME(OBJECT_NAME(parent_object_id)) +
' DROP CONSTRAINT ' + QUOTENAME(name) + ';'
FROM sys.foreign_keys
WHERE referenced_object_id = OBJECT_ID('Clients');
IF @sql <> '' EXEC sp_executesql @sql;
-- 3. Finalmente borrar la tabla
DROP TABLE Clients;
END
";
await connection.ExecuteAsync(dataMigrationSql);
// --- SEED DE DATOS (Usuario Admin) ---
var adminCount = await connection.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM Users WHERE Username = 'admin'");

View File

@@ -0,0 +1,69 @@
-- Run this script to update the database schema
-- Update Listings table
ALTER TABLE Listings ADD PublicationStartDate DATETIME2 NULL;
ALTER TABLE Listings ADD ApprovedAt DATETIME2 NULL;
ALTER TABLE Listings ADD IsFeatured BIT NOT NULL DEFAULT 0;
ALTER TABLE Listings ADD FeaturedExpiry DATETIME2 NULL;
ALTER TABLE Listings ADD AllowContact BIT NOT NULL DEFAULT 1;
-- Update Users table
ALTER TABLE Users ADD BillingName NVARCHAR(200) NULL;
ALTER TABLE Users ADD BillingAddress NVARCHAR(500) NULL;
ALTER TABLE Users ADD BillingTaxId NVARCHAR(50) NULL;
ALTER TABLE Users ADD BillingTaxType NVARCHAR(50) NULL;
ALTER TABLE Users ADD ClientId INT NULL;
ALTER TABLE Users ADD FailedLoginAttempts INT NOT NULL DEFAULT 0;
ALTER TABLE Users ADD LockoutEnd DATETIME NULL;
ALTER TABLE Users ADD MustChangePassword BIT NOT NULL DEFAULT 1;
ALTER TABLE Users ADD IsActive BIT NOT NULL DEFAULT 1;
ALTER TABLE Users ADD LastLogin DATETIME NULL;
ALTER TABLE Users ADD GoogleId NVARCHAR(255) NULL;
ALTER TABLE Users ADD IsMfaEnabled BIT NOT NULL DEFAULT 0;
ALTER TABLE Users ADD MfaSecret NVARCHAR(255) NULL;
ALTER TABLE Users ADD Phone NVARCHAR(50) NULL;
-- Create Coupons table
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='Coupons' and xtype='U')
BEGIN
CREATE TABLE Coupons (
Id INT IDENTITY(1,1) PRIMARY KEY,
Code NVARCHAR(50) NOT NULL,
DiscountType NVARCHAR(20) NOT NULL,
DiscountValue DECIMAL(18,2) NOT NULL,
ExpiryDate DATETIME2 NULL,
UsageCount INT NOT NULL DEFAULT 0,
MaxUsages INT NULL,
IsActive BIT NOT NULL DEFAULT 1,
CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE()
);
CREATE UNIQUE INDEX IX_Coupons_Code ON Coupons(Code);
END
-- Data Migration and Consolidation
-- Copy clients to users if they don't exist
INSERT INTO Users (Username, PasswordHash, Role, Email, BillingName, BillingTaxId, BillingAddress, Phone, MustChangePassword, IsActive)
SELECT
ISNULL(DniOrCuit, Name), 'N/A', 'Client', Email, Name, DniOrCuit, Address, Phone, 0, 1
FROM Clients
WHERE DniOrCuit NOT IN (SELECT BillingTaxId FROM Users WHERE BillingTaxId IS NOT NULL);
-- Update Listings to point to new User IDs
UPDATE l
SET l.ClientId = u.Id
FROM Listings l
JOIN Clients c ON l.ClientId = c.Id
JOIN Users u ON c.DniOrCuit = u.BillingTaxId;
-- Drop foreign keys referencing Clients
DECLARE @sql NVARCHAR(MAX) = N'';
SELECT @sql += 'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(parent_object_id)) + '.' + QUOTENAME(OBJECT_NAME(parent_object_id)) +
' DROP CONSTRAINT ' + QUOTENAME(name) + ';'
FROM sys.foreign_keys
WHERE referenced_object_id = OBJECT_ID('Clients');
IF @sql <> '' EXEC sp_executesql @sql;
-- DROP obsolete table
IF EXISTS (SELECT * FROM sysobjects WHERE name='Clients' and xtype='U')
DROP TABLE Clients;

View File

@@ -32,6 +32,8 @@ public static class DependencyInjection
services.AddScoped<ImageOptimizationService>();
services.AddScoped<IClaimRepository, ClaimRepository>();
services.AddScoped<NotificationRepository>();
services.AddScoped<ICouponRepository, CouponRepository>();
services.AddScoped<IListingNoteRepository, ListingNoteRepository>();
// Registro de MercadoPagoService configurado desde IConfiguration (appsettings o env vars)
services.AddScoped<MercadoPagoService>(sp =>

View File

@@ -37,4 +37,19 @@ public class AuditRepository
ORDER BY a.CreatedAt DESC";
return await conn.QueryAsync<AuditLog>(sql, new { UserId = userId, Limit = limit });
}
public async Task<IEnumerable<AuditLog>> GetFilteredLogsAsync(DateTime from, DateTime to, int? userId = null)
{
using var conn = _db.CreateConnection();
var sql = @"SELECT a.*, u.Username
FROM AuditLogs a
JOIN Users u ON a.UserId = u.Id
WHERE a.CreatedAt >= @From AND a.CreatedAt <= @To";
if (userId.HasValue) sql += " AND a.UserId = @UserId";
sql += " ORDER BY a.CreatedAt DESC";
return await conn.QueryAsync<AuditLog>(sql, new { From = from, To = to, UserId = userId });
}
}

View File

@@ -13,59 +13,63 @@ public class ClientRepository
_db = db;
}
// Búsqueda inteligente con protección de nulos
// Búsqueda inteligente redireccionada a Users
public async Task<IEnumerable<Client>> SearchAsync(string query)
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT TOP 10
Id,
ISNULL(Name, 'Sin Nombre') as Name,
ISNULL(DniOrCuit, '') as DniOrCuit,
Email, Phone, Address
FROM Clients
WHERE Name LIKE @Query OR DniOrCuit LIKE @Query
ORDER BY Name";
ISNULL(BillingName, Username) as Name,
ISNULL(BillingTaxId, '') as DniOrCuit,
Email, Phone, BillingAddress as Address
FROM Users
WHERE BillingName LIKE @Query OR BillingTaxId LIKE @Query OR Username LIKE @Query
ORDER BY BillingName";
return await conn.QueryAsync<Client>(sql, new { Query = $"%{query}%" });
}
// Asegurar existencia (Upsert)
// Asegurar existencia (Upsert en la tabla Users)
public async Task<int> EnsureClientExistsAsync(string name, string dni)
{
using var conn = _db.CreateConnection();
var existingId = await conn.ExecuteScalarAsync<int?>(
"SELECT Id FROM Clients WHERE DniOrCuit = @Dni", new { Dni = dni });
"SELECT Id FROM Users WHERE BillingTaxId = @Dni", new { Dni = dni });
if (existingId.HasValue)
{
await conn.ExecuteAsync("UPDATE Clients SET Name = @Name WHERE Id = @Id", new { Name = name, Id = existingId });
await conn.ExecuteAsync("UPDATE Users SET BillingName = @Name WHERE Id = @Id", new { Name = name, Id = existingId });
return existingId.Value;
}
else
{
// Si no existe, creamos un usuario con rol Cliente (sin password por ahora, es solo para gestión de mostrador)
var sql = @"
INSERT INTO Clients (Name, DniOrCuit) VALUES (@Name, @Dni);
INSERT INTO Users (Username, Role, BillingName, BillingTaxId, PasswordHash, MustChangePassword)
VALUES (@Username, 'Client', @Name, @Dni, 'N/A', 0);
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, new { Name = name, Dni = dni });
// El username será el DNI para asegurar unicidad si no hay otro dato
return await conn.QuerySingleAsync<int>(sql, new { Username = dni, Name = name, Dni = dni });
}
}
// Obtener todos con estadísticas (ISNULL agregado para seguridad)
// Obtener todos con estadísticas desde Users
public async Task<IEnumerable<dynamic>> GetAllWithStatsAsync()
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT
c.Id as id,
ISNULL(c.Name, 'Sin Nombre') as name,
ISNULL(c.DniOrCuit, 'S/D') as dniOrCuit,
ISNULL(c.Email, 'Sin correo') as email,
ISNULL(c.Phone, 'Sin teléfono') as phone,
(SELECT COUNT(1) FROM Listings l WHERE l.ClientId = c.Id) as totalAds,
ISNULL((SELECT SUM(AdFee) FROM Listings l WHERE l.ClientId = c.Id), 0) as totalSpent
FROM Clients c
ORDER BY c.Name";
u.Id as id,
ISNULL(u.BillingName, u.Username) as name,
ISNULL(u.BillingTaxId, 'S/D') as dniOrCuit,
ISNULL(u.Email, 'Sin correo') as email,
ISNULL(u.Phone, 'Sin teléfono') as phone,
(SELECT COUNT(1) FROM Listings l WHERE l.ClientId = u.Id) as totalAds,
ISNULL((SELECT SUM(AdFee) FROM Listings l WHERE l.ClientId = u.Id), 0) as totalSpent
FROM Users u
WHERE Role IN ('Client', 'User') -- Mostramos tanto clientes puros como usuarios web
ORDER BY name";
return await conn.QueryAsync(sql);
}
@@ -73,12 +77,12 @@ public class ClientRepository
{
using var conn = _db.CreateConnection();
var sql = @"
UPDATE Clients
SET Name = @Name,
DniOrCuit = @DniOrCuit,
UPDATE Users
SET BillingName = @Name,
BillingTaxId = @DniOrCuit,
Email = @Email,
Phone = @Phone,
Address = @Address
BillingAddress = @Address
WHERE Id = @Id";
await conn.ExecuteAsync(sql, client);
}
@@ -88,21 +92,23 @@ public class ClientRepository
using var conn = _db.CreateConnection();
var sql = @"
SELECT
c.Id, c.Name, c.DniOrCuit, c.Email, c.Phone, c.Address,
(SELECT COUNT(1) FROM Listings WHERE ClientId = c.Id) as TotalAds,
ISNULL((SELECT SUM(AdFee) FROM Listings WHERE ClientId = c.Id), 0) as TotalInvested,
(SELECT MAX(CreatedAt) FROM Listings WHERE ClientId = c.Id) as LastAdDate,
(SELECT COUNT(1) FROM Listings WHERE ClientId = c.Id AND Status = 'Published') as ActiveAds,
u.Id,
ISNULL(u.BillingName, u.Username) as Name,
u.BillingTaxId as DniOrCuit, u.Email, u.Phone, u.BillingAddress as Address,
(SELECT COUNT(1) FROM Listings WHERE ClientId = u.Id) as TotalAds,
ISNULL((SELECT SUM(AdFee) FROM Listings WHERE ClientId = u.Id), 0) as TotalInvested,
(SELECT MAX(CreatedAt) FROM Listings WHERE ClientId = u.Id) as LastAdDate,
(SELECT COUNT(1) FROM Listings WHERE ClientId = u.Id AND Status = 'Published') as ActiveAds,
ISNULL((
SELECT TOP 1 cat.Name
FROM Listings l
JOIN Categories cat ON l.CategoryId = cat.Id
WHERE l.ClientId = c.Id
WHERE l.ClientId = u.Id
GROUP BY cat.Name
ORDER BY COUNT(l.Id) DESC
), 'N/A') as PreferredCategory
FROM Clients c
WHERE c.Id = @Id";
FROM Users u
WHERE u.Id = @Id";
return await conn.QuerySingleOrDefaultAsync<dynamic>(sql, new { Id = clientId });
}

View File

@@ -0,0 +1,63 @@
using Dapper;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
public class CouponRepository : ICouponRepository
{
private readonly IDbConnectionFactory _connectionFactory;
public CouponRepository(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<IEnumerable<Coupon>> GetAllAsync()
{
using var conn = _connectionFactory.CreateConnection();
return await conn.QueryAsync<Coupon>("SELECT * FROM Coupons ORDER BY CreatedAt DESC");
}
public async Task<int> CreateAsync(Coupon coupon)
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
INSERT INTO Coupons (Code, DiscountType, DiscountValue, ExpiryDate, MaxUsages, MaxUsagesPerUser, IsActive, CreatedAt)
VALUES (@Code, @DiscountType, @DiscountValue, @ExpiryDate, @MaxUsages, @MaxUsagesPerUser, @IsActive, GETUTCDATE());
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, coupon);
}
public async Task DeleteAsync(int id)
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync("DELETE FROM Coupons WHERE Id = @Id", new { Id = id });
}
public async Task<Coupon?> GetByCodeAsync(string code)
{
using var conn = _connectionFactory.CreateConnection();
return await conn.QuerySingleOrDefaultAsync<Coupon>(
"SELECT * FROM Coupons WHERE Code = @Code AND IsActive = 1",
new { Code = code });
}
public async Task IncrementUsageAsync(int id)
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync(
"UPDATE Coupons SET UsageCount = UsageCount + 1 WHERE Id = @Id",
new { Id = id });
}
public async Task<int> CountUserUsageAsync(int userId, string couponCode)
{
using var conn = _connectionFactory.CreateConnection();
// Check listings for this user with this coupon code
// Note: CouponCode column was added to Listings
var sql = "SELECT COUNT(1) FROM Listings WHERE UserId = @UserId AND CouponCode = @CouponCode";
return await conn.ExecuteScalarAsync<int>(sql, new { UserId = userId, CouponCode = couponCode });
}
}

View File

@@ -0,0 +1,72 @@
using Dapper;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
public class ListingNoteRepository : IListingNoteRepository
{
private readonly IDbConnectionFactory _connectionFactory;
public ListingNoteRepository(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<int> CreateAsync(ListingNote note)
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
INSERT INTO ListingNotes (ListingId, SenderId, IsFromModerator, Message, CreatedAt, IsRead)
VALUES (@ListingId, @SenderId, @IsFromModerator, @Message, GETUTCDATE(), 0);
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, note);
}
public async Task<IEnumerable<ListingNote>> GetByListingIdAsync(int listingId)
{
using var conn = _connectionFactory.CreateConnection();
var sql = "SELECT * FROM ListingNotes WHERE ListingId = @ListingId ORDER BY CreatedAt ASC";
return await conn.QueryAsync<ListingNote>(sql, new { ListingId = listingId });
}
public async Task<IEnumerable<ListingNote>> GetByUserIdAsync(int userId)
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
SELECT ln.*
FROM ListingNotes ln
INNER JOIN Listings l ON ln.ListingId = l.Id
WHERE l.UserId = @UserId
ORDER BY ln.CreatedAt DESC";
return await conn.QueryAsync<ListingNote>(sql, new { UserId = userId });
}
public async Task MarkAsReadAsync(int noteId)
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync("UPDATE ListingNotes SET IsRead = 1 WHERE Id = @Id", new { Id = noteId });
}
public async Task<int> GetUnreadCountAsync(int userId, bool isForModerator)
{
using var conn = _connectionFactory.CreateConnection();
var sql = "";
if (isForModerator)
{
// Para moderadores: Mensajes que NO son de moderador y NO están leídos
sql = @"SELECT COUNT(*) FROM ListingNotes WHERE IsFromModerator = 0 AND IsRead = 0";
}
else
{
// Para usuarios: Mensajes que SON de moderador, para sus avisos, y NO están leídos
sql = @"
SELECT COUNT(*)
FROM ListingNotes ln
INNER JOIN Listings l ON ln.ListingId = l.Id
WHERE l.UserId = @UserId AND ln.IsFromModerator = 1 AND ln.IsRead = 0";
}
return await conn.ExecuteScalarAsync<int>(sql, new { UserId = userId });
}
}

View File

@@ -29,12 +29,14 @@ public class ListingRepository : IListingRepository
INSERT INTO Listings (
CategoryId, OperationId, Title, Description, Price, Currency,
CreatedAt, Status, UserId, PrintText, PrintStartDate, PrintDaysCount,
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee, ClientId
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee, ClientId,
PublicationStartDate, IsFeatured, FeaturedExpiry, AllowContact, CouponCode, Origin
)
VALUES (
@CategoryId, @OperationId, @Title, @Description, @Price, @Currency,
@CreatedAt, @Status, @UserId, @PrintText, @PrintStartDate, @PrintDaysCount,
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee, @ClientId
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee, @ClientId,
@PublicationStartDate, @IsFeatured, @FeaturedExpiry, @AllowContact, @CouponCode, @Origin
);
SELECT CAST(SCOPE_IDENTITY() as int);";
@@ -101,11 +103,15 @@ public class ListingRepository : IListingRepository
using var conn = _connectionFactory.CreateConnection();
var sql = @"
SELECT
l.*, c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit as ClientDni,
l.*,
c.Name as CategoryName,
p.Name as ParentCategoryName,
cl.BillingName as ClientName, cl.BillingTaxId as ClientDni,
u.Username as CashierName
FROM Listings l
LEFT JOIN Categories c ON l.CategoryId = c.Id
LEFT JOIN Clients cl ON l.ClientId = cl.Id
LEFT JOIN Categories p ON c.ParentId = p.Id
LEFT JOIN Users cl ON l.ClientId = cl.Id
LEFT JOIN Users u ON l.UserId = u.Id
WHERE l.Id = @Id;
@@ -142,10 +148,16 @@ public class ListingRepository : IListingRepository
CreatedAt = listingData.CreatedAt,
UserId = listingData.UserId,
CategoryName = listingData.CategoryName,
ParentCategoryName = listingData.ParentCategoryName,
PrintDaysCount = (int)(listingData.PrintDaysCount ?? 0),
PrintText = listingData.PrintText,
IsBold = listingData.IsBold != null && Convert.ToBoolean(listingData.IsBold),
IsFrame = listingData.IsFrame != null && Convert.ToBoolean(listingData.IsFrame),
PublicationStartDate = listingData.PublicationStartDate,
ApprovedAt = listingData.ApprovedAt,
IsFeatured = listingData.IsFeatured != null && Convert.ToBoolean(listingData.IsFeatured),
FeaturedExpiry = listingData.FeaturedExpiry,
AllowContact = listingData.AllowContact != null && Convert.ToBoolean(listingData.AllowContact),
},
Attributes = attributes,
Images = images,
@@ -162,6 +174,8 @@ public class ListingRepository : IListingRepository
FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id
WHERE l.Status = 'Published'
AND (l.PublicationStartDate IS NULL OR l.PublicationStartDate <= GETUTCDATE())
AND (l.PrintDaysCount = 0 OR DATEADD(day, l.PrintDaysCount, COALESCE(l.PublicationStartDate, l.ApprovedAt, l.CreatedAt)) >= GETUTCDATE())
ORDER BY l.CreatedAt DESC";
return await conn.QueryAsync<Listing>(sql);
@@ -190,7 +204,7 @@ public class ListingRepository : IListingRepository
public async Task<IEnumerable<Listing>> SearchAsync(string? query, int? categoryId)
{
return await SearchFacetedAsync(query, categoryId, null);
return await SearchFacetedAsync(query, categoryId, null, null, null, null, null, true);
}
// Búsqueda Avanzada Facetada
@@ -201,23 +215,24 @@ public class ListingRepository : IListingRepository
DateTime? from = null,
DateTime? to = null,
string? origin = null,
string? status = null)
string? status = null,
bool onlyActive = false)
{
using var conn = _connectionFactory.CreateConnection();
var parameters = new DynamicParameters();
string sql = @"
SELECT l.*, c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit as ClientDni,
SELECT l.*, c.Name as CategoryName, cl.BillingName as ClientName, cl.BillingTaxId as ClientDni,
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id
LEFT JOIN Clients cl ON l.ClientId = cl.Id
LEFT JOIN Users cl ON l.ClientId = cl.Id
WHERE 1=1";
// --- FILTROS EXISTENTES ---
if (!string.IsNullOrEmpty(query))
{
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query OR cl.DniOrCuit = @ExactQuery)";
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query OR cl.BillingTaxId = @ExactQuery OR cl.BillingName LIKE @Query)";
parameters.Add("Query", $"%{query}%");
parameters.Add("ExactQuery", query);
}
@@ -253,7 +268,16 @@ public class ListingRepository : IListingRepository
parameters.Add("Status", status);
}
sql += " ORDER BY l.CreatedAt DESC";
// --- FILTRO DE VISIBILIDAD (Solo para búsquedas públicas) ---
if (onlyActive)
{
// Solo avisos publicados y dentro de su rango de vigencia
sql += @" AND l.Status = 'Published'
AND (l.PublicationStartDate IS NULL OR l.PublicationStartDate <= GETUTCDATE())
AND (l.PrintDaysCount = 0 OR DATEADD(day, l.PrintDaysCount, COALESCE(l.PublicationStartDate, l.ApprovedAt, l.CreatedAt)) >= GETUTCDATE())";
}
sql += " ORDER BY l.IsFeatured DESC, l.CreatedAt DESC";
return await conn.QueryAsync<Listing>(sql, parameters);
}
@@ -277,15 +301,28 @@ public class ListingRepository : IListingRepository
{
using var conn = _connectionFactory.CreateConnection();
// Avisos que NO están publicados ni rechazados ni borrados.
// Asumimos 'Pending' o 'Draft' si vienen del Wizard y requieren revisión.
// Para este ejemplo, buscamos 'Pending'.
return await conn.QueryAsync<Listing>("SELECT * FROM Listings WHERE Status = 'Pending' ORDER BY CreatedAt ASC");
// Asumimos 'Pending'. Incluimos conteo de notas no leídas del usuario.
var sql = @"
SELECT l.*,
(SELECT COUNT(*) FROM ListingNotes ln WHERE ln.ListingId = l.Id AND ln.IsFromModerator = 0 AND ln.IsRead = 0) as UnreadNotesCount
FROM Listings l
WHERE l.Status = 'Pending'
ORDER BY l.CreatedAt ASC";
return await conn.QueryAsync<Listing>(sql);
}
public async Task UpdateStatusAsync(int id, string status)
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync("UPDATE Listings SET Status = @Status WHERE Id = @Id", new { Id = id, Status = status });
var sql = @"
UPDATE Listings
SET Status = @Status,
ApprovedAt = CASE
WHEN (@Status = 'Published' OR @Status = 'Approved') AND ApprovedAt IS NULL THEN GETUTCDATE()
ELSE ApprovedAt
END
WHERE Id = @Id";
await conn.ExecuteAsync(sql, new { Id = id, Status = status });
}
public async Task<int> CountByCategoryIdAsync(int categoryId)
@@ -562,10 +599,11 @@ public class ListingRepository : IListingRepository
SELECT
l.Id, l.CreatedAt as Date, l.Title,
c.Name as Category, u.Username as Cashier, l.AdFee as Amount,
l.Origin as Source
l.Origin as Source, cl.BillingName as ClientName
FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id
LEFT JOIN Users u ON l.UserId = u.Id
LEFT JOIN Users cl ON l.ClientId = cl.Id
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
AND l.Status = 'Published'
AND (@UserId IS NULL OR l.UserId = @UserId)

View File

@@ -46,8 +46,10 @@ public class UserRepository : IUserRepository
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
INSERT INTO Users (Username, PasswordHash, Role, Email, FailedLoginAttempts, LockoutEnd, MustChangePassword, IsActive, LastLogin, GoogleId, IsMfaEnabled, MfaSecret)
VALUES (@Username, @PasswordHash, @Role, @Email, @FailedLoginAttempts, @LockoutEnd, @MustChangePassword, @IsActive, @LastLogin, @GoogleId, @IsMfaEnabled, @MfaSecret);
INSERT INTO Users (Username, PasswordHash, Role, Email, FailedLoginAttempts, LockoutEnd, MustChangePassword, IsActive, LastLogin, GoogleId, IsMfaEnabled, MfaSecret,
BillingName, BillingAddress, BillingTaxId, BillingTaxType, ClientId, Phone)
VALUES (@Username, @PasswordHash, @Role, @Email, @FailedLoginAttempts, @LockoutEnd, @MustChangePassword, @IsActive, @LastLogin, @GoogleId, @IsMfaEnabled, @MfaSecret,
@BillingName, @BillingAddress, @BillingTaxId, @BillingTaxType, @ClientId, @Phone);
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, user);
}
@@ -75,7 +77,9 @@ public class UserRepository : IUserRepository
SET Username = @Username, Role = @Role, Email = @Email, PasswordHash = @PasswordHash,
FailedLoginAttempts = @FailedLoginAttempts, LockoutEnd = @LockoutEnd,
MustChangePassword = @MustChangePassword, IsActive = @IsActive, LastLogin = @LastLogin,
GoogleId = @GoogleId, IsMfaEnabled = @IsMfaEnabled, MfaSecret = @MfaSecret
GoogleId = @GoogleId, IsMfaEnabled = @IsMfaEnabled, MfaSecret = @MfaSecret,
BillingName = @BillingName, BillingAddress = @BillingAddress, BillingTaxId = @BillingTaxId, BillingTaxType = @BillingTaxType,
ClientId = @ClientId, Phone = @Phone
WHERE Id = @Id";
await conn.ExecuteAsync(sql, user);
}

View File

@@ -46,7 +46,7 @@ public class AuthService : IAuthService
// Si MFA está activo, no devolver token aún, pedir verificación
if (user.IsMfaEnabled)
{
return new AuthResult { Success = true, RequiresMfa = true };
return new AuthResult { Success = true, RequiresMfa = true, UserId = user.Id };
}
// Éxito: Reiniciar intentos y generar token
@@ -59,7 +59,8 @@ public class AuthService : IAuthService
{
Success = true,
Token = _tokenService.GenerateToken(user),
RequiresPasswordChange = user.MustChangePassword
RequiresPasswordChange = user.MustChangePassword,
UserId = user.Id
};
}
@@ -83,7 +84,7 @@ public class AuthService : IAuthService
};
await _userRepo.CreateAsync(user);
return new AuthResult { Success = true, Token = _tokenService.GenerateToken(user) };
return new AuthResult { Success = true, Token = _tokenService.GenerateToken(user), UserId = user.Id };
}
// Login mediante Google OAuth
@@ -117,9 +118,9 @@ public class AuthService : IAuthService
await _userRepo.UpdateAsync(user);
}
if (user.IsMfaEnabled) return new AuthResult { Success = true, RequiresMfa = true };
if (user.IsMfaEnabled) return new AuthResult { Success = true, RequiresMfa = true, UserId = user.Id };
return new AuthResult { Success = true, Token = _tokenService.GenerateToken(user) };
return new AuthResult { Success = true, Token = _tokenService.GenerateToken(user), UserId = user.Id };
}
catch (InvalidJwtException)
{

View File

@@ -1,6 +1,7 @@
using SIGCM.Application.DTOs;
using SIGCM.Domain.Entities;
using SIGCM.Infrastructure.Repositories;
using SIGCM.Domain.Interfaces;
using System.Text.RegularExpressions;
namespace SIGCM.Infrastructure.Services;
@@ -8,10 +9,12 @@ namespace SIGCM.Infrastructure.Services;
public class PricingService
{
private readonly PricingRepository _repo;
private readonly ICouponRepository _couponRepo;
public PricingService(PricingRepository repo)
public PricingService(PricingRepository repo, ICouponRepository couponRepo)
{
_repo = repo;
_couponRepo = couponRepo;
}
public async Task<CalculatePriceResponse> CalculateAsync(CalculatePriceRequest request)
@@ -53,41 +56,45 @@ public class PricingService
currentCost += extraWordCost + specialCharCost;
// 4. Estilos (Negrita / Recuadro) - Se suman al precio unitario diario
// 4. Estilos (Negrita / Recuadro / Destacado)
if (request.IsBold) currentCost += pricing.BoldSurcharge;
if (request.IsFrame) currentCost += pricing.FrameSurcharge;
// Costo Destacado (Hardcoded por ahora o agregar a regla)
decimal featuredSurcharge = 0;
if (request.IsFeatured)
{
featuredSurcharge = 500m; // Valor ejemplo por día
currentCost += featuredSurcharge;
}
// 5. Multiplicar por Días
decimal totalBeforeDiscount = currentCost * request.Days;
// 6. Motor de Promociones
// 6. Motor de Promociones y Cupones
var promotions = await _repo.GetActivePromotionsAsync();
decimal totalDiscount = 0;
List<string> appliedPromos = new();
// Lógica de promociones automáticas
foreach (var promo in promotions)
{
// Filtro por Categoría
if (promo.CategoryId.HasValue && promo.CategoryId != request.CategoryId) continue;
// Filtro por Cantidad de Días
if (promo.MinDays > 0 && request.Days < promo.MinDays) continue;
// Filtro por Días de la Semana
// Verificar si aplica según el día de la semana
if (!string.IsNullOrEmpty(promo.DaysOfWeek))
{
var targetDays = promo.DaysOfWeek.Split(',').Select(int.Parse).ToList();
bool hitsDay = false;
// Revisamos cada día que durará el aviso
for (int i = 0; i < request.Days; i++)
{
var currentDay = (int)request.StartDate.AddDays(i).DayOfWeek;
if (targetDays.Contains(currentDay)) hitsDay = true;
var currentDay = (int)request.StartDate.AddDays(i).DayOfWeek;
if (targetDays.Contains(currentDay)) hitsDay = true;
}
if (!hitsDay) continue; // No cae en ningún día de promo
if (!hitsDay) continue;
}
// Aplicar Descuento
if (promo.DiscountPercentage > 0)
{
decimal discountVal = totalBeforeDiscount * (promo.DiscountPercentage / 100m);
@@ -101,16 +108,38 @@ public class PricingService
}
}
// Lógica de Cupón
if (!string.IsNullOrEmpty(request.CouponCode))
{
var coupon = await _couponRepo.GetByCodeAsync(request.CouponCode);
if (coupon != null && (!coupon.ExpiryDate.HasValue || coupon.ExpiryDate > DateTime.UtcNow)
&& (!coupon.MaxUsages.HasValue || coupon.UsageCount < coupon.MaxUsages))
{
if (coupon.DiscountType == "Percentage")
{
decimal val = totalBeforeDiscount * (coupon.DiscountValue / 100m);
totalDiscount += val;
appliedPromos.Add($"CUPÓN {coupon.Code} (-{coupon.DiscountValue}%)");
}
else if (coupon.DiscountType == "Fixed")
{
totalDiscount += coupon.DiscountValue;
appliedPromos.Add($"CUPÓN {coupon.Code} (-${coupon.DiscountValue})");
}
}
}
return new CalculatePriceResponse
{
TotalPrice = Math.Max(0, totalBeforeDiscount - totalDiscount),
BaseCost = pricing.BasePrice * request.Days,
ExtraCost = (extraWordCost + specialCharCost) * request.Days,
Surcharges = ((request.IsBold ? pricing.BoldSurcharge : 0) + (request.IsFrame ? pricing.FrameSurcharge : 0)) * request.Days,
Surcharges = ((request.IsBold ? pricing.BoldSurcharge : 0) + (request.IsFrame ? pricing.FrameSurcharge : 0) + (request.IsFeatured ? featuredSurcharge : 0)) * request.Days,
Discount = totalDiscount,
WordCount = realWordCount,
SpecialCharCount = specialCharCount,
Details = $"Tarifa Diaria: ${currentCost} x {request.Days} días. (Extras diarios: ${extraWordCost + specialCharCost})"
Details = $"Tarifa Diaria: ${currentCost} x {request.Days} días. (Extras diarios: ${extraWordCost + specialCharCost}). {string.Join(", ", appliedPromos)}",
AppliedPromotion = string.Join(", ", appliedPromos)
};
}
}