816 lines
30 KiB
C#
816 lines
30 KiB
C#
|
|
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<IActionResult> 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<IActionResult> GetSearchSuggestions([FromQuery] string term)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrEmpty(term) || term.Length < 2)
|
||
|
|
{
|
||
|
|
return Ok(new List<string>());
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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<IActionResult> 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<IActionResult> 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<IActionResult> GetBrands(int vehicleTypeId)
|
||
|
|
{
|
||
|
|
var brands = await _context.Set<Brand>()
|
||
|
|
.Where(b => b.VehicleTypeID == vehicleTypeId)
|
||
|
|
.Select(b => new { id = b.BrandID, name = b.Name })
|
||
|
|
.ToListAsync();
|
||
|
|
return Ok(brands);
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpGet("models/{brandId}")]
|
||
|
|
public async Task<IActionResult> GetModels(int brandId)
|
||
|
|
{
|
||
|
|
var models = await _context.Set<Model>()
|
||
|
|
.Where(m => m.BrandID == brandId)
|
||
|
|
.Select(m => new { id = m.ModelID, name = m.Name })
|
||
|
|
.ToListAsync();
|
||
|
|
return Ok(models);
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpGet("models/search")]
|
||
|
|
public async Task<IActionResult> SearchModels([FromQuery] int brandId, [FromQuery] string query)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrEmpty(query) || query.Length < 2) return Ok(new List<object>());
|
||
|
|
|
||
|
|
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<IActionResult> 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<string>();
|
||
|
|
|
||
|
|
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> GetPaymentMethods()
|
||
|
|
{
|
||
|
|
var methods = await _context.PaymentMethods
|
||
|
|
.OrderBy(p => p.PaymentMethodID)
|
||
|
|
.Select(p => new { id = p.PaymentMethodID, mediodepago = p.Name })
|
||
|
|
.ToListAsync();
|
||
|
|
|
||
|
|
return Ok(methods);
|
||
|
|
}
|
||
|
|
}
|