package de.oaa.xxx.dating; import de.oaa.xxx.user.Geschlecht; import de.oaa.xxx.user.Neigung; import de.oaa.xxx.user.UserEntity; import de.oaa.xxx.user.UserRepository; import de.oaa.xxx.vorlieben.UserVorliebeEntity; import de.oaa.xxx.vorlieben.UserVorliebeRepository; import de.oaa.xxx.vorlieben.VorliebeBewertung; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @Service public class DatingService { private static final Set POSITIVE_RATINGS = Set.of( VorliebeBewertung.MAG_ICH, VorliebeBewertung.UNBEDINGT, VorliebeBewertung.WILL_AUSPROBIEREN ); private final UserRepository userRepository; private final UserVorliebeRepository userVorliebeRepository; private final DatingLikeRepository likeRepository; private final DatingMatchRepository matchRepository; private final DatingPassRepository passRepository; public DatingService(UserRepository userRepository, UserVorliebeRepository userVorliebeRepository, DatingLikeRepository likeRepository, DatingMatchRepository matchRepository, DatingPassRepository passRepository) { this.userRepository = userRepository; this.userVorliebeRepository = userVorliebeRepository; this.likeRepository = likeRepository; this.matchRepository = matchRepository; this.passRepository = passRepository; } // ── Records ────────────────────────────────────────────────────────────── record DatingFilter( int maxDistanceKm, int minAge, int maxAge, List geschlechter, List neigungen, List vorliebenIds, boolean vorliebenUnd ) {} record DatingProfileDto( UUID userId, String name, Integer alter, double distanzKm, String profilePicture, String neigung, String geschlecht, String datingStadt, String beschreibung, boolean likedByMe ) {} record LikeResult(boolean liked, boolean newMatch) {} record LikerDto(UUID userId, String name, String profilePicture, LocalDateTime likedAt) {} record WhoLikesMeResult(boolean premium, int total, List likers) {} record MatchDto(UUID userId, String name, String profilePicture, LocalDateTime matchedAt) {} record IdsResult(List ids, int total) {} // ── Public API ──────────────────────────────────────────────────────────── /** * Liefert alle passenden User-IDs, nach Entfernung sortiert. * Kein Paging – die Liste wird komplett zurückgegeben, das Frontend * holt Profildetails nachgelagert in Batches. */ public IdsResult findSortedIds(UUID currentUserId, DatingFilter filter, double myLat, double myLon) { List candidates = userRepository.findByDatingAktiv(true).stream() .filter(u -> !u.getUserId().equals(currentUserId)) .filter(u -> u.getDatingLat() != null && u.getDatingLon() != null) .toList(); candidates = filterByDistance(candidates, myLat, myLon, filter.maxDistanceKm()); candidates = filterByAge(candidates, filter.minAge(), filter.maxAge()); if (!filter.geschlechter().isEmpty()) { candidates = candidates.stream() .filter(u -> u.getGeschlecht() != null && filter.geschlechter().contains(u.getGeschlecht())) .toList(); } if (!filter.neigungen().isEmpty()) { candidates = candidates.stream() .filter(u -> u.getNeigung() != null && filter.neigungen().contains(u.getNeigung())) .toList(); } if (!filter.vorliebenIds().isEmpty()) { candidates = filterByVorlieben(candidates, filter.vorliebenIds(), filter.vorliebenUnd()); } List sorted = candidates.stream() .sorted(Comparator.comparingDouble(u -> haversineKm(myLat, myLon, u.getDatingLat(), u.getDatingLon()))) .map(UserEntity::getUserId) .toList(); return new IdsResult(sorted, sorted.size()); } /** * Lädt Profildetails für die übergebenen IDs und behält deren Reihenfolge bei. */ public List getProfilesByIds(List ids, UUID currentUserId, double myLat, double myLon) { if (ids == null || ids.isEmpty()) return List.of(); Map byId = userRepository.findAllById(ids).stream() .collect(Collectors.toMap(UserEntity::getUserId, Function.identity())); Set likedByMe = likeRepository.findByLikerId(currentUserId).stream() .map(DatingLikeEntity::getLikedId) .collect(Collectors.toSet()); return ids.stream() .map(byId::get) .filter(Objects::nonNull) .map(u -> toDto(u, currentUserId, likedByMe, myLat, myLon)) .toList(); } // ── Like / Match ────────────────────────────────────────────────────────── @Transactional public LikeResult toggleLike(UUID likerId, UUID likedId) { if (likeRepository.existsByLikerIdAndLikedId(likerId, likedId)) { likeRepository.deleteByLikerIdAndLikedId(likerId, likedId); matchRepository.deleteByUsers(likerId, likedId); return new LikeResult(false, false); } DatingLikeEntity like = new DatingLikeEntity(); like.setLikeId(UUID.randomUUID()); like.setLikerId(likerId); like.setLikedId(likedId); like.setLikedAt(LocalDateTime.now()); likeRepository.save(like); boolean mutual = likeRepository.existsByLikerIdAndLikedId(likedId, likerId); boolean newMatch = false; if (mutual && !matchRepository.existsByUsers(likerId, likedId)) { DatingMatchEntity match = new DatingMatchEntity(); match.setMatchId(UUID.randomUUID()); match.setUser1Id(likerId); match.setUser2Id(likedId); match.setMatchedAt(LocalDateTime.now()); matchRepository.save(match); newMatch = true; } return new LikeResult(true, newMatch); } public WhoLikesMeResult whoLikesMe(UUID userId, boolean isPremium) { List likes = likeRepository.findByLikedId(userId); int total = likes.size(); if (total == 0) return new WhoLikesMeResult(isPremium, 0, List.of()); Map byId = userRepository.findAllById( likes.stream().map(DatingLikeEntity::getLikerId).toList()).stream() .collect(Collectors.toMap(UserEntity::getUserId, Function.identity())); List likers = likes.stream() .sorted(Comparator.comparing(DatingLikeEntity::getLikedAt).reversed()) .map(l -> { UserEntity u = byId.get(l.getLikerId()); if (u == null) return null; return new LikerDto( isPremium ? u.getUserId() : null, isPremium ? u.getName() : null, u.getProfilePicture(), l.getLikedAt()); }) .filter(Objects::nonNull) .toList(); return new WhoLikesMeResult(isPremium, total, likers); } public List getMatches(UUID userId) { List matches = matchRepository.findByUser(userId); // PartnerId ermitteln Map byId = userRepository.findAllById( matches.stream() .map(m -> m.getUser1Id().equals(userId) ? m.getUser2Id() : m.getUser1Id()) .toList()).stream() .collect(Collectors.toMap(UserEntity::getUserId, Function.identity())); return matches.stream() .sorted(Comparator.comparing(DatingMatchEntity::getMatchedAt).reversed()) .map(m -> { UUID partnerId = m.getUser1Id().equals(userId) ? m.getUser2Id() : m.getUser1Id(); UserEntity u = byId.get(partnerId); if (u == null) return null; return new MatchDto(u.getUserId(), u.getName(), u.getProfilePicture(), m.getMatchedAt()); }) .filter(Objects::nonNull) .toList(); } public List getLikedByMe(UUID userId) { return likeRepository.findByLikerId(userId).stream() .map(DatingLikeEntity::getLikedId) .toList(); } /** * Liefert zufällig sortierte IDs für den Discovery-Modus (Match-Tab). * Bedingung: gegenseitige Geschlechter-Kompatibilität, Distanz, noch nicht geliked. */ public List findDiscoveryIds(UserEntity me) { Set myGeschlechter = me.getDatingGeschlechter() != null && !me.getDatingGeschlechter().isBlank() ? Set.of(me.getDatingGeschlechter().split(",")) : Set.of(); List candidates = userRepository.findByDatingAktiv(true).stream() .filter(u -> !u.getUserId().equals(me.getUserId())) .filter(u -> u.getDatingLat() != null && u.getDatingLon() != null) .toList(); // Ich muss an deren Geschlecht interessiert sein if (!myGeschlechter.isEmpty()) { candidates = candidates.stream() .filter(u -> u.getGeschlecht() != null && myGeschlechter.contains(u.getGeschlecht().name())) .toList(); } // Sie müssen an meinem Geschlecht interessiert sein (oder keine Einschränkung haben) if (me.getGeschlecht() != null) { final String myGender = me.getGeschlecht().name(); candidates = candidates.stream() .filter(u -> { if (u.getDatingGeschlechter() == null || u.getDatingGeschlechter().isBlank()) return true; return Set.of(u.getDatingGeschlechter().split(",")).contains(myGender); }) .toList(); } // Distanz: mein gespeicherter Max-Wert, Fallback 50 km int maxDist = me.getDatingMaxDistanzKm() != null ? me.getDatingMaxDistanzKm() : 50; candidates = filterByDistance(candidates, me.getDatingLat(), me.getDatingLon(), maxDist); // Bereits gelikte und abgelehnte Profile ausschließen Set alreadyLiked = likeRepository.findByLikerId(me.getUserId()).stream() .map(DatingLikeEntity::getLikedId) .collect(Collectors.toSet()); Set alreadyPassed = passRepository.findPassedIdsByPasserId(me.getUserId()); candidates = candidates.stream() .filter(u -> !alreadyLiked.contains(u.getUserId()) && !alreadyPassed.contains(u.getUserId())) .toList(); List ids = new ArrayList<>(candidates.stream().map(UserEntity::getUserId).toList()); Collections.shuffle(ids); return ids; } // ── Private helpers ─────────────────────────────────────────────────────── private List filterByDistance(List users, double myLat, double myLon, int maxKm) { return users.stream() .filter(u -> haversineKm(myLat, myLon, u.getDatingLat(), u.getDatingLon()) <= maxKm) .toList(); } private List filterByAge(List users, int minAge, int maxAge) { return users.stream() .filter(u -> { Integer age = u.getAlter(); if (age == null) return false; return age >= minAge && age <= maxAge; }) .toList(); } private List filterByVorlieben(List candidates, List requiredItemIds, boolean andLogic) { Set candidateIds = candidates.stream() .map(UserEntity::getUserId) .collect(Collectors.toSet()); Map> positiveByUser = userVorliebeRepository.findByUserIdIn(candidateIds).stream() .filter(uv -> POSITIVE_RATINGS.contains(uv.getBewertung())) .collect(Collectors.groupingBy( UserVorliebeEntity::getUserId, Collectors.mapping(UserVorliebeEntity::getItemId, Collectors.toSet()) )); Set required = new HashSet<>(requiredItemIds); return candidates.stream() .filter(u -> { Set userItems = positiveByUser.getOrDefault(u.getUserId(), Set.of()); return andLogic ? userItems.containsAll(required) : required.stream().anyMatch(userItems::contains); }) .toList(); } private DatingProfileDto toDto(UserEntity u, UUID currentUserId, Set likedByMe, double myLat, double myLon) { double dist = Math.round(haversineKm(myLat, myLon, u.getDatingLat(), u.getDatingLon()) * 10.0) / 10.0; return new DatingProfileDto( u.getUserId(), u.getName(), u.getAlter(), dist, u.getProfilePicture(), u.getNeigung() != null ? u.getNeigung().getLabel() : null, u.getGeschlecht() != null ? u.getGeschlecht().getLabel() : null, u.getDatingStadt(), u.getBeschreibung(), likedByMe.contains(u.getUserId()) ); } /** * Haversine-Formel: Luftlinie zwischen zwei Koordinaten in Kilometern. */ static double haversineKm(double lat1, double lon1, double lat2, double lon2) { final double R = 6371.0; double dLat = Math.toRadians(lat2 - lat1); double dLon = Math.toRadians(lon2 - lon1); double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } }