Feat: Cambios Varios 2

This commit is contained in:
2026-01-05 10:30:04 -03:00
parent 8bc1308bc5
commit 0fa77e4a98
184 changed files with 11098 additions and 6348 deletions

View File

@@ -0,0 +1,107 @@
using Dapper;
using SIGCM.Domain.Entities;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
public class CashClosingRepository
{
private readonly IDbConnectionFactory _db;
public CashClosingRepository(IDbConnectionFactory db) => _db = db;
// Crear un nuevo cierre de caja
public async Task<int> CreateAsync(CashClosing closing)
{
using var conn = _db.CreateConnection();
var sql = @"
INSERT INTO CashClosings (
UserId, ClosingDate,
DeclaredCash, DeclaredDebit, DeclaredCredit, DeclaredTransfer,
SystemCash, SystemDebit, SystemCredit, SystemTransfer,
CashDifference, DebitDifference, CreditDifference, TransferDifference,
TotalDeclared, TotalSystem, TotalDifference,
Notes, HasDiscrepancies, IsApproved
)
VALUES (
@UserId, @ClosingDate,
@DeclaredCash, @DeclaredDebit, @DeclaredCredit, @DeclaredTransfer,
@SystemCash, @SystemDebit, @SystemCredit, @SystemTransfer,
@CashDifference, @DebitDifference, @CreditDifference, @TransferDifference,
@TotalDeclared, @TotalSystem, @TotalDifference,
@Notes, @HasDiscrepancies, @IsApproved
);
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, closing);
}
// Obtener cierres de un usuario en un rango de fechas
public async Task<IEnumerable<CashClosing>> GetByUserAsync(int userId, DateTime startDate, DateTime endDate)
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT * FROM CashClosings
WHERE UserId = @UserId
AND CAST(ClosingDate AS DATE) BETWEEN @StartDate AND @EndDate
ORDER BY ClosingDate DESC";
return await conn.QueryAsync<CashClosing>(sql, new { UserId = userId, StartDate = startDate.Date, EndDate = endDate.Date });
}
// Obtener todos los cierres con discrepancias (para administradores)
public async Task<IEnumerable<CashClosing>> GetDiscrepanciesAsync()
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT c.*, u.Username
FROM CashClosings c
JOIN Users u ON c.UserId = u.Id
WHERE c.HasDiscrepancies = 1 AND c.IsApproved = 0
ORDER BY c.ClosingDate DESC";
return await conn.QueryAsync<CashClosing>(sql);
}
// Aprobar un cierre de caja
public async Task ApproveAsync(int closingId)
{
using var conn = _db.CreateConnection();
await conn.ExecuteAsync("UPDATE CashClosings SET IsApproved = 1 WHERE Id = @Id", new { Id = closingId });
}
// Calcular totales de pagos por método del día actual para un usuario
public async Task<Dictionary<string, decimal>> GetPaymentTotalsByMethodAsync(int userId, DateTime date)
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT p.PaymentMethod, SUM(p.Amount + p.Surcharge) as Total
FROM Payments p
INNER JOIN Listings l ON p.ListingId = l.Id
WHERE l.UserId = @UserId
AND CAST(l.CreatedAt AS DATE) = @Date
GROUP BY p.PaymentMethod";
var results = await conn.QueryAsync<dynamic>(sql, new { UserId = userId, Date = date.Date });
var totals = new Dictionary<string, decimal>
{
["Cash"] = 0,
["Debit"] = 0,
["Credit"] = 0,
["Transfer"] = 0
};
foreach (var row in results)
{
string method = row.PaymentMethod;
decimal total = row.Total;
if (totals.ContainsKey(method))
{
totals[method] = total;
}
}
return totals;
}
}

View File

@@ -0,0 +1,105 @@
using Dapper;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
public class CashSessionRepository
{
private readonly IDbConnectionFactory _db;
public CashSessionRepository(IDbConnectionFactory db) => _db = db;
// 1. Verificar si el usuario ya tiene una caja abierta
public async Task<CashSession?> GetActiveSessionAsync(int userId)
{
using var conn = _db.CreateConnection();
var sql = "SELECT * FROM CashSessions WHERE UserId = @userId AND Status = 'Open'";
return await conn.QuerySingleOrDefaultAsync<CashSession>(sql, new { userId });
}
// 2. Abrir Caja
public async Task<int> OpenSessionAsync(int userId, decimal openingBalance)
{
using var conn = _db.CreateConnection();
var sql = @"
INSERT INTO CashSessions (UserId, OpeningBalance, Status, OpeningDate)
VALUES (@userId, @openingBalance, 'Open', GETUTCDATE());
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, new { userId, openingBalance });
}
// 3. Obtener totales del sistema para un rango de tiempo (El turno)
public async Task<dynamic> GetSystemTotalsAsync(int userId, DateTime start, DateTime end)
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT
SUM(CASE WHEN PaymentMethod = 'Cash' THEN Amount + Surcharge ELSE 0 END) as Cash,
SUM(CASE WHEN PaymentMethod IN ('Debit', 'Credit') THEN Amount + Surcharge ELSE 0 END) as Cards,
SUM(CASE WHEN PaymentMethod = 'Transfer' THEN Amount + Surcharge ELSE 0 END) as Transfers
FROM Payments p
JOIN Listings l ON p.ListingId = l.Id
WHERE l.UserId = @userId AND p.PaymentDate BETWEEN @start AND @end";
return await conn.QuerySingleAsync<dynamic>(sql, new { userId, start, end });
}
// 4. Cerrar Caja (Arqueo Ciego)
public async Task CloseSessionAsync(CashSession session)
{
using var conn = _db.CreateConnection();
var sql = @"
UPDATE CashSessions
SET Status = 'PendingValidation',
ClosingDate = GETUTCDATE(),
DeclaredCash = @DeclaredCash,
DeclaredCards = @DeclaredCards,
DeclaredTransfers = @DeclaredTransfers,
SystemExpectedCash = @SystemExpectedCash,
SystemExpectedCards = @SystemExpectedCards,
SystemExpectedTransfers = @SystemExpectedTransfers,
TotalDifference = @TotalDifference
WHERE Id = @Id";
await conn.ExecuteAsync(sql, session);
}
// Obtener todas las sesiones que requieren validación (para el Supervisor)
public async Task<IEnumerable<CashSession>> GetPendingValidationAsync()
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT cs.*, u.Username
FROM CashSessions cs
JOIN Users u ON cs.UserId = u.Id
WHERE cs.Status = 'PendingValidation'
ORDER BY cs.ClosingDate DESC";
return await conn.QueryAsync<CashSession>(sql);
}
// Validar / Liquidar una sesión
public async Task ValidateSessionAsync(int sessionId, int adminId, string notes)
{
using var conn = _db.CreateConnection();
var sql = @"
UPDATE CashSessions
SET Status = 'Closed',
ValidatedByUserId = @adminId,
ValidationDate = GETUTCDATE(),
ValidationNotes = @notes
WHERE Id = @sessionId";
await conn.ExecuteAsync(sql, new { sessionId, adminId, notes });
}
public async Task<CashSession?> GetSessionDetailAsync(int sessionId)
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT cs.*, u.Username, v.Username as ValidatorName
FROM CashSessions cs
JOIN Users u ON cs.UserId = u.Id
LEFT JOIN Users v ON cs.ValidatedByUserId = v.Id
WHERE cs.Id = @sessionId";
return await conn.QuerySingleOrDefaultAsync<CashSession>(sql, new { sessionId });
}
}

View File

