diff --git a/HashGen/HashGen.csproj b/HashGen/HashGen.csproj new file mode 100644 index 0000000..8184560 --- /dev/null +++ b/HashGen/HashGen.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/HashGen/Program.cs b/HashGen/Program.cs new file mode 100644 index 0000000..74270d4 --- /dev/null +++ b/HashGen/Program.cs @@ -0,0 +1 @@ +System.Console.WriteLine(BCrypt.Net.BCrypt.HashPassword("1234")); diff --git a/frontend/admin-panel/src/pages/Clients/ClientManager.tsx b/frontend/admin-panel/src/pages/Clients/ClientManager.tsx index 8a7cc88..4d11f93 100644 --- a/frontend/admin-panel/src/pages/Clients/ClientManager.tsx +++ b/frontend/admin-panel/src/pages/Clients/ClientManager.tsx @@ -15,6 +15,10 @@ interface Client { email?: string; phone?: string; address?: string; + taxType?: string; + username: string; + isActive: boolean; + role: string; totalAds: number; totalSpent: number; } @@ -32,12 +36,17 @@ export default function ClientManager() { const [showSummaryModal, setShowSummaryModal] = useState(false); const [isSaving, setIsSaving] = useState(false); - useEffect(() => { loadClients(); }, []); + useEffect(() => { + const timer = setTimeout(() => { + loadClients(search); + }, 400); // 400ms debounce + return () => clearTimeout(timer); + }, [search]); - const loadClients = async () => { + const loadClients = async (query?: string) => { setLoading(true); try { - const res = await clientService.getAll(); + const res = await clientService.getAll(query); setClients(res); } finally { setLoading(false); @@ -53,7 +62,7 @@ export default function ClientManager() { }; const handleOpenEdit = (client: Client) => { - setSelectedClient({ ...client }); + setSelectedClient({ ...client, taxType: client.taxType || "Consumidor Final" }); setShowEditModal(true); }; @@ -63,144 +72,215 @@ export default function ClientManager() { setIsSaving(true); try { await clientService.update(selectedClient.id, selectedClient); - await loadClients(); + await loadClients(search); setShowEditModal(false); } finally { setIsSaving(false); } }; - const filteredClients = clients.filter(c => { - const name = (c.name || "").toLowerCase(); - const dni = (c.dniOrCuit || "").toLowerCase(); - const s = search.toLowerCase(); - return name.includes(s) || dni.includes(s); - }); - return (
-
+
-

- Directorio de Clientes +

+ + + + Directorio de Clientes

-

Gestión de datos fiscales y analítica de anunciantes

+

Gestión de datos fiscales y analítica de anunciantes

-
- +
+ setSearch(e.target.value)} /> + {search && ( +
+ Filtrando en servidor... +
+ )}
-
- {loading ? ( -
- Sincronizando base de datos... +
+ {loading && clients.length === 0 ? ( +
+
+

Sincronizando base de datos...

- ) : filteredClients.map(client => ( -
-
-
-
+ ) : clients.length === 0 ? ( +
+
No se encontraron clientes que coincidan
+
+ ) : clients.map(client => ( +
+
+ + +
+
{(client.name || "?").charAt(0).toUpperCase()}
- -
-

{client.name}

-
- {client.dniOrCuit} +
+

{client.name}

+
+ @{client.username} + + {client.dniOrCuit} +
+
-
-
- {client.email || 'N/A'} +
+
+ + {client.email || 'Sin correo'}
-
- {client.phone || 'N/A'} +
+ + {client.phone || 'Sin tel.'}
-
-
-
Avisos
-
{client.totalAds}
+
+
+ Avisos + {client.totalAds}
-
-
Invertido
-
${client.totalSpent?.toLocaleString()}
+
+ Invertido + ${client.totalSpent > 1000 ? Math.round(client.totalSpent / 1000) + 'k' : client.totalSpent}
))}
{/* --- MODAL DE EDICIÓN / FACTURACIÓN --- */} - setShowEditModal(false)} title="Datos Fiscales y de Contacto"> -
-
-
- - setSelectedClient({ ...selectedClient!, name: e.target.value })} - /> -
+ setShowEditModal(false)} title="Actualizar Perfil de Cliente"> + +
- - setSelectedClient({ ...selectedClient!, dniOrCuit: e.target.value })} - /> -
-
- - setSelectedClient({ ...selectedClient!, phone: e.target.value })} - /> +

