using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using MotoresArgentinosV2.Infrastructure.Data; using MotoresArgentinosV2.Core.Entities; using MotoresArgentinosV2.Infrastructure.Services; using MotoresArgentinosV2.Core.Interfaces; using MotoresArgentinosV2.Core.DTOs; using Microsoft.AspNetCore.Authorization; namespace MotoresArgentinosV2.API.Controllers; [ApiController] [Route("api/[controller]")] public class AdsV2Controller : ControllerBase { private readonly MotoresV2DbContext _context; private readonly IImageStorageService _imageService; private readonly IIdentityService _identityService; public AdsV2Controller(MotoresV2DbContext context, IImageStorageService imageService, IIdentityService identityService) { _context = context; _imageService = imageService; _identityService = identityService; } private int GetCurrentUserId() { return int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0"); } private bool IsUserAdmin() { return User.IsInRole("Admin"); } [HttpGet] public async Task GetAll( [FromQuery] string? q, [FromQuery] string? c, [FromQuery] int? minPrice, [FromQuery] int? maxPrice, [FromQuery] string? currency, [FromQuery] int? minYear, [FromQuery] int? maxYear, [FromQuery] int? userId, [FromQuery] int? brandId, [FromQuery] int? modelId, [FromQuery] string? fuel, [FromQuery] string? transmission, [FromQuery] string? color, [FromQuery] bool? isFeatured) { var query = _context.Ads .Include(a => a.Photos) .Include(a => a.Features) .Include(a => a.Brand) .Include(a => a.Model) .AsQueryable(); if (isFeatured.HasValue) query = query.Where(a => a.IsFeatured == isFeatured.Value); if (brandId.HasValue) query = query.Where(a => a.BrandID == brandId.Value); if (modelId.HasValue) query = query.Where(a => a.ModelID == modelId.Value); if (!string.IsNullOrEmpty(currency)) { query = query.Where(a => a.Currency == currency); } if (minPrice.HasValue) query = query.Where(a => a.Price >= minPrice.Value); if (maxPrice.HasValue) query = query.Where(a => a.Price <= maxPrice.Value); if (!string.IsNullOrEmpty(fuel)) query = query.Where(a => a.FuelType == fuel || a.Features.Any(f => f.FeatureKey == "Combustible" && f.FeatureValue == fuel)); if (!string.IsNullOrEmpty(transmission)) query = query.Where(a => a.Transmission == transmission || a.Features.Any(f => f.FeatureKey == "Transmision" && f.FeatureValue == transmission)); if (!string.IsNullOrEmpty(color)) query = query.Where(a => a.Color == color || a.Features.Any(f => f.FeatureKey == "Color" && f.FeatureValue == color)); if (!string.IsNullOrEmpty(Request.Query["segment"])) { var segment = Request.Query["segment"].ToString(); query = query.Where(a => a.Segment == segment); } if (!string.IsNullOrEmpty(Request.Query["location"])) { var loc = Request.Query["location"].ToString(); query = query.Where(a => a.Location != null && a.Location.Contains(loc)); } if (!string.IsNullOrEmpty(Request.Query["condition"])) { var cond = Request.Query["condition"].ToString(); query = query.Where(a => a.Condition == cond); } if (!string.IsNullOrEmpty(Request.Query["steering"])) { var st = Request.Query["steering"].ToString(); query = query.Where(a => a.Steering == st); } if (int.TryParse(Request.Query["doorCount"], out int dc)) { query = query.Where(a => a.DoorCount == dc); } if (!userId.HasValue) { var publicStatuses = new[] { (int)AdStatusEnum.Active, (int)AdStatusEnum.Sold, (int)AdStatusEnum.Reserved }; query = query.Where(a => publicStatuses.Contains(a.StatusID)); } else { query = query.Where(a => a.UserID == userId.Value); } // --- LÓGICA DE BÚSQUEDA POR PALABRAS --- if (!string.IsNullOrEmpty(q)) { // 1. Dividimos el término de búsqueda en palabras individuales. var keywords = q.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); // 2. Por cada palabra, agregamos una condición 'Where'. // Esto crea un AND implícito: el aviso debe coincidir con TODAS las palabras. foreach (var keyword in keywords) { var lowerKeyword = keyword.ToLower(); query = query.Where(a => (a.Brand != null && a.Brand.Name.ToLower().Contains(lowerKeyword)) || (a.Model != null && a.Model.Name.ToLower().Contains(lowerKeyword)) || (a.VersionName != null && a.VersionName.ToLower().Contains(lowerKeyword)) ); } } if (!string.IsNullOrEmpty(c)) { int typeId = c == "EAUTOS" ? 1 : (c == "EMOTOS" ? 2 : 0); if (typeId > 0) query = query.Where(a => a.VehicleTypeID == typeId); } if (minPrice.HasValue) query = query.Where(a => a.Price >= minPrice.Value); if (maxPrice.HasValue) query = query.Where(a => a.Price <= maxPrice.Value); if (minYear.HasValue) query = query.Where(a => a.Year >= minYear.Value); if (maxYear.HasValue) query = query.Where(a => a.Year <= maxYear.Value); var results = await query.OrderByDescending(a => a.IsFeatured) .ThenByDescending(a => a.CreatedAt) .Select(a => new { id = a.AdID, brandName = a.Brand != null ? a.Brand.Name : null, versionName = a.VersionName, price = a.Price, currency = a.Currency, year = a.Year, km = a.KM, image = a.Photos.OrderBy(p => p.SortOrder).Select(p => p.FilePath).FirstOrDefault(), isFeatured = a.IsFeatured, statusId = a.StatusID, viewsCounter = a.ViewsCounter, createdAt = a.CreatedAt, location = a.Location, fuelType = a.FuelType, color = a.Color, segment = a.Segment, condition = a.Condition, doorCount = a.DoorCount, transmission = a.Transmission, steering = a.Steering }) .ToListAsync(); return Ok(results); } [HttpGet("search-suggestions")] public async Task GetSearchSuggestions([FromQuery] string term) { if (string.IsNullOrEmpty(term) || term.Length < 2) { return Ok(new List()); } // Buscar en Marcas var brands = await _context.Brands .Where(b => b.Name.Contains(term)) .Select(b => b.Name) .ToListAsync(); // Buscar en Modelos var models = await _context.Models .Where(m => m.Name.Contains(term)) .Select(m => m.Name) .ToListAsync(); // Combinar, eliminar duplicados y tomar los primeros 5 var suggestions = brands.Concat(models) .Distinct() .OrderBy(s => s) .Take(5) .ToList(); return Ok(suggestions); } [HttpGet("{id}")] public async Task GetById(int id) { var ad = await _context.Ads .Include(a => a.Photos) .Include(a => a.Features) .Include(a => a.Brand) .Include(a => a.Model) .Include(a => a.User) .FirstOrDefaultAsync(a => a.AdID == id); if (ad == null) return NotFound(); // 1. SEGURIDAD Y CONTROL DE ACCESO // Obtenemos datos del usuario actual var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; int currentUserId = int.TryParse(userIdStr, out int uid) ? uid : 0; bool isAdmin = User.IsInRole("Admin"); bool isOwner = ad.UserID == currentUserId; // Definimos qué estados son visibles para todo el mundo var publicStatuses = new[] { (int)AdStatusEnum.Active, (int)AdStatusEnum.Reserved, (int)AdStatusEnum.Sold }; bool isPublic = publicStatuses.Contains(ad.StatusID); // REGLA A: Si está ELIMINADO (9), nadie lo ve por URL pública (solo admin podría en dashboard) if (ad.StatusID == (int)AdStatusEnum.Deleted && !isAdmin) { return NotFound(); } // REGLA B: Si NO es público (ej: Vencido, Borrador, Pausado) y NO es dueño ni admin -> 404 if (!isPublic && !isOwner && !isAdmin) { return NotFound(); } // 2. LÓGICA DE CONTEO try { // REGLA: Solo contamos si NO es el dueño (isOwner ya calculado arriba) if (!isOwner) { var userIp = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0"; var cutoffDate = DateTime.UtcNow.AddHours(-24); // Verificamos si esta IP ya vio este aviso en las últimas 24hs var alreadyViewed = await _context.AdViewLogs .AnyAsync(v => v.AdID == id && v.IPAddress == userIp && v.ViewDate > cutoffDate); if (!alreadyViewed) { // A. Registrar Log _context.AdViewLogs.Add(new AdViewLog { AdID = id, IPAddress = userIp, ViewDate = DateTime.UtcNow }); // B. Incrementar Contador await _context.Ads .Where(a => a.AdID == id) .ExecuteUpdateAsync(s => s.SetProperty(a => a.ViewsCounter, a => a.ViewsCounter + 1)); await _context.SaveChangesAsync(); } } } catch (Exception) { // Ignoramos errores de conteo para no bloquear la visualización } // 3. RESPUESTA return Ok(new { adID = ad.AdID, userID = ad.UserID, ownerUserType = ad.User?.UserType, vehicleTypeID = ad.VehicleTypeID, brandID = ad.BrandID, modelID = ad.ModelID, versionName = ad.VersionName, brandName = ad.Brand?.Name, year = ad.Year, km = ad.KM, price = ad.Price, currency = ad.Currency, description = ad.Description, isFeatured = ad.IsFeatured, statusID = ad.StatusID, createdAt = ad.CreatedAt, publishedAt = ad.PublishedAt, expiresAt = ad.ExpiresAt, legacyAdID = ad.LegacyAdID, viewsCounter = ad.ViewsCounter, fuelType = ad.FuelType, color = ad.Color, segment = ad.Segment, location = ad.Location, condition = ad.Condition, doorCount = ad.DoorCount, transmission = ad.Transmission, steering = ad.Steering, contactPhone = ad.ContactPhone, contactEmail = ad.ContactEmail, displayContactInfo = ad.DisplayContactInfo, photos = ad.Photos.Select(p => new { p.PhotoID, p.FilePath, p.IsCover, p.SortOrder }), features = ad.Features.Select(f => new { f.FeatureKey, f.FeatureValue }), brand = ad.Brand != null ? new { id = ad.Brand.BrandID, name = ad.Brand.Name } : null, model = ad.Model != null ? new { id = ad.Model.ModelID, name = ad.Model.Name } : null }); } [HttpPost] public async Task Create([FromBody] CreateAdRequestDto request) { var currentUserId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0"); var userRole = User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value; bool isAdmin = userRole == "Admin"; int finalUserId = currentUserId; if (isAdmin) { if (request.TargetUserID.HasValue) { finalUserId = request.TargetUserID.Value; } else if (!string.IsNullOrEmpty(request.GhostUserEmail)) { // Pasamos nombre y apellido por separado var ghost = await _identityService.CreateGhostUserAsync( request.GhostUserEmail, request.GhostFirstName ?? "Usuario", request.GhostLastName ?? "", request.GhostUserPhone ?? "" ); finalUserId = ghost.UserID; } } // 🟢 LÓGICA DE MODELO DINÁMICO int finalModelId = request.ModelID; // Si el front manda 0 (modelo nuevo escrito a mano) if (finalModelId == 0 && !string.IsNullOrEmpty(request.VersionName)) { // Intentamos extraer el nombre del modelo desde el VersionName var brand = await _context.Brands.FindAsync(request.BrandID); string modelNameCandidate = request.VersionName; // Si el nombre de versión empieza con la marca (ej: "Fiat 600"), quitamos la marca if (brand != null && modelNameCandidate.StartsWith(brand.Name, StringComparison.OrdinalIgnoreCase)) { modelNameCandidate = modelNameCandidate.Substring(brand.Name.Length).Trim(); } // Si quedó vacío o es muy genérico, usar un default if (string.IsNullOrWhiteSpace(modelNameCandidate)) modelNameCandidate = "Modelo Desconocido"; // Opcional: Tomar solo la primera palabra como nombre del modelo para agrupar mejor // var firstWord = modelNameCandidate.Split(' ')[0]; // if (firstWord.Length > 2) modelNameCandidate = firstWord; // Buscar si ya existe este modelo para esta marca var existingModel = await _context.Models .FirstOrDefaultAsync(m => m.BrandID == request.BrandID && m.Name == modelNameCandidate); if (existingModel != null) { finalModelId = existingModel.ModelID; } else { // Crear Nuevo Modelo var newModel = new Model { BrandID = request.BrandID, Name = modelNameCandidate }; _context.Models.Add(newModel); await _context.SaveChangesAsync(); // Guardar para obtener ID finalModelId = newModel.ModelID; } } var ad = new Ad { UserID = finalUserId, VehicleTypeID = request.VehicleTypeID, BrandID = request.BrandID, ModelID = finalModelId, // Usamos el ID resuelto VersionName = request.VersionName, Year = request.Year, KM = request.KM, Price = request.Price, Currency = request.Currency, Description = request.Description, IsFeatured = request.IsFeatured, FuelType = request.FuelType, Color = request.Color, Segment = request.Segment, Location = request.Location, Condition = request.Condition, DoorCount = request.DoorCount, Transmission = request.Transmission, Steering = request.Steering, ContactPhone = request.ContactPhone, ContactEmail = request.ContactEmail, DisplayContactInfo = request.DisplayContactInfo, CreatedAt = DateTime.UtcNow }; if (isAdmin) { ad.StatusID = (int)AdStatusEnum.Active; ad.PublishedAt = DateTime.UtcNow; ad.ExpiresAt = DateTime.UtcNow.AddDays(30); } else { ad.StatusID = (int)AdStatusEnum.Draft; } _context.Ads.Add(ad); await _context.SaveChangesAsync(); // 📝 AUDITORÍA var brandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? ""; _context.AuditLogs.Add(new AuditLog { Action = "AD_CREATED", Entity = "Ad", EntityID = ad.AdID, UserID = currentUserId, Details = $"Aviso '{brandName} {ad.VersionName}' creado. Estado inicial: {ad.StatusID}" }); await _context.SaveChangesAsync(); return CreatedAtAction(nameof(GetById), new { id = ad.AdID }, ad); } [HttpGet("brands/{vehicleTypeId}")] public async Task GetBrands(int vehicleTypeId) { var brands = await _context.Set() .Where(b => b.VehicleTypeID == vehicleTypeId) .Select(b => new { id = b.BrandID, name = b.Name }) .ToListAsync(); return Ok(brands); } [HttpGet("models/{brandId}")] public async Task GetModels(int brandId) { var models = await _context.Set() .Where(m => m.BrandID == brandId) .Select(m => new { id = m.ModelID, name = m.Name }) .ToListAsync(); return Ok(models); } [HttpGet("models/search")] public async Task SearchModels([FromQuery] int brandId, [FromQuery] string query) { if (string.IsNullOrEmpty(query) || query.Length < 2) return Ok(new List()); var models = await _context.Models .Where(m => m.BrandID == brandId && m.Name.Contains(query)) .OrderBy(m => m.Name) .Take(20) .Select(m => new { id = m.ModelID, name = m.Name }) .ToListAsync(); return Ok(models); } [HttpPost("{id}/upload-photos")] public async Task UploadPhotos(int id, [FromForm] IFormFileCollection files) { if (files == null || files.Count == 0) return BadRequest("No se recibieron archivos en la petición."); var ad = await _context.Ads.Include(a => a.Photos).FirstOrDefaultAsync(a => a.AdID == id); if (ad == null) return NotFound("Aviso no encontrado."); // 🛡️ SEGURIDAD: Verificar propiedad o ser admin var currentUserId = GetCurrentUserId(); if (ad.UserID != currentUserId && !IsUserAdmin()) return Forbid(); int currentCount = ad.Photos.Count; int availableSlots = 5 - currentCount; if (availableSlots <= 0) { return BadRequest($"Límite de 5 fotos alcanzado. Espacios disponibles: 0. Fotos actuales: {currentCount}"); } var filesToProcess = files.Take(availableSlots).ToList(); var uploadedCount = 0; var errors = new List(); foreach (var file in filesToProcess) { if (file.Length > 0) { try { var relativePath = await _imageService.SaveAdImageAsync(id, file); _context.AdPhotos.Add(new AdPhoto { AdID = id, FilePath = relativePath, IsCover = !_context.AdPhotos.Any(p => p.AdID == id) && uploadedCount == 0, SortOrder = currentCount + uploadedCount }); uploadedCount++; } catch (Exception ex) { errors.Add($"{file.FileName}: {ex.Message}"); } } } if (uploadedCount > 0) { await _context.SaveChangesAsync(); return Ok(new { message = $"{uploadedCount} fotos procesadas.", errors = errors.Any() ? errors : null }); } return BadRequest(new { message = "No se pudieron procesar las imágenes.", details = errors }); } [HttpDelete("photos/{photoId}")] public async Task DeletePhoto(int photoId) { var photo = await _context.AdPhotos.Include(p => p.Ad).FirstOrDefaultAsync(p => p.PhotoID == photoId); if (photo == null) return NotFound(); // 🛡️ SEGURIDAD: Verificar propiedad o ser admin var currentUserId = GetCurrentUserId(); if (photo.Ad.UserID != currentUserId && !IsUserAdmin()) return Forbid(); // Eliminar del disco _imageService.DeleteAdImage(photo.FilePath); // Eliminar de la base de datos _context.AdPhotos.Remove(photo); await _context.SaveChangesAsync(); return NoContent(); } [HttpPut("{id}")] [Authorize] public async Task Update(int id, [FromBody] CreateAdRequestDto updatedAdDto) { var ad = await _context.Ads.FindAsync(id); if (ad == null) return NotFound(); var userId = GetCurrentUserId(); if (ad.UserID != userId && !IsUserAdmin()) { return Forbid(); } // Si está en moderación, no se puede editar (salvo Admin) if (!IsUserAdmin() && ad.StatusID == (int)AdStatusEnum.ModerationPending) { return BadRequest("No puedes editar un aviso que está en proceso de revisión."); } // --- Lógica de Modelo Dinámico (Si edita a un modelo nuevo) --- int finalModelId = updatedAdDto.ModelID; if (finalModelId == 0 && !string.IsNullOrEmpty(updatedAdDto.VersionName)) { var brand = await _context.Brands.FindAsync(updatedAdDto.BrandID); string modelNameCandidate = updatedAdDto.VersionName; if (brand != null && modelNameCandidate.StartsWith(brand.Name, StringComparison.OrdinalIgnoreCase)) { modelNameCandidate = modelNameCandidate.Substring(brand.Name.Length).Trim(); } if (string.IsNullOrWhiteSpace(modelNameCandidate)) modelNameCandidate = "Modelo Desconocido"; var existingModel = await _context.Models .FirstOrDefaultAsync(m => m.BrandID == updatedAdDto.BrandID && m.Name == modelNameCandidate); if (existingModel != null) { finalModelId = existingModel.ModelID; } else { var newModel = new Model { BrandID = updatedAdDto.BrandID, Name = modelNameCandidate }; _context.Models.Add(newModel); await _context.SaveChangesAsync(); finalModelId = newModel.ModelID; } } // --- Mapeo manual desde el DTO a la Entidad --- ad.BrandID = updatedAdDto.BrandID; ad.ModelID = finalModelId; // Usar el ID resuelto ad.VersionName = updatedAdDto.VersionName; ad.Year = updatedAdDto.Year; ad.KM = updatedAdDto.KM; ad.Price = updatedAdDto.Price; ad.Currency = updatedAdDto.Currency; ad.Description = updatedAdDto.Description; ad.FuelType = updatedAdDto.FuelType; ad.Color = updatedAdDto.Color; ad.Segment = updatedAdDto.Segment; ad.Location = updatedAdDto.Location; ad.Condition = updatedAdDto.Condition; ad.DoorCount = updatedAdDto.DoorCount; ad.Transmission = updatedAdDto.Transmission; ad.Steering = updatedAdDto.Steering; ad.ContactPhone = updatedAdDto.ContactPhone; ad.ContactEmail = updatedAdDto.ContactEmail; ad.DisplayContactInfo = updatedAdDto.DisplayContactInfo; // Nota: IsFeatured y otros campos sensibles se manejan por separado (pago/admin) // 📝 AUDITORÍA var adBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? ""; _context.AuditLogs.Add(new AuditLog { Action = "AD_UPDATED", Entity = "Ad", EntityID = ad.AdID, UserID = userId, Details = $"Aviso ID {ad.AdID} ({adBrandName} {ad.VersionName}) actualizado por el propietario/admin." }); await _context.SaveChangesAsync(); return Ok(ad); } [HttpDelete("{id}")] [Authorize] public async Task Delete(int id) { var ad = await _context.Ads.FindAsync(id); if (ad == null) return NotFound(); var userId = GetCurrentUserId(); if (ad.UserID != userId && !IsUserAdmin()) { return Forbid(); } ad.StatusID = (int)AdStatusEnum.Deleted; ad.DeletedAt = DateTime.UtcNow; // 📝 AUDITORÍA var delBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? ""; _context.AuditLogs.Add(new AuditLog { Action = "AD_DELETED_SOFT", Entity = "Ad", EntityID = ad.AdID, UserID = userId, Details = $"Aviso ID {ad.AdID} ({delBrandName} {ad.VersionName}) marcado como eliminado (borrado lógico)." }); await _context.SaveChangesAsync(); return NoContent(); } [HttpPost("favorites")] public async Task AddFavorite([FromBody] Favorite fav) { if (await _context.Favorites.AnyAsync(f => f.UserID == fav.UserID && f.AdID == fav.AdID)) return BadRequest("Ya es favorito"); _context.Favorites.Add(fav); await _context.SaveChangesAsync(); return Ok(); } [HttpDelete("favorites")] public async Task RemoveFavorite([FromQuery] int userId, [FromQuery] int adId) { var fav = await _context.Favorites.FindAsync(userId, adId); if (fav == null) return NotFound(); _context.Favorites.Remove(fav); await _context.SaveChangesAsync(); return NoContent(); } [HttpGet("favorites/{userId}")] public async Task GetFavorites(int userId) { var ads = await _context.Favorites .Where(f => f.UserID == userId) .Join(_context.Ads, f => f.AdID, a => a.AdID, (f, a) => a) .Include(a => a.Photos) .Select(a => new { id = a.AdID, BrandName = a.Brand != null ? a.Brand.Name : null, VersionName = a.VersionName, price = a.Price, currency = a.Currency, year = a.Year, km = a.KM, image = a.Photos.OrderBy(p => p.SortOrder).Select(p => p.FilePath).FirstOrDefault() }) .ToListAsync(); return Ok(ads); } [HttpPatch("{id}/status")] public async Task ChangeStatus(int id, [FromBody] int newStatus) { var ad = await _context.Ads.FindAsync(id); if (ad == null) return NotFound(); var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0"); var userRole = User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value; bool isAdmin = userRole == "Admin"; if (ad.UserID != userId && !isAdmin) return Forbid(); // 🛡️ VALIDACIONES DE ESTADO if (!isAdmin) { // 1. No activar borradores sin pagar if (ad.StatusID == (int)AdStatusEnum.Draft || ad.StatusID == (int)AdStatusEnum.PaymentPending) { return BadRequest("Debes completar el pago para activar este aviso."); } // 2. NUEVO: No tocar si está en moderación if (ad.StatusID == (int)AdStatusEnum.ModerationPending) { return BadRequest("El aviso está en revisión. Espera la aprobación del administrador."); } } // Validar estados destino permitidos para el usuario var allowedUserStatuses = new[] { 4, 6, 7, 9, 10 }; if (!isAdmin && !allowedUserStatuses.Contains(newStatus)) { return BadRequest("Estado destino no permitido."); } int oldStatus = ad.StatusID; ad.StatusID = newStatus; // 📝 AUDITORÍA var statusBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? ""; _context.AuditLogs.Add(new AuditLog { Action = "AD_STATUS_CHANGED", Entity = "Ad", EntityID = ad.AdID, UserID = userId, Details = $"Estado de aviso ({statusBrandName} {ad.VersionName}) cambiado de {oldStatus} a {newStatus}." }); await _context.SaveChangesAsync(); return Ok(new { message = "Estado actualizado" }); } [HttpGet("payment-methods")] public async Task GetPaymentMethods() { var methods = await _context.PaymentMethods .OrderBy(p => p.PaymentMethodID) .Select(p => new { id = p.PaymentMethodID, mediodepago = p.Name }) .ToListAsync(); return Ok(methods); } }