@@ -0,0 +1,126 @@
using Dapper;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Data;
using SIGCM.Domain.Models;
namespace SIGCM.Infrastructure.Repositories;
public class ClaimRepository : IClaimRepository
{
private readonly IDbConnectionFactory _db;
public ClaimRepository(IDbConnectionFactory db) => _db = db;
public async Task<int> CreateAsync(Claim claim)
{
using var conn = _db.CreateConnection();
var sql = @"
INSERT INTO Claims (ListingId, CreatedByUserId, ClaimType, Description, Status)
VALUES (@ListingId, @CreatedByUserId, @ClaimType, @Description, @Status);
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, claim);
}
public async Task<IEnumerable<Claim>> GetByListingIdAsync(int listingId)
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT c.*, u.Username as CreatedByUsername, r.Username as ResolvedByUsername
FROM Claims c
JOIN Users u ON c.CreatedByUserId = u.Id
LEFT JOIN Users r ON c.ResolvedByUserId = r.Id
WHERE c.ListingId = @ListingId
ORDER BY c.CreatedAt DESC";
return await conn.QueryAsync<Claim>(sql, new { ListingId = listingId });
}
public async Task<IEnumerable<Claim>> GetAllActiveAsync()
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT c.*, u.Username as CreatedByUsername
FROM Claims c
JOIN Users u ON c.CreatedByUserId = u.Id
WHERE c.Status <> 'Resolved'
ORDER BY c.CreatedAt ASC";
return await conn.QueryAsync<Claim>(sql);
}
public async Task UpdateStatusAsync(int claimId, string status, ResolveRequest request, int resolvedByUserId)
{
using var conn = _db.CreateConnection();
conn.Open();
using var transaction = conn.BeginTransaction();
try
{
// 1. Buscamos el aviso y sus valores ACTUALES antes de cambiarlos
var currentDataSql = @"
SELECT l.Id, l.PrintText, l.PrintStartDate, l.PrintDaysCount
FROM Listings l
JOIN Claims c ON c.ListingId = l.Id
WHERE c.Id = @claimId";
var oldValues = await conn.QuerySingleOrDefaultAsync<dynamic>(currentDataSql, new { claimId }, transaction);
if (oldValues == null)
throw new InvalidOperationException("Reclamo o Aviso no encontrado.");
// 2. Construimos un string descriptivo del estado anterior
string originalValuesLog = $"Texto: {oldValues.PrintText} | " +
$"Fecha: {oldValues.PrintStartDate:yyyy-MM-dd} | " +
$"Días: {oldValues.PrintDaysCount}";
// 3. Actualizar el Reclamo (Guardando los valores originales)
var sqlClaim = @"
UPDATE Claims
SET Status = @status,
SolutionDescription = @solution,
OriginalValues = @originalValuesLog,
ResolvedAt = GETUTCDATE(),
ResolvedByUserId = @resolvedByUserId
WHERE Id = @claimId";
await conn.ExecuteAsync(sqlClaim, new
{
claimId,
status,
solution = request.Solution,
originalValuesLog,
resolvedByUserId
}, transaction);
// 4. Aplicar los nuevos ajustes técnicos al aviso
if (!string.IsNullOrEmpty(request.NewPrintText) || request.NewStartDate.HasValue || request.NewDaysCount.HasValue)
{
var sqlListing = @"
UPDATE Listings
SET PrintText = ISNULL(@NewPrintText, PrintText),
PrintStartDate = ISNULL(@NewStartDate, PrintStartDate),
PrintDaysCount = ISNULL(@NewDaysCount, PrintDaysCount)
WHERE Id = @listingId";
await conn.ExecuteAsync(sqlListing, new
{
listingId = (int)oldValues.Id,
request.NewPrintText,
request.NewStartDate,
request.NewDaysCount
}, transaction);
}
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
public async Task<Claim?> GetByIdAsync(int id)
{
using var conn = _db.CreateConnection();
return await conn.QuerySingleOrDefaultAsync<Claim>("SELECT * FROM Claims WHERE Id = @Id", new { Id = id });
}
}

View File

@@ -13,35 +13,37 @@ public class ClientRepository
_db = db;
}
// Búsqueda inteligente por Nombre O DNI
// Búsqueda inteligente con protección de nulos
public async Task<IEnumerable<Client>> SearchAsync(string query)
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT TOP 10 * FROM Clients
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";
return await conn.QueryAsync<Client>(sql, new { Query = $"%{query}%" });
}
// Buscar o Crear (Upsert) al guardar el aviso
// Asegurar existencia (Upsert)
public async Task<int> EnsureClientExistsAsync(string name, string dni)
{
using var conn = _db.CreateConnection();
// 1. Buscamos por DNI/CUIT (es el identificador único más fiable)
var existingId = await conn.ExecuteScalarAsync<int?>(
"SELECT Id FROM Clients WHERE DniOrCuit = @Dni", new { Dni = dni });
if (existingId.HasValue)
{
// Opcional: Actualizar nombre si cambió
await conn.ExecuteAsync("UPDATE Clients SET Name = @Name WHERE Id = @Id", new { Name = name, Id = existingId });
return existingId.Value;
}
else
{
// Crear nuevo
var sql = @"
INSERT INTO Clients (Name, DniOrCuit) VALUES (@Name, @Dni);
SELECT CAST(SCOPE_IDENTITY() as int);";
@@ -49,23 +51,59 @@ public class ClientRepository
}
}
// Obtener todos con estadísticas (ISNULL agregado para seguridad)
public async Task<IEnumerable<dynamic>> GetAllWithStatsAsync()
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT c.*,
(SELECT COUNT(1) FROM Listings l WHERE l.ClientId = c.Id) as TotalAds,
(SELECT SUM(AdFee) FROM Listings l WHERE l.ClientId = c.Id) as TotalSpent
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";
return await conn.QueryAsync(sql);
}
public async Task<IEnumerable<Listing>> GetClientHistoryAsync(int clientId)
public async Task UpdateAsync(Client client)
{
using var conn = _db.CreateConnection();
return await conn.QueryAsync<Listing>(
"SELECT * FROM Listings WHERE ClientId = @Id ORDER BY CreatedAt DESC",
new { Id = clientId });
var sql = @"
UPDATE Clients
SET Name = @Name,
DniOrCuit = @DniOrCuit,
Email = @Email,
Phone = @Phone,
Address = @Address
WHERE Id = @Id";
await conn.ExecuteAsync(sql, client);
}
public async Task<dynamic?> GetClientSummaryAsync(int clientId)
{
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,
ISNULL((
SELECT TOP 1 cat.Name
FROM Listings l
JOIN Categories cat ON l.CategoryId = cat.Id
WHERE l.ClientId = c.Id
GROUP BY cat.Name
ORDER BY COUNT(l.Id) DESC
), 'N/A') as PreferredCategory
FROM Clients c
WHERE c.Id = @Id";
return await conn.QuerySingleOrDefaultAsync<dynamic>(sql, new { Id = clientId });
}
}

View File

@@ -0,0 +1,68 @@
using Dapper;
using SIGCM.Domain.Entities;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
public class EditionClosureRepository
{
private readonly IDbConnectionFactory _db;
public EditionClosureRepository(IDbConnectionFactory db) => _db = db;
// Cerrar una edición específica
public async Task<int> CloseEditionAsync(EditionClosure closure)
{
using var conn = _db.CreateConnection();
var sql = @"
INSERT INTO EditionClosures (EditionDate, ClosureDateTime, ClosedByUserId, IsClosed, Notes)
VALUES (@EditionDate, @ClosureDateTime, @ClosedByUserId, @IsClosed, @Notes);
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, closure);
}
// Verificar si una edición está cerrada
public async Task<bool> IsEditionClosedAsync(DateTime editionDate)
{
using var conn = _db.CreateConnection();
var sql = "SELECT COUNT(1) FROM EditionClosures WHERE EditionDate = @Date AND IsClosed = 1";
var count = await conn.ExecuteScalarAsync<int>(sql, new { Date = editionDate.Date });
return count > 0;
}
// Reabrir una edición
public async Task ReopenEditionAsync(DateTime editionDate)
{
using var conn = _db.CreateConnection();
await conn.ExecuteAsync(
"UPDATE EditionClosures SET IsClosed = 0 WHERE EditionDate = @Date",
new { Date = editionDate.Date });
}
// Obtener historial de cierres
public async Task<IEnumerable<EditionClosure>> GetClosureHistoryAsync(int limit = 30)
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT TOP (@Limit) ec.*, u.Username as ClosedByUsername
FROM EditionClosures ec
JOIN Users u ON ec.ClosedByUserId = u.Id
ORDER BY ec.EditionDate DESC";
return await conn.QueryAsync<EditionClosure>(sql, new { Limit = limit });
}
// Obtener el cierre de una fecha específica
public async Task<EditionClosure?> GetClosureByDateAsync(DateTime editionDate)
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT ec.*, u.Username as ClosedByUsername
FROM EditionClosures ec
JOIN Users u ON ec.ClosedByUserId = u.Id
WHERE ec.EditionDate = @Date";
return await conn.QuerySingleOrDefaultAsync<EditionClosure>(sql, new { Date = editionDate.Date });
}
}