Estado de la Cuenta

+

{selectedClient?.isActive ? 'ACTIVA Y OPERATIVA' : 'CUENTA DESACTIVADA'}

+ +
+ +
- - setSelectedClient({ ...selectedClient!, email: e.target.value })} - /> -
-
- +
- + + setSelectedClient({ ...selectedClient!, name: e.target.value })} + /> +
+
+ +
+ +
+ @ + setSelectedClient({ ...selectedClient!, username: e.target.value })} + /> +
+
+ +
+ +
+ + setSelectedClient({ ...selectedClient!, dniOrCuit: e.target.value })} + /> +
+
+ +
+ + +
+ +
+ +
+ setSelectedClient({ ...selectedClient!, phone: e.target.value })} + /> +
+
+ +
+ +
+ + setSelectedClient({ ...selectedClient!, email: e.target.value })} + /> +
+
+ +
+ +
+ + setSelectedClient({ ...selectedClient!, address: e.target.value })} placeholder="Calle, Nro, Localidad..." @@ -208,14 +288,39 @@ export default function ClientManager() {
-
- + +
+
+ + +
diff --git a/frontend/admin-panel/src/services/clientService.ts b/frontend/admin-panel/src/services/clientService.ts index 0093a19..88a4d11 100644 --- a/frontend/admin-panel/src/services/clientService.ts +++ b/frontend/admin-panel/src/services/clientService.ts @@ -2,8 +2,8 @@ import api from './api'; export const clientService = { - getAll: async () => { - const res = await api.get('/clients'); + getAll: async (q?: string) => { + const res = await api.get('/clients', { params: { q } }); return res.data; }, getSummary: async (id: number) => { @@ -12,5 +12,9 @@ export const clientService = { }, update: async (id: number, clientData: any) => { await api.put(`/clients/${id}`, clientData); + }, + resetPassword: async (id: number) => { + const res = await api.post(`/clients/${id}/reset-password`); + return res.data; } }; \ No newline at end of file diff --git a/frontend/counter-panel/src/components/CashClosingModal.tsx b/frontend/counter-panel/src/components/CashClosingModal.tsx index 6902eb7..39eb4d5 100644 --- a/frontend/counter-panel/src/components/CashClosingModal.tsx +++ b/frontend/counter-panel/src/components/CashClosingModal.tsx @@ -80,75 +80,78 @@ export default function CashClosingModal({ onClose, onComplete }: CashClosingMod {/* Header */} -
+
- Finalización de Turno -

Cierre de Caja

+ Control de Tesorería +

Cierre de Caja

{!done && ( - )}
-
+
{!done ? ( - + {/* Visualización de Totales del Sistema */}

Resumen de Valores en Caja

-
- } /> - } isSale /> - } isSale /> - } isSale /> +
+ } /> + } isSale /> + } isSale /> + } isSale />
{/* Total a Entregar */} -
-
- Total Final a Entregar - +
+
+
+ Total Final a Entregar + $ {summary.totalExpected.toLocaleString('es-AR', { minimumFractionDigits: 2 })}
- +
{/* Notas opcionales */} -
- +
+ -
F10 para Cobrar
+
+
+ + setFormData({ ...formData, title: e.target.value })} + />
-
-
-
Palabras 0 ? "text-blue-400" : "text-slate-700")}>{pricing.wordCount.toString().padStart(2, '0')}
-
Signos Especiales 0 ? "text-amber-400" : "text-slate-700")}>{pricing.specialCharCount.toString().padStart(2, '0')}
+
+ +
+ $ + setFormData({ ...formData, price: e.target.value })} + />
-
Vista optimizada para diario
{[1, 2, 3, 4, 5].map(i =>
i * 15 ? "bg-blue-500" : "bg-slate-800")}>
)}
-
-
+
+ +
+ +
+
+
+
Palabras 0 ? "text-blue-400" : "text-slate-700")}>{pricing.wordCount.toString().padStart(2, '0')}
+
Signos 0 ? "text-amber-400" : "text-slate-700")}>{pricing.specialCharCount.toString().padStart(2, '0')}
+
+
+ Vista Diario +
+ {[1, 2, 3, 4, 5].map(i =>
i * 15 ? "bg-blue-500" : "bg-slate-800")}>
)} +
+
+
+
+ +
+
+ +
+ + setFormData({ ...formData, startDate: e.target.value })} + /> +
+
+
-
+
setFormData({ ...formData, days: Math.max(1, parseInt(e.target.value) || 0) })} />
-
+
-
+
- setShowSuggestions(true)} onChange={e => { setFormData({ ...formData, clientName: e.target.value }); setShowSuggestions(true); }} /> + setShowSuggestions(true)} onChange={e => { setFormData({ ...formData, clientName: e.target.value }); setShowSuggestions(true); }} />
{showSuggestions && clientSuggestions.length > 0 && ( @@ -424,9 +517,37 @@ export default function FastEntryPage() { )}
-
+
- setFormData({ ...formData, clientDni: e.target.value })} /> + setFormData({ ...formData, clientDni: e.target.value })} /> +
+
+ +
+
setFormData({ ...formData, isFeatured: !formData.isFeatured })} + > +
+ +
+
+ Aviso Destacado + Aparece primero en la web +
+
+
+
setFormData({ ...formData, allowContact: !formData.allowContact })} + > +
+ +
+
+ Permitir Contacto + Habilita botón de mensajes +
@@ -479,18 +600,57 @@ export default function FastEntryPage() {
+ +
+
+

+ Multimedia +

+ {selectedImages.length} fotos +
+ +
+ {imagePreviews.map((url, i) => ( +
+ Preview + +
+ ))} + + + +
+
-
- - {showPaymentModal && ( - setShowPaymentModal(false)} /> - )} + + {showPaymentModal && ( + setShowPaymentModal(false)} /> + )} ); } \ No newline at end of file diff --git a/frontend/public-web/.gitignore b/frontend/public-web/.gitignore index a547bf3..ac66765 100644 --- a/frontend/public-web/.gitignore +++ b/frontend/public-web/.gitignore @@ -12,6 +12,11 @@ dist dist-ssr *.local +# Carpeta de Subidas +# --------------------- +uploads/ + + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/src/SIGCM.API/Controllers/ClientsController.cs b/src/SIGCM.API/Controllers/ClientsController.cs index 2e7b3b1..8aaa73a 100644 --- a/src/SIGCM.API/Controllers/ClientsController.cs +++ b/src/SIGCM.API/Controllers/ClientsController.cs @@ -27,9 +27,9 @@ public class ClientsController : ControllerBase } [HttpGet] - public async Task GetAll() + public async Task GetAll([FromQuery] string? q = null) { - var clients = await _repo.GetAllWithStatsAsync(); + var clients = await _repo.GetAllWithStatsAsync(q); return Ok(clients); } @@ -64,4 +64,30 @@ public class ClientsController : ControllerBase if (summary == null) return NotFound(); return Ok(summary); } + + [HttpPost("{id}/reset-password")] + [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")] + public async Task ResetPassword(int id) + { + // Establecemos 1234 como clave por defecto para el blanqueo + var passwordHash = BCrypt.Net.BCrypt.HashPassword("1234"); + await _repo.ResetPasswordAsync(id, passwordHash); + + // Audit Log + var userIdClaim = User.FindFirst("Id")?.Value; + if (int.TryParse(userIdClaim, out int userId)) + { + await _auditRepo.AddLogAsync(new AuditLog + { + UserId = userId, + Action = "RESET_CLIENT_PASSWORD", + EntityId = id, + EntityType = "User", + Details = "Contraseña de cliente restablecida a '1234' por el administrador.", + CreatedAt = DateTime.UtcNow + }); + } + + return Ok(new { message = "Contraseña restablecida a '1234' correctamente." }); + } } \ No newline at end of file diff --git a/src/SIGCM.API/Controllers/CouponsController.cs b/src/SIGCM.API/Controllers/CouponsController.cs index 9fa0201..d25860d 100644 --- a/src/SIGCM.API/Controllers/CouponsController.cs +++ b/src/SIGCM.API/Controllers/CouponsController.cs @@ -8,7 +8,6 @@ namespace SIGCM.API.Controllers; [ApiController] [Route("api/[controller]")] -[Authorize(Roles = "Admin")] public class CouponsController : ControllerBase { private readonly ICouponRepository _repository; @@ -21,13 +20,35 @@ public class CouponsController : ControllerBase } [HttpGet] + [Authorize(Roles = "Admin")] public async Task GetAll() { var coupons = await _repository.GetAllAsync(); return Ok(coupons); } + [HttpGet("validate/{code}")] + [Authorize] + public async Task GetByCode(string code) + { + var coupon = await _repository.GetByCodeAsync(code); + if (coupon == null || !coupon.IsActive) + return NotFound(new { message = "Cupón inválido o inactivo." }); + + if (coupon.ExpiryDate.HasValue && coupon.ExpiryDate.Value < DateTime.UtcNow) + return BadRequest(new { message = "El cupón ha expirado." }); + + if (coupon.MaxUsages.HasValue) + { + // Note: We might need a method to count total usages if we want strictly enforce total max usages here + // but for now, we return the coupon data so the frontend can display info. + } + + return Ok(coupon); + } + [HttpPost] + [Authorize(Roles = "Admin")] public async Task Create(CreateCouponDto dto) { // Simple manual mapping @@ -63,6 +84,7 @@ public class CouponsController : ControllerBase } [HttpDelete("{id}")] + [Authorize(Roles = "Admin")] public async Task Delete(int id) { await _repository.DeleteAsync(id); diff --git a/src/SIGCM.API/Controllers/ListingsController.cs b/src/SIGCM.API/Controllers/ListingsController.cs index d7ec418..0bd0933 100644 --- a/src/SIGCM.API/Controllers/ListingsController.cs +++ b/src/SIGCM.API/Controllers/ListingsController.cs @@ -43,25 +43,37 @@ public class ListingsController : ControllerBase int? clientId = null; var user = await _userRepo.GetByIdAsync(currentUserId); - // Lógica de Vinculación Usuario-Cliente - if (user != null) + // Lógica de Vinculación Distinguida (Web vs Mostrador) + if (dto.Origin == "Mostrador") { - clientId = user.ClientId; - - // Si el usuario no tiene cliente vinculado, pero envía datos de facturación (o registramos los que tiene) - if (clientId == null) + if (!string.IsNullOrWhiteSpace(dto.ClientDni)) { - string? dniToUse = dto.ClientDni ?? user.BillingTaxId; - if (!string.IsNullOrWhiteSpace(dniToUse)) + // En mostrador buscamos/creamos al cliente por su DNI, pero NO lo vinculamos al cajero + clientId = await _clientRepo.EnsureClientExistsAsync(dto.ClientName ?? "S/D", dto.ClientDni); + } + } + else + { + // Lógica para usuarios Web (Auto-servicio) + if (user != null) + { + clientId = user.ClientId; + + // Si el usuario no tiene cliente vinculado, pero envía datos de facturación, lo vinculamos + if (clientId == null) { - string? nameToUse = dto.ClientName ?? user.BillingName ?? user.Username; - clientId = await _clientRepo.EnsureClientExistsAsync(nameToUse!, dniToUse!); - - // Actualizamos el usuario con su nuevo nexo de cliente - user.ClientId = clientId; - user.BillingTaxId = dniToUse; - user.BillingName = nameToUse; - await _userRepo.UpdateAsync(user); + string? dniToUse = dto.ClientDni ?? user.BillingTaxId; + if (!string.IsNullOrWhiteSpace(dniToUse)) + { + string? nameToUse = dto.ClientName ?? user.BillingName ?? user.Username; + clientId = await _clientRepo.EnsureClientExistsAsync(nameToUse!, dniToUse!); + + // Actualizamos el usuario auto-servicio con su nuevo nexo de cliente + user.ClientId = clientId; + user.BillingTaxId = dniToUse; + user.BillingName = nameToUse; + await _userRepo.UpdateAsync(user); + } } } } diff --git a/src/SIGCM.API/Controllers/UsersController.cs b/src/SIGCM.API/Controllers/UsersController.cs index 124d8aa..711241e 100644 --- a/src/SIGCM.API/Controllers/UsersController.cs +++ b/src/SIGCM.API/Controllers/UsersController.cs @@ -25,9 +25,9 @@ public class UsersController : ControllerBase public async Task GetAll() { var users = await _repository.GetAllAsync(); - // Excluimos clientes para que solo aparezcan en su propio gestor + // Excluimos tanto clientes de mostrador como usuarios web para que solo aparezcan en su propio gestor var sanitized = users - .Where(u => u.Role != "Client") + .Where(u => u.Role != "Client" && u.Role != "User") .Select(u => new { u.Id, u.Username, u.Role, u.Email, u.CreatedAt }); diff --git a/src/SIGCM.Domain/Entities/Client.cs b/src/SIGCM.Domain/Entities/Client.cs index 58e2474..a213f56 100644 --- a/src/SIGCM.Domain/Entities/Client.cs +++ b/src/SIGCM.Domain/Entities/Client.cs @@ -8,4 +8,8 @@ public class Client public string? Email { get; set; } public string? Phone { get; set; } public string? Address { get; set; } + public string? TaxType { get; set; } + public string? Username { get; set; } + public bool IsActive { get; set; } + public string? Role { get; set; } } \ No newline at end of file diff --git a/src/SIGCM.Infrastructure/Data/DbInitializer.cs b/src/SIGCM.Infrastructure/Data/DbInitializer.cs index db968d3..ef8f713 100644 --- a/src/SIGCM.Infrastructure/Data/DbInitializer.cs +++ b/src/SIGCM.Infrastructure/Data/DbInitializer.cs @@ -291,6 +291,27 @@ END "; await connection.ExecuteAsync(dataMigrationSql); + // --- NORMALIZACIÓN DE CLIENTES SIN CLAVE (Usuario corto y clave 1234) --- + var upgradeClientsSql = @" + -- Juan Perez + UPDATE Users SET Username = 'jperez', PasswordHash = '$2a$11$EaJecdmgKfGhOUEmMzT/I.hD/9WES3GehL72xXyc2stXC26ncLNsO', MustChangePassword = 1 + WHERE Id = 1003 AND PasswordHash = 'N/A'; + + -- Maria Rodriguez + UPDATE Users SET Username = 'mrodriguez', PasswordHash = '$2a$11$EaJecdmgKfGhOUEmMzT/I.hD/9WES3GehL72xXyc2stXC26ncLNsO', MustChangePassword = 1 + WHERE Id = 1004 AND PasswordHash = 'N/A'; + + -- Inmobiliaria City Bell + UPDATE Users SET Username = 'inmocity', PasswordHash = '$2a$11$EaJecdmgKfGhOUEmMzT/I.hD/9WES3GehL72xXyc2stXC26ncLNsO', MustChangePassword = 1 + WHERE Id = 1005 AND PasswordHash = 'N/A'; + + -- General para otros clientes migrados (si hubiera) + UPDATE Users + SET PasswordHash = '$2a$11$EaJecdmgKfGhOUEmMzT/I.hD/9WES3GehL72xXyc2stXC26ncLNsO', MustChangePassword = 1 + WHERE PasswordHash = 'N/A' AND Role = 'Client'; + "; + await connection.ExecuteAsync(upgradeClientsSql); + // --- SEED DE DATOS (Usuario Admin) --- var adminCount = await connection.ExecuteScalarAsync("SELECT COUNT(1) FROM Users WHERE Username = 'admin'"); diff --git a/src/SIGCM.Infrastructure/Repositories/ClientRepository.cs b/src/SIGCM.Infrastructure/Repositories/ClientRepository.cs index a2ddf90..f3b64be 100644 --- a/src/SIGCM.Infrastructure/Repositories/ClientRepository.cs +++ b/src/SIGCM.Infrastructure/Repositories/ClientRepository.cs @@ -22,7 +22,8 @@ public class ClientRepository Id, ISNULL(BillingName, Username) as Name, ISNULL(BillingTaxId, '') as DniOrCuit, - Email, Phone, BillingAddress as Address + Email, Phone, BillingAddress as Address, BillingTaxType as TaxType, + Username, IsActive, Role FROM Users WHERE BillingName LIKE @Query OR BillingTaxId LIKE @Query OR Username LIKE @Query ORDER BY BillingName"; @@ -54,23 +55,34 @@ public class ClientRepository } } - // Obtener todos con estadísticas desde Users - public async Task> GetAllWithStatsAsync() + // Obtener con estadísticas desde Users (con filtro y límite para performance) + public async Task> GetAllWithStatsAsync(string? searchTerm = null) { using var conn = _db.CreateConnection(); var sql = @" - SELECT + SELECT TOP 100 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, + ISNULL(u.BillingTaxType, 'Consumidor Final') as taxType, + u.Username as username, + u.IsActive as isActive, + u.Role as role, (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); + WHERE Role IN ('Client', 'User')"; + + if (!string.IsNullOrWhiteSpace(searchTerm)) + { + sql += " AND (u.BillingName LIKE @Query OR u.BillingTaxId LIKE @Query OR u.Username LIKE @Query)"; + } + + sql += " ORDER BY name"; + + return await conn.QueryAsync(sql, new { Query = $"%{searchTerm}%" }); } public async Task UpdateAsync(Client client) @@ -82,7 +94,10 @@ public class ClientRepository BillingTaxId = @DniOrCuit, Email = @Email, Phone = @Phone, - BillingAddress = @Address + BillingAddress = @Address, + BillingTaxType = @TaxType, + Username = @Username, + IsActive = @IsActive WHERE Id = @Id"; await conn.ExecuteAsync(sql, client); } @@ -95,6 +110,8 @@ public class ClientRepository u.Id, ISNULL(u.BillingName, u.Username) as Name, u.BillingTaxId as DniOrCuit, u.Email, u.Phone, u.BillingAddress as Address, + u.BillingTaxType as TaxType, + u.Username, u.IsActive, u.Role, (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, @@ -112,4 +129,11 @@ public class ClientRepository return await conn.QuerySingleOrDefaultAsync(sql, new { Id = clientId }); } + + public async Task ResetPasswordAsync(int clientId, string passwordHash) + { + using var conn = _db.CreateConnection(); + var sql = "UPDATE Users SET PasswordHash = @Hash, MustChangePassword = 1 WHERE Id = @Id"; + await conn.ExecuteAsync(sql, new { Hash = passwordHash, Id = clientId }); + } } \ No newline at end of file diff --git a/src/SIGCM.Infrastructure/Repositories/ListingRepository.cs b/src/SIGCM.Infrastructure/Repositories/ListingRepository.cs index 5c2bbaa..ffb64f7 100644 --- a/src/SIGCM.Infrastructure/Repositories/ListingRepository.cs +++ b/src/SIGCM.Infrastructure/Repositories/ListingRepository.cs @@ -192,11 +192,15 @@ public class ListingRepository : IListingRepository { using var conn = _connectionFactory.CreateConnection(); var sql = @" + DECLARE @CId INT = (SELECT ClientId FROM Users WHERE Id = @UserId); + 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 + WHERE l.UserId = @UserId + OR l.ClientId = @UserId + OR (l.ClientId = @CId AND l.ClientId IS NOT NULL) ORDER BY l.CreatedAt DESC"; return await conn.QueryAsync(sql, new { UserId = userId });