BandListingService.java
package com.wavii.service;
import com.wavii.dto.band.BandListingResponse;
import com.wavii.dto.band.CreateBandListingRequest;
import com.wavii.model.BandListing;
import com.wavii.model.User;
import com.wavii.model.enums.MusicalGenre;
import com.wavii.model.enums.MusicianRole;
import com.wavii.repository.BandListingRepository;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.Predicate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Servicio para la gestión de anuncios de bandas y músicos.
* Permite crear, listar, filtrar y eliminar anuncios en el marketplace.
*
* @author danielrguezh
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class BandListingService {
private final BandListingRepository repository;
/**
* Obtiene una página de anuncios de bandas filtrados por género, ciudad y rol.
*
* @param genreStr Nombre del género musical.
* @param city Ciudad del anuncio.
* @param roleStr Nombre del rol (instrumento/puesto).
* @param page Número de página.
* @return Página de resultados de anuncios.
*/
@Transactional(readOnly = true)
public Page<BandListingResponse> getListings(String genreStr, String city, String roleStr, int page) {
MusicalGenre genre = parseEnum(MusicalGenre.class, genreStr);
MusicianRole role = parseEnum(MusicianRole.class, roleStr);
String cityFilter = (city != null && !city.isBlank()) ? city.trim().toLowerCase() : null;
Specification<BandListing> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (genre != null) {
predicates.add(cb.equal(root.get("genre"), genre));
}
if (cityFilter != null) {
predicates.add(cb.like(cb.lower(root.get("city")), "%" + cityFilter + "%"));
}
if (role != null) {
Join<BandListing, String> rolesJoin = root.join("roles", JoinType.INNER);
predicates.add(cb.equal(rolesJoin, role.name()));
query.distinct(true);
}
predicates.add(cb.isFalse(root.get("removedByModeration")));
return cb.and(predicates.toArray(new Predicate[0]));
};
return repository.findAll(spec, PageRequest.of(page, 20, Sort.by(Sort.Direction.DESC, "createdAt")))
.map(this::toResponse);
}
/**
* Obtiene un anuncio por su ID único.
*
* @param id ID del anuncio.
* @return Respuesta con los datos del anuncio.
*/
@Transactional(readOnly = true)
public BandListingResponse getById(UUID id) {
return toResponse(findOrThrow(id));
}
/**
* Crea un nuevo anuncio de banda.
*
* @param req Datos para la creación del anuncio.
* @param creator Usuario que crea el anuncio.
* @return Respuesta con el anuncio creado.
*/
@Transactional
public BandListingResponse create(CreateBandListingRequest req, User creator) {
validate(req);
BandListing listing = BandListing.builder()
.title(req.title().trim())
.description(req.description() != null ? req.description().trim() : null)
.type(req.type())
.genre(req.genre())
.city(req.city().trim())
.roles(req.roles() != null ? req.roles() : List.of())
.creator(creator)
.contactInfo(req.contactInfo() != null ? req.contactInfo().trim() : null)
.contactPhone(normalize(req.contactPhone()))
.contactEmail(normalize(req.contactEmail()))
.instagramUrl(normalize(req.instagramUrl()))
.tiktokUrl(normalize(req.tiktokUrl()))
.youtubeUrl(normalize(req.youtubeUrl()))
.coverImageUrl(normalizeUrl(req.coverImageUrl()))
.imageUrls(safeImageUrls(req.imageUrls()))
.build();
return toResponse(repository.save(listing));
}
/**
* Elimina un anuncio si el usuario solicitante es su creador.
*
* @param id ID del anuncio.
* @param requester Usuario que solicita el borrado.
*/
@Transactional
public void delete(UUID id, User requester) {
BandListing listing = findOrThrow(id);
if (!listing.getCreator().getId().equals(requester.getId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "No puedes eliminar este anuncio");
}
repository.delete(listing);
}
/**
* Obtiene la lista de anuncios creados por el usuario especificado.
*
* @param user Usuario creador.
* @return Lista de anuncios del usuario.
*/
@Transactional(readOnly = true)
public List<BandListingResponse> getMyListings(User user) {
return repository.findByCreatorIdAndRemovedByModerationFalseOrderByCreatedAtDesc(user.getId())
.stream().map(this::toResponse).toList();
}
// ── helpers ─────────────────────────────────────────────────────────────
private BandListing findOrThrow(UUID id) {
return repository.findById(id)
.filter(listing -> !listing.isRemovedByModeration())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Anuncio no encontrado"));
}
private void validate(CreateBandListingRequest req) {
if (req.title() == null || req.title().isBlank())
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "El título es obligatorio");
if (req.type() == null)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "El tipo es obligatorio");
if (req.genre() == null)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "El género es obligatorio");
if (req.city() == null || req.city().isBlank())
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "La ciudad es obligatoria");
if (req.roles() == null || req.roles().isEmpty())
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Selecciona al menos un rol");
}
private BandListingResponse toResponse(BandListing bl) {
return new BandListingResponse(
bl.getId().toString(),
bl.getTitle(),
bl.getDescription(),
bl.getType().name(),
bl.getGenre().name(),
bl.getCity(),
bl.getRoles().stream().map(Enum::name).toList(),
bl.getCreator().getId().toString(),
bl.getCreator().getName(),
bl.getContactInfo(),
bl.getContactPhone(),
bl.getContactEmail(),
bl.getInstagramUrl(),
bl.getTiktokUrl(),
bl.getYoutubeUrl(),
bl.getCoverImageUrl(),
safeImageUrls(bl.getImageUrls()),
bl.getCreatedAt().toString()
);
}
private String normalizeUrl(String value) {
if (value == null || value.isBlank()) return null;
return value.trim();
}
private String normalize(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
private List<String> safeImageUrls(List<String> urls) {
if (urls == null) return List.of();
return urls.stream()
.filter(url -> url != null && !url.isBlank())
.map(String::trim)
.limit(3)
.toList();
}
private <E extends Enum<E>> E parseEnum(Class<E> cls, String value) {
if (value == null || value.isBlank()) return null;
try {
return Enum.valueOf(cls, value.toUpperCase());
} catch (IllegalArgumentException e) {
return null;
}
}
}