View File

@@ -23,6 +23,16 @@ public class ImageRepository : IImageRepository
await conn.ExecuteAsync(sql, image);
}
// Obtiene una imagen por su ID
public async Task<ListingImage?> GetByIdAsync(int id)
{
using var conn = _connectionFactory.CreateConnection();
return await conn.QuerySingleOrDefaultAsync<ListingImage>(
"SELECT * FROM ListingImages WHERE Id = @Id",
new { Id = id });
}
// Obtiene las imágenes de un aviso ordenadas
public async Task<IEnumerable<ListingImage>> GetByListingIdAsync(int listingId)
{
using var conn = _connectionFactory.CreateConnection();
@@ -30,4 +40,11 @@ public class ImageRepository : IImageRepository
"SELECT * FROM ListingImages WHERE ListingId = @ListingId ORDER BY DisplayOrder",
new { ListingId = listingId });
}
// Elimina el registro de una imagen de la base de datos
public async Task DeleteAsync(int id)
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync("DELETE FROM ListingImages WHERE Id = @Id", new { Id = id });
}
}

View File

@@ -1,4 +1,4 @@
using System.Data;
// src/SIGCM.Infrastructure/Repositories/ListingRepository.cs
using Dapper;
using SIGCM.Application.DTOs;
using SIGCM.Domain.Entities;
@@ -17,7 +17,7 @@ public class ListingRepository : IListingRepository
_connectionFactory = connectionFactory;
}
public async Task<int> CreateAsync(Listing listing, Dictionary<int, string> attributes)
public async Task<int> CreateAsync(Listing listing, Dictionary<int, string> attributes, List<Payment>? payments = null)
{
using var conn = _connectionFactory.CreateConnection();
conn.Open();
@@ -29,12 +29,12 @@ public class ListingRepository : IListingRepository
INSERT INTO Listings (
CategoryId, OperationId, Title, Description, Price, Currency,
CreatedAt, Status, UserId, PrintText, PrintStartDate, PrintDaysCount,
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee, ClientId
)
VALUES (
@CategoryId, @OperationId, @Title, @Description, @Price, @Currency,
@CreatedAt, @Status, @UserId, @PrintText, @PrintStartDate, @PrintDaysCount,
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee, @ClientId
);
SELECT CAST(SCOPE_IDENTITY() as int);";
@@ -52,6 +52,34 @@ public class ListingRepository : IListingRepository
}
}
if (listing.ImagesToClone != null && listing.ImagesToClone.Any())
{
foreach (var url in listing.ImagesToClone)
{
// Validamos que la URL sea válida antes de insertar
if (string.IsNullOrEmpty(url)) continue;
var sqlImg = @"INSERT INTO ListingImages (ListingId, Url, IsMainInfo, DisplayOrder)
VALUES (@listingId, @url, 0, 0)";
// Usamos el ID del nuevo aviso (listingId) y la URL del viejo
await conn.ExecuteAsync(sqlImg, new { listingId, url }, transaction);
}
}
if (payments != null && payments.Any())
{
var sqlPayment = @"
INSERT INTO Payments (ListingId, Amount, PaymentMethod, CardPlan, Surcharge, PaymentDate)
VALUES (@ListingId, @Amount, @PaymentMethod, @CardPlan, @Surcharge, @PaymentDate)";
foreach (var pay in payments)
{
pay.ListingId = listingId;
await conn.ExecuteAsync(sqlPayment, pay, transaction);
}
}
transaction.Commit();
return listingId;
}
@@ -71,55 +99,57 @@ public class ListingRepository : IListingRepository
public async Task<ListingDetail?> GetDetailByIdAsync(int id)
{
using var conn = _connectionFactory.CreateConnection();
// Mejoramos el SQL para asegurar que los nulos se conviertan en 0 (false) desde el motor
var sql = @"
SELECT
l.Id, l.CategoryId, l.OperationId, l.Title, l.Description, l.Price, l.AdFee,
l.Currency, l.CreatedAt, l.Status, l.UserId, l.PrintText, l.PrintDaysCount,
ISNULL(l.IsBold, 0) as IsBold,
ISNULL(l.IsFrame, 0) as IsFrame,
l.PrintFontSize, l.PrintAlignment, l.ClientId,
c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit as ClientDni
FROM Listings l
LEFT JOIN Categories c ON l.CategoryId = c.Id
LEFT JOIN Clients cl ON l.ClientId = cl.Id
WHERE l.Id = @Id;
SELECT lav.*, ad.Name as AttributeName
FROM ListingAttributeValues lav
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
WHERE lav.ListingId = @Id;
SELECT * FROM ListingImages WHERE ListingId = @Id ORDER BY DisplayOrder;
";
SELECT
l.*, c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit 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 Users u ON l.UserId = u.Id
WHERE l.Id = @Id;
SELECT lav.*, ad.Name as AttributeName
FROM ListingAttributeValues lav
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
WHERE lav.ListingId = @Id;
SELECT * FROM ListingImages WHERE ListingId = @Id ORDER BY DisplayOrder;
SELECT * FROM Payments WHERE ListingId = @Id;
";
using var multi = await conn.QueryMultipleAsync(sql, new { Id = id });
var listing = await multi.ReadSingleOrDefaultAsync<dynamic>();
if (listing == null) return null;
var listingData = await multi.ReadSingleOrDefaultAsync<dynamic>();
if (listingData == null) return null;
var attributes = await multi.ReadAsync<ListingAttributeValueWithName>();
var images = await multi.ReadAsync<ListingImage>();
var payments = await multi.ReadAsync<Payment>();
return new ListingDetail
{
Listing = new Listing
{
Id = (int)listing.Id,
Title = listing.Title,
Description = listing.Description,
Price = listing.Price,
AdFee = listing.AdFee,
Status = listing.Status,
CreatedAt = listing.CreatedAt,
PrintText = listing.PrintText,
IsBold = Convert.ToBoolean(listing.IsBold),
IsFrame = Convert.ToBoolean(listing.IsFrame),
PrintDaysCount = listing.PrintDaysCount,
CategoryName = listing.CategoryName
Id = (int)listingData.Id,
CategoryId = (int)(listingData.CategoryId ?? 0),
OperationId = (int)(listingData.OperationId ?? 0),
Title = listingData.Title ?? "",
Description = listingData.Description ?? "",
Price = listingData.Price ?? 0m,
AdFee = listingData.AdFee ?? 0m,
Status = listingData.Status ?? "Draft",
CreatedAt = listingData.CreatedAt,
UserId = listingData.UserId,
CategoryName = listingData.CategoryName,
PrintDaysCount = (int)(listingData.PrintDaysCount ?? 0),
PrintText = listingData.PrintText,
IsBold = listingData.IsBold != null && Convert.ToBoolean(listingData.IsBold),
IsFrame = listingData.IsFrame != null && Convert.ToBoolean(listingData.IsFrame),
},
Attributes = attributes,
Images = images
Images = images,
Payments = payments
};
}
@@ -127,85 +157,100 @@ public class ListingRepository : IListingRepository
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
SELECT TOP 20 l.*,
SELECT l.*, c.Name as CategoryName,
(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
WHERE l.Status = 'Published'
ORDER BY l.CreatedAt DESC";
return await conn.QueryAsync<Listing>(sql);
}
// Implementación de incremento
public async Task IncrementViewCountAsync(int id)
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync("UPDATE Listings SET ViewCount = ViewCount + 1 WHERE Id = @Id", new { Id = id });
}
public async Task<IEnumerable<Listing>> GetByUserIdAsync(int userId)
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
SELECT l.*, c.Name as CategoryName,
(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
WHERE l.UserId = @UserId
ORDER BY l.CreatedAt DESC";
return await conn.QueryAsync<Listing>(sql, new { UserId = userId });
}
public async Task<IEnumerable<Listing>> SearchAsync(string? query, int? categoryId)
{
return await SearchFacetedAsync(query, categoryId, null);
}
// Búsqueda Avanzada Facetada
public async Task<IEnumerable<Listing>> SearchFacetedAsync(string? query, int? categoryId, Dictionary<string, string>? attributes)
public async Task<IEnumerable<Listing>> SearchFacetedAsync(
string? query,
int? categoryId,
Dictionary<string, string>? attributes,
DateTime? from = null,
DateTime? to = null,
string? origin = null,
string? status = null)
{
using var conn = _connectionFactory.CreateConnection();
var parameters = new DynamicParameters();
string sql;
// Construcción Dinámica de la Query con CTE
if (categoryId.HasValue && categoryId.Value > 0)
{
sql = @"
WITH CategoryTree AS (
SELECT Id FROM Categories WHERE Id = @CategoryId
UNION ALL
SELECT c.Id FROM Categories c
INNER JOIN CategoryTree ct ON c.ParentId = ct.Id
)
SELECT l.*,
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
FROM Listings l
WHERE l.Status = 'Published'
AND l.CategoryId IN (SELECT Id FROM CategoryTree)";
string sql = @"
SELECT l.*, c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit 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
WHERE 1=1";
parameters.Add("CategoryId", categoryId);
}
else
{
// Sin filtro de categoría (o todas)
sql = @"
SELECT l.*,
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
FROM Listings l
WHERE l.Status = 'Published'";
}
// Filtro de Texto
// --- FILTROS EXISTENTES ---
if (!string.IsNullOrEmpty(query))
{
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query)";
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query OR cl.DniOrCuit = @ExactQuery)";
parameters.Add("Query", $"%{query}%");
parameters.Add("ExactQuery", query);
}
// Filtros de Atributos (Igual que antes)
if (attributes != null && attributes.Any())
if (categoryId.HasValue && categoryId.Value > 0)
{
int i = 0;
foreach (var attr in attributes)
{
if (string.IsNullOrWhiteSpace(attr.Value)) continue;
string paramName = $"@Val{i}";
string paramKey = $"@Key{i}";
sql += " AND l.CategoryId = @CategoryId";
parameters.Add("CategoryId", categoryId);
}
sql += $@" AND EXISTS (
SELECT 1 FROM ListingAttributeValues lav
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
WHERE lav.ListingId = l.Id
AND ad.Name = {paramKey}
AND lav.Value LIKE {paramName}
)";
if (from.HasValue)
{
sql += " AND l.CreatedAt >= @From";
parameters.Add("From", from.Value.Date);
}
parameters.Add($"Val{i}", $"%{attr.Value}%");
parameters.Add($"Key{i}", attr.Key);
i++;
}
if (to.HasValue)
{
sql += " AND l.CreatedAt <= @To";
parameters.Add("To", to.Value.Date.AddDays(1).AddSeconds(-1));
}
if (!string.IsNullOrEmpty(origin) && origin != "All")
{
sql += " AND l.Origin = @Origin";
parameters.Add("Origin", origin);
}
// --- FILTRO DE ESTADO ---
if (!string.IsNullOrEmpty(status))
{
sql += " AND l.Status = @Status";
parameters.Add("Status", status);
}
sql += " ORDER BY l.CreatedAt DESC";
@@ -317,11 +362,11 @@ public class ListingRepository : IListingRepository
AND Status = 'Published'";
var kpis = await conn.QueryFirstOrDefaultAsync(kpisSql, new { StartDate = startDate.Date, EndDate = endDate.Date });
stats.RevenueToday = kpis != null ? (decimal)kpis.RevenueToday : 0;
stats.AdsToday = kpis != null ? (int)kpis.AdsToday : 0;
stats.RevenueToday = kpis?.RevenueToday ?? 0;
stats.AdsToday = kpis?.AdsToday ?? 0;
stats.TicketAverage = stats.AdsToday > 0 ? stats.RevenueToday / stats.AdsToday : 0;
// 2. Ocupación (basada en el último día del rango)
// 2. Occupation (based on the last day of the range)
stats.PaperOccupation = Math.Min(100, (stats.AdsToday * 100.0) / 100.0);
// 3. Tendencia del periodo
@@ -357,6 +402,142 @@ public class ListingRepository : IListingRepository
return stats;
}
public async Task<AdvancedAnalyticsDto> GetAdvancedAnalyticsAsync(DateTime startDate, DateTime endDate)
{
using var conn = _connectionFactory.CreateConnection();
var analytics = new AdvancedAnalyticsDto();
// 1. Calcular Periodo Anterior para comparación
var duration = endDate - startDate;
var prevStart = startDate.Add(-duration);
var prevEnd = startDate.AddSeconds(-1);
// 2. KPIs del Periodo Actual
var currentKpiSql = @"
SELECT
CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as Revenue,
COUNT(Id) as Ads
FROM Listings
WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
AND Status = 'Published'";
var currentKpis = await conn.QueryFirstOrDefaultAsync(currentKpiSql, new { Start = startDate.Date, End = endDate.Date });
analytics.TotalRevenue = currentKpis?.Revenue ?? 0;
analytics.TotalAds = currentKpis?.Ads ?? 0;
// 3. KPIs del Periodo Anterior
var prevKpiSql = @"
SELECT
CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as Revenue,
COUNT(Id) as Ads
FROM Listings
WHERE CAST(CreatedAt AS DATE) BETWEEN @PrevStart AND @PrevEnd
AND Status = 'Published'";
var prevKpis = await conn.QueryFirstOrDefaultAsync(prevKpiSql, new { PrevStart = prevStart.Date, PrevEnd = prevEnd.Date });
analytics.PreviousPeriodRevenue = prevKpis?.Revenue ?? 0;
analytics.PreviousPeriodAds = prevKpis?.Ads ?? 0;
// 4. Calcular Crecimiento
if (analytics.PreviousPeriodRevenue > 0)
analytics.RevenueGrowth = (double)((analytics.TotalRevenue - analytics.PreviousPeriodRevenue) / analytics.PreviousPeriodRevenue) * 100;
if (analytics.PreviousPeriodAds > 0)
analytics.AdsGrowth = (double)(analytics.TotalAds - analytics.PreviousPeriodAds) / analytics.PreviousPeriodAds * 100;
// 5. Distribución de Pagos (Real)
var paymentsSql = @"
SELECT
p.PaymentMethod as Method,
SUM(p.Amount + p.Surcharge) as Total,
COUNT(p.Id) as Count
FROM Payments p
INNER JOIN Listings l ON p.ListingId = l.Id
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
AND l.Status = 'Published'
GROUP BY p.PaymentMethod";
analytics.PaymentsDistribution = (await conn.QueryAsync<PaymentMethodStat>(paymentsSql, new { Start = startDate.Date, End = endDate.Date })).ToList();
// 6. Rendimiento por Categoría
var categorySql = @"
SELECT
c.Name as CategoryName,
SUM(l.AdFee) as Revenue,
COUNT(l.Id) as AdsCount
FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
AND l.Status = 'Published'
GROUP BY c.Name
ORDER BY Revenue DESC";
var catPerf = (await conn.QueryAsync<CategoryPerformanceStat>(categorySql, new { Start = startDate.Date, End = endDate.Date })).ToList();
foreach (var cp in catPerf)
{
cp.Share = analytics.TotalRevenue > 0 ? (double)(cp.Revenue / analytics.TotalRevenue) * 100 : 0;
}
analytics.CategoryPerformance = catPerf;
// 7. Análisis Horario (Peak Hours)
var hourlySql = @"
SELECT
DATEPART(HOUR, CreatedAt) as Hour,
COUNT(Id) as Count
FROM Listings
WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
AND Status = 'Published'
GROUP BY DATEPART(HOUR, CreatedAt)
ORDER BY Hour";
analytics.HourlyActivity = (await conn.QueryAsync<HourlyStat>(hourlySql, new { Start = startDate.Date, End = endDate.Date })).ToList();
// 8. Tendencia Diaria
var dailySql = @"
SELECT
FORMAT(CreatedAt, 'dd/MM') as Day,
SUM(AdFee) as Amount
FROM Listings
WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
AND Status = 'Published'
GROUP BY FORMAT(CreatedAt, 'dd/MM'), CAST(CreatedAt AS DATE)
ORDER BY CAST(CreatedAt AS DATE) ASC";
analytics.DailyTrends = (await conn.QueryAsync<DailyRevenue>(dailySql, new { Start = startDate.Date, End = endDate.Date })).ToList();
var sourceSql = @"
SELECT
Origin,
COUNT(Id) as Count
FROM Listings
WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
AND Status = 'Published'
GROUP BY Origin";
var sources = await conn.QueryAsync<dynamic>(sourceSql, new { Start = startDate.Date, End = endDate.Date });
int total = 0;
int web = 0;
int mostrador = 0;
foreach (var s in sources)
{
if (s.Origin == "Web") web = (int)s.Count;
if (s.Origin == "Mostrador") mostrador = (int)s.Count;
total += (int)s.Count;
}
analytics.SourceMix = new SourceMixDto
{
MostradorCount = mostrador,
WebCount = web,
MostradorPercent = total > 0 ? (mostrador * 100.0 / total) : 0,
WebPercent = total > 0 ? (web * 100.0 / total) : 0
};
return analytics;
}
public async Task<CashierDashboardDto?> GetCashierStatsAsync(int userId, DateTime startDate, DateTime endDate)
{
using var conn = _connectionFactory.CreateConnection();
@@ -364,18 +545,12 @@ public class ListingRepository : IListingRepository
// Filtramos tanto la recaudación como los pendientes por el rango seleccionado
var sql = @"
SELECT
CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as MyRevenue,
COUNT(Id) as MyAdsCount,
(SELECT COUNT(1) FROM Listings
WHERE UserId = @UserId AND Status = 'Pending'
AND CAST(CreatedAt AS DATE) BETWEEN @Start AND @End) as MyPendingAds
FROM Listings
WHERE UserId = @UserId
AND CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
AND Status = 'Published'";
(SELECT ISNULL(SUM(Amount + Surcharge), 0) FROM Payments p INNER JOIN Listings l ON p.ListingId = l.Id
WHERE l.UserId = @UserId AND CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End AND l.Status = 'Published') as MyRevenue,
(SELECT COUNT(1) FROM Listings WHERE UserId = @UserId AND Status = 'Pending') as MyPendingAds,
(SELECT COUNT(1) FROM Listings WHERE UserId = @UserId AND CAST(CreatedAt AS DATE) BETWEEN @Start AND @End AND Status = 'Published') as MyAdsCount";
return await conn.QueryFirstOrDefaultAsync<CashierDashboardDto>(sql,
new { UserId = userId, Start = startDate.Date, End = endDate.Date });
return await conn.QueryFirstOrDefaultAsync<CashierDashboardDto>(sql, new { UserId = userId, Start = startDate.Date, End = endDate.Date });
}
public async Task<GlobalReportDto> GetDetailedReportAsync(DateTime start, DateTime end, int? userId = null)
@@ -383,30 +558,83 @@ public class ListingRepository : IListingRepository
using var conn = _connectionFactory.CreateConnection();
var report = new GlobalReportDto { FromDate = start, ToDate = end };
// Filtro inteligente: Si @UserId es NULL, devuelve todo. Si no, filtra por ese usuario.
var sql = @"
SELECT
l.Id, l.CreatedAt as Date, l.Title,
c.Name as Category, u.Username as Cashier, l.AdFee as Amount
FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id
LEFT JOIN Users u ON l.UserId = u.Id
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
AND l.Status = 'Published'
AND (@UserId IS NULL OR l.UserId = @UserId) -- <--- FILTRO DINÁMICO
ORDER BY l.CreatedAt ASC";
var items = await conn.QueryAsync<ReportItemDto>(sql, new
{
Start = start.Date,
End = end.Date,
UserId = userId // Dapper pasará null si el parámetro es null
});
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
FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id
LEFT JOIN Users u ON l.UserId = u.Id
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
AND l.Status = 'Published'
AND (@UserId IS NULL OR l.UserId = @UserId)
ORDER BY l.CreatedAt ASC";
var items = await conn.QueryAsync<ReportItemDto>(sql, new { Start = start.Date, End = end.Date, UserId = userId });
report.Items = items.ToList();
report.TotalRevenue = report.Items.Sum(x => x.Amount);
report.TotalAds = report.Items.Count;
// TOTALES FÍSICOS (Solo lo que entró por caja)
var totalsSql = @"
SELECT
p.PaymentMethod,
SUM(p.Amount + p.Surcharge) as Total
FROM Payments p
INNER JOIN Listings l ON p.ListingId = l.Id
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
AND l.Status = 'Published'
AND l.Origin = 'Mostrador'
AND (@UserId IS NULL OR l.UserId = @UserId)
GROUP BY p.PaymentMethod";
var paymentTotals = await conn.QueryAsync<dynamic>(totalsSql, new { Start = start.Date, End = end.Date, UserId = userId });
// Reiniciar totales para el reporte
report.TotalCash = 0; report.TotalDebit = 0; report.TotalCredit = 0; report.TotalTransfer = 0;
foreach (var p in paymentTotals)
{
string method = p.PaymentMethod;
decimal total = (decimal)p.Total;
switch (method)
{
case "Cash": report.TotalCash = total; break;
case "Debit": report.TotalDebit = total; break;
case "Credit": report.TotalCredit = total; break;
case "Transfer": report.TotalTransfer = total; break;
}
}
report.TotalRevenue = report.TotalCash + report.TotalDebit + report.TotalCredit + report.TotalTransfer;
return report;
}
public async Task AddPaymentAsync(Payment payment)
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
INSERT INTO Payments (ListingId, Amount, PaymentMethod, CardPlan, Surcharge, PaymentDate, ExternalReference, ExternalId, Status)
VALUES (@ListingId, @Amount, @PaymentMethod, @CardPlan, @Surcharge, @PaymentDate, @ExternalReference, @ExternalId, @Status)";
await conn.ExecuteAsync(sql, payment);
}
public async Task<IEnumerable<dynamic>> GetActiveCashiersAsync()
{
using var conn = _connectionFactory.CreateConnection();
// Traemos usuarios que tengan el rol adecuado para filtrar en el historial
var sql = @"SELECT Id, Username FROM Users
WHERE Role IN ('Cajero', 'Admin')
AND IsActive = 1
ORDER BY Username ASC";
return await conn.QueryAsync(sql);
}
public async Task UpdateOverlayStatusAsync(int id, int userId, string? status)
{
using var conn = _connectionFactory.CreateConnection();
// Seguridad: Filtramos por Id del aviso Y Id del usuario dueño
var sql = "UPDATE Listings SET OverlayStatus = @status WHERE Id = @id AND UserId = @userId";
await conn.ExecuteAsync(sql, new { id, userId, status });
}
}

