2026-01-29 13:43:44 -03:00
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
{
2026-02-26 20:17:52 -03:00
query = query . Where ( a = > a . UserID = = userId . Value & & a . StatusID ! = ( int ) AdStatusEnum . Deleted ) ;
2026-01-29 13:43:44 -03:00
}
// --- 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 ,
2026-02-19 19:47:13 -03:00
showPhone = ad . ShowPhone ,
allowWhatsApp = ad . AllowWhatsApp ,
showEmail = ad . ShowEmail ,
2026-01-29 13:43:44 -03:00
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 )
{
2026-02-19 19:59:23 -03:00
// Validación adicional: Asegurar que el ID seleccionado no sea Admin (por seguridad extra)
var targetUser = await _context . Users . FindAsync ( request . TargetUserID . Value ) ;
if ( targetUser ! = null & & targetUser . UserType = = 3 )
{
return BadRequest ( "No se puede asignar un aviso a una cuenta de Administrador." ) ;
}
2026-01-29 13:43:44 -03:00
finalUserId = request . TargetUserID . Value ;
}
else if ( ! string . IsNullOrEmpty ( request . GhostUserEmail ) )
{
2026-02-19 19:59:23 -03:00
// Verificamos si el email escrito manualmente pertenece a un Admin existente
var existingAdmin = await _context . Users
. AnyAsync ( u = > u . Email = = request . GhostUserEmail & & u . UserType = = 3 ) ;
if ( existingAdmin )
{
return BadRequest ( $"El correo '{request.GhostUserEmail}' pertenece a un Administrador. No puedes asignar avisos a administradores." ) ;
}
2026-01-29 13:43:44 -03:00
// 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 ,
2026-02-19 19:47:13 -03:00
ShowPhone = request . ShowPhone ,
AllowWhatsApp = request . AllowWhatsApp ,
ShowEmail = request . ShowEmail ,
2026-01-29 13:43:44 -03:00
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 ) ;
}
2026-03-18 15:52:24 -03:00
private async Task NormalizeAdPhotosAsync ( int adId )
{
var photos = await _context . AdPhotos
. Where ( p = > p . AdID = = adId )
. OrderBy ( p = > p . SortOrder )
. ThenBy ( p = > p . PhotoID )
. ToListAsync ( ) ;
bool coverAssigned = false ;
for ( int i = 0 ; i < photos . Count ; i + + )
{
photos [ i ] . SortOrder = i ;
if ( photos [ i ] . IsCover & & ! coverAssigned ) {
coverAssigned = true ;
} else {
photos [ i ] . IsCover = false ;
}
}
if ( ! coverAssigned & & photos . Count > 0 )
{
photos [ 0 ] . IsCover = true ;
}
await _context . SaveChangesAsync ( ) ;
}
2026-01-29 13:43:44 -03:00
[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 ( ) ;
2026-03-18 15:52:24 -03:00
await NormalizeAdPhotosAsync ( id ) ;
2026-01-29 13:43:44 -03:00
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 ( ) ;
2026-03-18 15:52:24 -03:00
await NormalizeAdPhotosAsync ( photo . AdID ) ;
2026-01-29 13:43:44 -03:00
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 ;
2026-02-19 19:47:13 -03:00
ad . ShowPhone = updatedAdDto . ShowPhone ;
ad . AllowWhatsApp = updatedAdDto . AllowWhatsApp ;
ad . ShowEmail = updatedAdDto . ShowEmail ;
2026-01-29 13:43:44 -03:00
// Nota: IsFeatured y otros campos sensibles se manejan por separado (pago/admin)
2026-02-13 15:52:33 -03:00
// LÓGICA DE ESTADO TRAS RECHAZO
if ( ! IsUserAdmin ( ) & & ad . StatusID = = ( int ) AdStatusEnum . Rejected )
{
// Si estaba rechazado y el dueño lo edita, vuelve a revisión.
ad . StatusID = ( int ) AdStatusEnum . ModerationPending ;
}
2026-01-29 13:43:44 -03:00
// 📝 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 )
2026-02-26 20:17:52 -03:00
. Where ( a = > a . StatusID ! = ( int ) AdStatusEnum . Deleted )
2026-01-29 13:43:44 -03:00
. 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." ) ;
}
2026-02-13 15:52:33 -03:00
// 2. No tocar si está en moderación
2026-01-29 13:43:44 -03:00
if ( ad . StatusID = = ( int ) AdStatusEnum . ModerationPending )
{
return BadRequest ( "El aviso está en revisión. Espera la aprobación del administrador." ) ;
}
2026-02-13 15:52:33 -03:00
// 3. Bloquear si está RECHAZADO
if ( ad . StatusID = = ( int ) AdStatusEnum . Rejected )
{
return BadRequest ( "Este aviso fue rechazado. Debes editarlo y corregirlo para que sea revisado nuevamente." ) ;
}
2026-01-29 13:43:44 -03:00
}
// 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 ;
2026-02-26 20:17:52 -03:00
if ( newStatus = = ( int ) AdStatusEnum . Deleted )
{
ad . DeletedAt = DateTime . UtcNow ;
}
2026-02-26 20:28:01 -03:00
else
{
ad . DeletedAt = null ;
}
2026-02-26 20:17:52 -03:00
2026-01-29 13:43:44 -03:00
// 📝 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 ,
2026-02-26 20:28:01 -03:00
Details = $"Estado de aviso ({statusBrandName} {ad.VersionName}) cambiado de {AdStatusHelper.GetStatusDisplayName(oldStatus)} a {AdStatusHelper.GetStatusDisplayName(newStatus)}."
2026-01-29 13:43:44 -03:00
} ) ;
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 ) ;
}
}