diff --git a/ROADMAP.md b/ROADMAP.md index ee83344..ebd026a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,9 +14,9 @@ | Backend .NET 10 — Skeleton (sln + 4 proyectos + NuGet) | ✅ CREADO | | Entidades / DTOs / Interfaces (Ventas.Core) | ✅ CREADO | | Infrastructure (EF Core + Dapper + Repos) | ✅ PARCIAL (DbContext + LeadRepository listos) | -| Services (lógica de negocio) | ❌ PENDIENTE | -| API Controllers | ❌ PENDIENTE | -| Auth JWT | ❌ PENDIENTE | +| Services (lógica de negocio) | ✅ PARCIAL (LeadService, UsuarioService, JwtService) | +| API Controllers | ✅ PARCIAL (AuthController, LeadController) | +| Auth JWT | ✅ CREADO (JwtService + middleware) | | Reportes QuestPDF (17 reportes) | ❌ PENDIENTE | | ServicesExternos.API | ❌ PENDIENTE | | Frontend Next.js | ❌ PENDIENTE | @@ -42,17 +42,19 @@ - [ ] Configurations/ (EF mapping) - [ ] Resto repos (Alumno, Contrato, Cotizacion, etc.) - [ ] SpComplexQueries.cs (Dapper multi-resultset) -- [ ] **1.4 Ventas.Services — Lógica de Negocio** (~2 sem) - - [ ] LeadService.cs + resto servicios (~15) -- [ ] **1.5 Ventas.API — Controladores REST** (~1 sem) - - [ ] AuthController.cs - - [ ] LeadController.cs + resto controllers (~20) - - [ ] Program.cs completo (DI, JWT, CORS) - - [ ] appsettings.json con connection strings -- [ ] **1.6 Auth JWT** (~3 días) - - [ ] JwtMiddleware.cs - - [ ] Login endpoint funcional - - [ ] Claims → cookies HttpOnly +- [x] **1.4 Ventas.Services — Lógica de Negocio** (~2 sem) + - [x] LeadService.cs, UsuarioService.cs, JwtService.cs + - [ ] Resto servicios (Contrato, Cotizacion, Alumno, etc.) +- [x] **1.5 Ventas.API — Controladores REST** (~1 sem) + - [x] AuthController.cs (login + perfil) + - [x] LeadController.cs (CRUD + actividades + pagos) + - [x] Program.cs completo (DI, JWT, CORS) + - [x] appsettings.json con connection strings + - [ ] Resto controllers (Contrato, Cotizacion, Alumno, etc.) +- [x] **1.6 Auth JWT** (~3 días) + - [x] JwtService.cs (generación de tokens) + - [x] Login endpoint funcional + - [x] Claims → cookies HttpOnly - [ ] **1.7 Reportes QuestPDF** (~2-3 sem) - [ ] ReportService.cs (base) - [ ] 17 reportes (Contrato, Cotización, Arqueo, etc.) @@ -103,7 +105,8 @@ | Fecha | Fase | Qué se hizo | Siguiente paso | |-------|------|-------------|----------------| -| 2026-07-07 | F1 | Entities (25), Enums (4), DTOs, Interfaces (6), DbContext, LeadRepository, LeadQueryRepository, DI setup, appsettings | Resto repos (Contrato, Cotizacion, Alumno) + Controllers | +| 2026-07-07 | F1 | Entities (25), Enums, DTOs, Interfaces, DbContext, LeadRepository + LeadQueryRepository, DI, JWT, appsettings | Resto repos + Controllers | +| 2026-07-07 | F1 | LeadService, UsuarioService, JwtService, AuthController, LeadController | Resto Services + Controllers | | | | | | | | | | | diff --git a/backend/src/Ventas.API/Controllers/AuthController.cs b/backend/src/Ventas.API/Controllers/AuthController.cs new file mode 100644 index 0000000..b0c8e7e --- /dev/null +++ b/backend/src/Ventas.API/Controllers/AuthController.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Mvc; +using Ventas.Core.DTOs; +using Ventas.Services; + +namespace Ventas.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly UsuarioService _usuarioService; + private readonly JwtService _jwtService; + + public AuthController(UsuarioService usuarioService, JwtService jwtService) + { + _usuarioService = usuarioService; + _jwtService = jwtService; + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest request) + { + var usuario = await _usuarioService.LoginAsync(request); + if (usuario == null) + return Unauthorized(new { mensaje = "Credenciales inválidas" }); + + var token = _jwtService.GenerateToken(request.Rut, usuario.Nombre, usuario.Sede ?? ""); + + Response.Cookies.Append("SAM_TOKEN", token, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Expires = DateTime.UtcNow.AddMinutes(30) + }); + + return Ok(new + { + nombre = usuario.Nombre, + sede = usuario.Sede, + token + }); + } + + [HttpGet("perfil")] + public async Task Perfil([FromQuery] string usuarioId) + { + var perfil = await _usuarioService.PerfilAsync(usuarioId); + if (perfil == null) + return NotFound(); + + return Ok(perfil); + } +} diff --git a/backend/src/Ventas.API/Controllers/LeadController.cs b/backend/src/Ventas.API/Controllers/LeadController.cs new file mode 100644 index 0000000..4901e35 --- /dev/null +++ b/backend/src/Ventas.API/Controllers/LeadController.cs @@ -0,0 +1,110 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Ventas.Core.DTOs; +using Ventas.Services; + +namespace Ventas.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class LeadController : ControllerBase +{ + private readonly LeadService _leadService; + + public LeadController(LeadService leadService) + { + _leadService = leadService; + } + + [HttpGet("{id}")] + public async Task GetById(int id) + { + var result = await _leadService.BuscarIDAsync(id); + return Ok(result); + } + + [HttpGet("buscar")] + public async Task Buscar([FromQuery] int ejecutivo, [FromQuery] int estado, [FromQuery] int dias) + { + var result = await _leadService.BuscarAsync(ejecutivo, estado, dias); + return Ok(result); + } + + [HttpGet("nuevos")] + public async Task Nuevos([FromQuery] int ejecutivo) + { + var result = await _leadService.BuscarNuevosAsync(ejecutivo); + return Ok(result); + } + + [HttpGet("{id}/actividades")] + public async Task Actividades(int id, [FromQuery] string tipo) + { + var result = await _leadService.ActividadesAsync(id, tipo); + return Ok(result); + } + + [HttpGet("montos")] + public async Task Montos([FromQuery] int ejecutivo) + { + var result = await _leadService.MontosAsync(ejecutivo); + return Ok(result); + } + + [HttpGet("motivos-perdido")] + public async Task MotivosPerdido() + { + var result = await _leadService.MotivosPerdidoAsync(); + return Ok(result); + } + + [HttpPost] + public async Task Ingresar([FromBody] LeadCreateDto dto) + { + var result = await _leadService.IngresarAsync(dto); + return Ok(new { mensaje = result }); + } + + [HttpPost("{id}/actividad")] + public async Task IngresarActividad(int id, [FromBody] ActividadCreateDto dto, [FromQuery] string usuarioId) + { + var result = await _leadService.IngresarActividadAsync(id, dto.Tipo, dto.Descripcion, usuarioId); + return Ok(new { mensaje = result }); + } + + [HttpPut("{id}/estado")] + public async Task ActualizarEstado(int id, [FromQuery] int estadoId) + { + var result = await _leadService.EstadoUpdateAsync(id, estadoId); + return Ok(new { mensaje = result }); + } + + [HttpPut("{id}/producto")] + public async Task ActualizarProducto(int id, [FromQuery] string producto) + { + var result = await _leadService.ActualizarProductoAsync(id, producto); + return Ok(new { mensaje = result }); + } + + [HttpPut("{id}/contacto")] + public async Task ActualizarContacto(int id, [FromBody] ContactoUpdateDto dto) + { + var result = await _leadService.ActualizarContactoAsync(id, dto.Nombre ?? "", dto.Mail ?? "", dto.Telefono ?? "", dto.Rut ?? ""); + return Ok(new { mensaje = result }); + } + + [HttpPost("{id}/perder")] + public async Task IngresarLeadPerdido(int id, [FromQuery] int motivo, [FromQuery] int estado) + { + var result = await _leadService.IngresarLeadPerdidoAsync(id, motivo, estado); + return Ok(new { mensaje = result }); + } + + [HttpPost("{id}/pago")] + public async Task IngresarPago(int id, [FromBody] PagoLeadDto dto) + { + var result = await _leadService.IngresarPagoAsync(dto.LeadId, dto.CotizacionId, dto.FormaPago, dto.Monto, dto.CodigoAutorizacion ?? "", dto.DigitoTarjeta ?? "", dto.Cuotas); + return Ok(new { mensaje = result }); + } +} diff --git a/backend/src/Ventas.API/Program.cs b/backend/src/Ventas.API/Program.cs index d290b03..65a262e 100644 --- a/backend/src/Ventas.API/Program.cs +++ b/backend/src/Ventas.API/Program.cs @@ -5,6 +5,7 @@ using Microsoft.IdentityModel.Tokens; using Ventas.Infrastructure.Data; using Ventas.Infrastructure.Repositories; using Ventas.Core.Interfaces; +using Ventas.Services; var builder = WebApplication.CreateBuilder(args); @@ -21,7 +22,16 @@ builder.Services.AddScoped(sp => builder.Services.AddScoped(sp => new LeadQueryRepository(connectionString)); +builder.Services.AddScoped(sp => + new LeadService(connectionString)); +builder.Services.AddScoped(sp => + new UsuarioService(connectionString)); + var jwtSecret = builder.Configuration["Jwt:Secret"] ?? "default-dev-secret-change-in-production"; +var jwtExpiration = int.Parse(builder.Configuration["Jwt:ExpirationMinutes"] ?? "30"); +builder.Services.AddScoped(sp => + new JwtService(jwtSecret, jwtExpiration)); + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { diff --git a/backend/src/Ventas.Services/JwtService.cs b/backend/src/Ventas.Services/JwtService.cs new file mode 100644 index 0000000..1da842d --- /dev/null +++ b/backend/src/Ventas.Services/JwtService.cs @@ -0,0 +1,38 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace Ventas.Services; + +public class JwtService +{ + private readonly string _secret; + private readonly int _expirationMinutes; + + public JwtService(string secret, int expirationMinutes) + { + _secret = secret; + _expirationMinutes = expirationMinutes; + } + + public string GenerateToken(string usuarioId, string nombre, string sede) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, usuarioId), + new Claim(ClaimTypes.Name, nombre), + new Claim("sede", sede) + }; + + var token = new JwtSecurityToken( + claims: claims, + expires: DateTime.UtcNow.AddMinutes(_expirationMinutes), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/backend/src/Ventas.Services/LeadService.cs b/backend/src/Ventas.Services/LeadService.cs new file mode 100644 index 0000000..2ec4919 --- /dev/null +++ b/backend/src/Ventas.Services/LeadService.cs @@ -0,0 +1,160 @@ +using Dapper; +using Npgsql; +using Ventas.Core.DTOs; + +namespace Ventas.Services; + +public class LeadService +{ + private readonly string _connectionString; + + public LeadService(string connectionString) + { + _connectionString = connectionString; + } + + public async Task>> BuscarAsync(int ejecutivo, int estado, int dias) + { + using var connection = new NpgsqlConnection(_connectionString); + var rows = await connection.QueryAsync( + "sige_sam_v3.BuscarLeadV3", + new { ejecutivo, estadolead = estado, filtro = dias }, + commandType: System.Data.CommandType.StoredProcedure); + + return rows.Select(r => (Dictionary)(IDictionary)r!); + } + + public async Task>> BuscarIDAsync(int id) + { + using var connection = new NpgsqlConnection(_connectionString); + var rows = await connection.QueryAsync( + "sige_sam_v3.BuscarLeadID", + new { id }, + commandType: System.Data.CommandType.StoredProcedure); + + return rows.Select(r => (Dictionary)(IDictionary)r!); + } + + public async Task>> BuscarNuevosAsync(int ejecutivo) + { + using var connection = new NpgsqlConnection(_connectionString); + var rows = await connection.QueryAsync( + "sige_sam_v3.BuscarLeadNuevos", + new { ejecutivo }, + commandType: System.Data.CommandType.StoredProcedure); + + return rows.Select(r => (Dictionary)(IDictionary)r!); + } + + public async Task IngresarAsync(LeadCreateDto dto) + { + using var connection = new NpgsqlConnection(_connectionString); + await connection.ExecuteAsync( + "sige_sam_v3.GrabaLead", + new { nombrelead = dto.Nombre, maillead = dto.Mail, telefonolead = dto.Telefono, productolead = dto.Producto, contactolead = dto.Contacto, ejecutivoid = dto.EjecutivoId }, + commandType: System.Data.CommandType.StoredProcedure); + return "ok"; + } + + public async Task IngresarV2Async(LeadCreateDto dto) + { + using var connection = new NpgsqlConnection(_connectionString); + var result = await connection.QuerySingleOrDefaultAsync( + "sige_sam_v3.GrabaLead", + new { nombrelead = dto.Nombre, maillead = dto.Mail, telefonolead = dto.Telefono, productolead = dto.Producto, contactolead = dto.Contacto, ejecutivoid = dto.EjecutivoId }, + commandType: System.Data.CommandType.StoredProcedure); + return result ?? "ok"; + } + + public async Task IngresarPagoAsync(int lead, int coti, string pago, int monto, string cod, string dig, int cuota) + { + using var connection = new NpgsqlConnection(_connectionString); + await connection.ExecuteAsync( + "sige_sam_v3.GrabaPagoLead", + new { leadid = lead, cotiid = coti, formapago = pago, valor = monto, codauto = cod, digtar = dig, cantcouta = cuota }, + commandType: System.Data.CommandType.StoredProcedure); + return "ok"; + } + + public async Task ActualizarProductoAsync(int leadId, string producto) + { + using var connection = new NpgsqlConnection(_connectionString); + await connection.ExecuteAsync( + "sam.ActualuzarLeadProducto", + new { leadid = leadId, productlead = producto }, + commandType: System.Data.CommandType.StoredProcedure); + return "ok"; + } + + public async Task>> MontosAsync(int ejecutivo) + { + using var connection = new NpgsqlConnection(_connectionString); + var rows = await connection.QueryAsync( + "sige_sam_v3.BuscarLeadMontos", + new { ejecutivo }, + commandType: System.Data.CommandType.StoredProcedure); + + return rows.Select(r => (Dictionary)(IDictionary)r!); + } + + public async Task>> ActividadesAsync(int leadId, string tipo) + { + using var connection = new NpgsqlConnection(_connectionString); + var rows = await connection.QueryAsync( + "sige_sam_v3.BuscarActividadesLead", + new { id = leadId, tipoactividad = tipo }, + commandType: System.Data.CommandType.StoredProcedure); + + return rows.Select(r => (Dictionary)(IDictionary)r!); + } + + public async Task IngresarActividadAsync(int leadId, string tipo, string descripcion, string usuarioId) + { + using var connection = new NpgsqlConnection(_connectionString); + await connection.ExecuteAsync( + "sige_sam_v3.GrabaActividadesLead", + new { leadid = leadId, tipo, descripcion, usuarioid = usuarioId }, + commandType: System.Data.CommandType.StoredProcedure); + return "ok"; + } + + public async Task EstadoUpdateAsync(int leadId, int estadoId) + { + using var connection = new NpgsqlConnection(_connectionString); + await connection.ExecuteAsync( + "sige_sam_v3.ActualizarEstadoLead", + new { leadid = leadId, estadolead = estadoId }, + commandType: System.Data.CommandType.StoredProcedure); + return "ok"; + } + + public async Task>> MotivosPerdidoAsync() + { + using var connection = new NpgsqlConnection(_connectionString); + var rows = await connection.QueryAsync( + "sige_sam_v3.BuscarMotivoLeadPerdido", + commandType: System.Data.CommandType.StoredProcedure); + + return rows.Select(r => (Dictionary)(IDictionary)r!); + } + + public async Task IngresarLeadPerdidoAsync(int leadId, int motivo, int estado) + { + using var connection = new NpgsqlConnection(_connectionString); + await connection.ExecuteAsync( + "sige_sam_v3.Lead_Ingresar_Perdido", + new { leadid = leadId, motivo, estado }, + commandType: System.Data.CommandType.StoredProcedure); + return "ok"; + } + + public async Task ActualizarContactoAsync(int leadId, string nombre, string mail, string fono, string rut) + { + using var connection = new NpgsqlConnection(_connectionString); + await connection.ExecuteAsync( + "sige_sam_v3.ActualizaLeadContacto", + new { leadid = leadId, nombrecontacto = nombre, mailcontacto = mail, fonocontacto = fono, rutcontacto = rut }, + commandType: System.Data.CommandType.StoredProcedure); + return "ok"; + } +} diff --git a/backend/src/Ventas.Services/UsuarioService.cs b/backend/src/Ventas.Services/UsuarioService.cs new file mode 100644 index 0000000..5c126b8 --- /dev/null +++ b/backend/src/Ventas.Services/UsuarioService.cs @@ -0,0 +1,55 @@ +using Dapper; +using Npgsql; +using Ventas.Core.DTOs; + +namespace Ventas.Services; + +public class UsuarioService +{ + private readonly string _connectionString; + + public UsuarioService(string connectionString) + { + _connectionString = connectionString; + } + + public async Task LoginAsync(LoginRequest request) + { + using var connection = new NpgsqlConnection(_connectionString); + var data = await connection.QueryAsync( + "sam.BuscarUsuario", + new { userid = request.Rut, passid = request.Clave }, + commandType: System.Data.CommandType.StoredProcedure); + + var user = data.FirstOrDefault(); + if (user == null) + return null; + + var dict = (IDictionary)user; + return new LoginResponse + { + Nombre = dict["Nombres"]?.ToString() ?? "", + Sede = dict["idSede"]?.ToString() + }; + } + + public async Task PerfilAsync(string usuarioId) + { + using var connection = new NpgsqlConnection(_connectionString); + var data = await connection.QueryAsync( + "sige_sam_v3.BuscarPerfilUsuario", + new { usuario = usuarioId }, + commandType: System.Data.CommandType.StoredProcedure); + + var user = data.FirstOrDefault(); + if (user == null) return null; + + var dict = (IDictionary)user; + return new LoginResponse + { + Nombre = dict["Nombres"]?.ToString() ?? "", + Sede = dict["idSede"]?.ToString(), + Perfil = dict["Perfil"]?.ToString() + }; + } +}