View File

@@ -0,0 +1,33 @@
using Dapper;
using SIGCM.Domain.Entities;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
public class NotificationRepository
{
private readonly IDbConnectionFactory _db;
public NotificationRepository(IDbConnectionFactory db) => _db = db;
public async Task<IEnumerable<Notification>> GetByUserAsync(int userId)
{
using var conn = _db.CreateConnection();
return await conn.QueryAsync<Notification>(
"SELECT TOP 10 * FROM Notifications WHERE UserId = @userId ORDER BY CreatedAt DESC",
new { userId });
}
public async Task MarkAsReadAsync(int notificationId)
{
using var conn = _db.CreateConnection();
await conn.ExecuteAsync("UPDATE Notifications SET IsRead = 1 WHERE Id = @Id", new { Id = notificationId });
}
public async Task<int> GetUnreadCountAsync(int userId)
{
using var conn = _db.CreateConnection();
return await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM Notifications WHERE UserId = @userId AND IsRead = 0",
new { userId });
}
}

View File

@@ -14,6 +14,7 @@ public class UserRepository : IUserRepository
_connectionFactory = connectionFactory;
}
// Busca usuario por nombre de usuario
public async Task<User?> GetByUsernameAsync(string username)
{
using var conn = _connectionFactory.CreateConnection();
@@ -22,38 +23,64 @@ public class UserRepository : IUserRepository
new { Username = username });
}
// Busca usuario por correo electrónico
public async Task<User?> GetByEmailAsync(string email)
{
using var conn = _connectionFactory.CreateConnection();
return await conn.QuerySingleOrDefaultAsync<User>(
"SELECT * FROM Users WHERE Email = @Email",
new { Email = email });
}
// Busca usuario por identificador único de Google
public async Task<User?> GetByGoogleIdAsync(string googleId)
{
using var conn = _connectionFactory.CreateConnection();
return await conn.QuerySingleOrDefaultAsync<User>(
"SELECT * FROM Users WHERE GoogleId = @GoogleId",
new { GoogleId = googleId });
}
// Crea un nuevo usuario en el sistema
public async Task<int> CreateAsync(User user)
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
INSERT INTO Users (Username, PasswordHash, Role, Email)
VALUES (@Username, @PasswordHash, @Role, @Email);
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);
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, user);
}
// Obtiene todos los usuarios
public async Task<IEnumerable<User>> GetAllAsync()
{
using var conn = _connectionFactory.CreateConnection();
return await conn.QueryAsync<User>("SELECT Id, Username, Role, Email, CreatedAt, PasswordHash FROM Users");
return await conn.QueryAsync<User>("SELECT * FROM Users");
}
// Obtiene un usuario por su ID
public async Task<User?> GetByIdAsync(int id)
{
using var conn = _connectionFactory.CreateConnection();
return await conn.QuerySingleOrDefaultAsync<User>("SELECT * FROM Users WHERE Id = @Id", new { Id = id });
}
// Actualiza los datos de un usuario existente
public async Task UpdateAsync(User user)
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
UPDATE Users
SET Username = @Username, Role = @Role, Email = @Email, PasswordHash = @PasswordHash
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
WHERE Id = @Id";
await conn.ExecuteAsync(sql, user);
}
// Elimina un usuario por su ID
public async Task DeleteAsync(int id)
{
using var conn = _connectionFactory.CreateConnection();