Weiter am Dating gearbeitet
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled

This commit is contained in:
2026-04-04 00:09:08 +02:00
parent 87c85b1b17
commit d386f5a7a9
61 changed files with 29863 additions and 350 deletions

View File

@@ -0,0 +1,194 @@
package de.oaa.xxx.dating;
import de.oaa.xxx.social.SseService;
import de.oaa.xxx.subscription.SubscriptionLimitService;
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.user.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.security.Principal;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
@RestController
@RequestMapping("/dating")
public class DatingController {
private final DatingService datingService;
private final UserService userService;
private final UserRepository userRepository;
private final DatingPassRepository passRepository;
private final SubscriptionLimitService subscriptionLimitService;
private final SseService sseService;
public DatingController(DatingService datingService, UserService userService,
UserRepository userRepository,
DatingPassRepository passRepository,
SubscriptionLimitService subscriptionLimitService,
SseService sseService) {
this.datingService = datingService;
this.userService = userService;
this.userRepository = userRepository;
this.passRepository = passRepository;
this.subscriptionLimitService = subscriptionLimitService;
this.sseService = sseService;
}
// ── Profilsuche ───────────────────────────────────────────────────────────
@GetMapping("/profile-ids")
public ResponseEntity<DatingService.IdsResult> getProfileIds(
@RequestParam(name = "maxDistanceKm", defaultValue = "50") int maxDistanceKm,
@RequestParam(name = "minAge", defaultValue = "18") int minAge,
@RequestParam(name = "maxAge", defaultValue = "99") int maxAge,
@RequestParam(name = "geschlechter", required = false) List<String> geschlechter,
@RequestParam(name = "neigungen", required = false) List<String> neigungen,
@RequestParam(name = "vorliebenIds", required = false) List<UUID> vorliebenIds,
@RequestParam(name = "vorliebenUnd", defaultValue = "false") boolean vorliebenUnd,
Principal principal) {
UserEntity me = requireDatingUser(principal);
DatingService.DatingFilter filter = new DatingService.DatingFilter(
Math.max(maxDistanceKm, 1),
Math.max(minAge, 0),
Math.min(maxAge, 120),
parseEnumList(geschlechter, Geschlecht.class),
parseEnumList(neigungen, Neigung.class),
vorliebenIds != null ? vorliebenIds : List.of(),
vorliebenUnd
);
return ResponseEntity.ok(datingService.findSortedIds(
me.getUserId(), filter, me.getDatingLat(), me.getDatingLon()));
}
@PostMapping("/profiles/batch")
public ResponseEntity<List<DatingService.DatingProfileDto>> getProfileBatch(
@RequestBody List<UUID> ids,
Principal principal) {
UserEntity me = requireDatingUser(principal);
if (ids == null || ids.isEmpty()) return ResponseEntity.ok(List.of());
List<UUID> capped = ids.stream().filter(Objects::nonNull).limit(50).toList();
return ResponseEntity.ok(datingService.getProfilesByIds(
capped, me.getUserId(), me.getDatingLat(), me.getDatingLon()));
}
@GetMapping("/discovery")
public ResponseEntity<List<UUID>> getDiscovery(Principal principal) {
UserEntity me = requireDatingUser(principal);
return ResponseEntity.ok(datingService.findDiscoveryIds(me));
}
@PostMapping("/pass/{targetId}")
public ResponseEntity<Void> passProfile(@PathVariable("targetId") UUID targetId, Principal principal) {
UserEntity me = userService.requireUser(principal);
if (me.getUserId().equals(targetId)) return ResponseEntity.badRequest().build();
if (!passRepository.existsByPasserIdAndPassedId(me.getUserId(), targetId)) {
DatingPassEntity pass = new DatingPassEntity();
pass.setPassId(java.util.UUID.randomUUID());
pass.setPasserId(me.getUserId());
pass.setPassedId(targetId);
pass.setPassedAt(java.time.LocalDateTime.now());
passRepository.save(pass);
}
return ResponseEntity.status(201).build();
}
// ── Likes ─────────────────────────────────────────────────────────────────
/**
* Toggelt einen Like auf ein Profil (Like wenn nicht vorhanden, Unlike wenn vorhanden).
* Gibt zurück ob jetzt geliked wird und ob ein neues Match entstanden ist.
* Setzt Dating-Aktiv beim eigenen User voraus; beim Ziel-User wird es nicht geprüft
* (damit auch Profil-Seiten außerhalb der Dating-Suche geliked werden können).
*/
@PostMapping("/like/{targetId}")
public ResponseEntity<DatingService.LikeResult> toggleLike(
@PathVariable("targetId") UUID targetId,
Principal principal) {
UserEntity me = userService.requireUser(principal);
if (!me.isDatingAktiv()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Dating nicht aktiviert");
}
if (me.getUserId().equals(targetId)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Eigenes Profil kann nicht geliked werden");
}
DatingService.LikeResult result = datingService.toggleLike(me.getUserId(), targetId);
if (result.liked() && result.newMatch()) {
userRepository.findById(targetId).ifPresent(target -> {
String myPic = me.getProfilePicture() != null ? me.getProfilePicture() : "";
String theirPic = target.getProfilePicture() != null ? target.getProfilePicture() : "";
sseService.push(targetId, "MATCH", Map.of("partnerId", me.getUserId().toString(), "partnerName", me.getName(), "partnerPicture", myPic));
sseService.push(me.getUserId(), "MATCH", Map.of("partnerId", targetId.toString(), "partnerName", target.getName(), "partnerPicture", theirPic));
});
}
return ResponseEntity.ok(result);
}
/**
* Alle User-IDs, die der eingeloggte User geliked hat.
* Wird vom Profil-Frontend genutzt um den Like-Status initial zu setzen.
*/
@GetMapping("/liked-by-me")
public ResponseEntity<List<UUID>> getLikedByMe(Principal principal) {
UserEntity me = userService.requireUser(principal);
return ResponseEntity.ok(datingService.getLikedByMe(me.getUserId()));
}
/**
* Wer hat mich geliked? Premium-geschützt:
* - Premium: volle Profildaten (userId, name, Bild)
* - Kein Premium: nur Profilbild (anonymisiert), userId + name = null
*/
@GetMapping("/who-likes-me")
public ResponseEntity<DatingService.WhoLikesMeResult> whoLikesMe(Principal principal) {
UserEntity me = userService.requireUser(principal);
boolean premium = subscriptionLimitService.hasActivePaidSubscription(me.getUserId());
return ResponseEntity.ok(datingService.whoLikesMe(me.getUserId(), premium));
}
/**
* Alle Matches des eingeloggten Users (gegenseitige Likes).
*/
@GetMapping("/matches")
public ResponseEntity<List<DatingService.MatchDto>> getMatches(Principal principal) {
UserEntity me = userService.requireUser(principal);
return ResponseEntity.ok(datingService.getMatches(me.getUserId()));
}
// ── Helpers ───────────────────────────────────────────────────────────────
private UserEntity requireDatingUser(Principal principal) {
UserEntity me = userService.requireUser(principal);
if (!me.isDatingAktiv()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Dating nicht aktiviert");
}
if (me.getDatingLat() == null || me.getDatingLon() == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Standort nicht gesetzt");
}
return me;
}
private <E extends Enum<E>> List<E> parseEnumList(List<String> values, Class<E> enumClass) {
if (values == null || values.isEmpty()) return List.of();
return values.stream()
.map(s -> {
try { return Enum.valueOf(enumClass, s); }
catch (IllegalArgumentException e) { return null; }
})
.filter(Objects::nonNull)
.toList();
}
}

View File

@@ -0,0 +1,29 @@
package de.oaa.xxx.dating;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "dating_like",
uniqueConstraints = @UniqueConstraint(columnNames = {"liker_id", "liked_id"}))
public class DatingLikeEntity {
@Id
@Column
private UUID likeId;
@Column(name = "liker_id", nullable = false)
private UUID likerId;
@Column(name = "liked_id", nullable = false)
private UUID likedId;
@Column(nullable = false)
private LocalDateTime likedAt;
}

View File

@@ -0,0 +1,21 @@
package de.oaa.xxx.dating;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface DatingLikeRepository extends JpaRepository<DatingLikeEntity, UUID> {
boolean existsByLikerIdAndLikedId(UUID likerId, UUID likedId);
void deleteByLikerIdAndLikedId(UUID likerId, UUID likedId);
/** Alle User-IDs, die der Liker geliked hat. */
List<DatingLikeEntity> findByLikerId(UUID likerId);
/** Alle Likes, die auf den User zeigen (wer hat ihn geliked). */
List<DatingLikeEntity> findByLikedId(UUID likedId);
void deleteByLikerIdOrLikedId(UUID likerId, UUID likedId);
}

View File

@@ -0,0 +1,28 @@
package de.oaa.xxx.dating;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "dating_match")
public class DatingMatchEntity {
@Id
@Column
private UUID matchId;
@Column(nullable = false)
private UUID user1Id;
@Column(nullable = false)
private UUID user2Id;
@Column(nullable = false)
private LocalDateTime matchedAt;
}

View File

@@ -0,0 +1,27 @@
package de.oaa.xxx.dating;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.UUID;
public interface DatingMatchRepository extends JpaRepository<DatingMatchEntity, UUID> {
@Query("SELECT m FROM DatingMatchEntity m WHERE m.user1Id = :userId OR m.user2Id = :userId")
List<DatingMatchEntity> findByUser(@Param("userId") UUID userId);
@Query("SELECT COUNT(m) > 0 FROM DatingMatchEntity m WHERE (m.user1Id = :a AND m.user2Id = :b) OR (m.user1Id = :b AND m.user2Id = :a)")
boolean existsByUsers(@Param("a") UUID a, @Param("b") UUID b);
@Query("DELETE FROM DatingMatchEntity m WHERE (m.user1Id = :a AND m.user2Id = :b) OR (m.user1Id = :b AND m.user2Id = :a)")
@org.springframework.data.jpa.repository.Modifying
@org.springframework.transaction.annotation.Transactional
void deleteByUsers(@Param("a") UUID a, @Param("b") UUID b);
@Query("DELETE FROM DatingMatchEntity m WHERE m.user1Id = :userId OR m.user2Id = :userId")
@org.springframework.data.jpa.repository.Modifying
@org.springframework.transaction.annotation.Transactional
void deleteAllByUser(@Param("userId") UUID userId);
}

View File

@@ -0,0 +1,28 @@
package de.oaa.xxx.dating;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "dating_pass", uniqueConstraints = @UniqueConstraint(columnNames = {"passerId", "passedId"}))
public class DatingPassEntity {
@Id
@Column
private UUID passId;
@Column(nullable = false)
private UUID passerId;
@Column(nullable = false)
private UUID passedId;
@Column(nullable = false)
private LocalDateTime passedAt;
}

View File

@@ -0,0 +1,16 @@
package de.oaa.xxx.dating;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Set;
import java.util.UUID;
public interface DatingPassRepository extends JpaRepository<DatingPassEntity, UUID> {
boolean existsByPasserIdAndPassedId(UUID passerId, UUID passedId);
@Query("SELECT p.passedId FROM DatingPassEntity p WHERE p.passerId = :passerId")
Set<UUID> findPassedIdsByPasserId(@Param("passerId") UUID passerId);
}

View File

@@ -0,0 +1,347 @@
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<VorliebeBewertung> 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<Geschlecht> geschlechter,
List<Neigung> neigungen,
List<UUID> 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<LikerDto> likers) {}
record MatchDto(UUID userId, String name, String profilePicture, LocalDateTime matchedAt) {}
record IdsResult(List<UUID> 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<UserEntity> 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<UUID> 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<DatingProfileDto> getProfilesByIds(List<UUID> ids, UUID currentUserId, double myLat, double myLon) {
if (ids == null || ids.isEmpty()) return List.of();
Map<UUID, UserEntity> byId = userRepository.findAllById(ids).stream()
.collect(Collectors.toMap(UserEntity::getUserId, Function.identity()));
Set<UUID> 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<DatingLikeEntity> likes = likeRepository.findByLikedId(userId);
int total = likes.size();
if (total == 0) return new WhoLikesMeResult(isPremium, 0, List.of());
Map<UUID, UserEntity> byId = userRepository.findAllById(
likes.stream().map(DatingLikeEntity::getLikerId).toList()).stream()
.collect(Collectors.toMap(UserEntity::getUserId, Function.identity()));
List<LikerDto> 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<MatchDto> getMatches(UUID userId) {
List<DatingMatchEntity> matches = matchRepository.findByUser(userId);
// PartnerId ermitteln
Map<UUID, UserEntity> 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<UUID> 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<UUID> findDiscoveryIds(UserEntity me) {
Set<String> myGeschlechter = me.getDatingGeschlechter() != null && !me.getDatingGeschlechter().isBlank()
? Set.of(me.getDatingGeschlechter().split(","))
: Set.of();
List<UserEntity> 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<UUID> alreadyLiked = likeRepository.findByLikerId(me.getUserId()).stream()
.map(DatingLikeEntity::getLikedId)
.collect(Collectors.toSet());
Set<UUID> alreadyPassed = passRepository.findPassedIdsByPasserId(me.getUserId());
candidates = candidates.stream()
.filter(u -> !alreadyLiked.contains(u.getUserId()) && !alreadyPassed.contains(u.getUserId()))
.toList();
List<UUID> ids = new ArrayList<>(candidates.stream().map(UserEntity::getUserId).toList());
Collections.shuffle(ids);
return ids;
}
// ── Private helpers ───────────────────────────────────────────────────────
private List<UserEntity> filterByDistance(List<UserEntity> users, double myLat, double myLon, int maxKm) {
return users.stream()
.filter(u -> haversineKm(myLat, myLon, u.getDatingLat(), u.getDatingLon()) <= maxKm)
.toList();
}
private List<UserEntity> filterByAge(List<UserEntity> 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<UserEntity> filterByVorlieben(List<UserEntity> candidates,
List<UUID> requiredItemIds,
boolean andLogic) {
Set<UUID> candidateIds = candidates.stream()
.map(UserEntity::getUserId)
.collect(Collectors.toSet());
Map<UUID, Set<UUID>> positiveByUser = userVorliebeRepository.findByUserIdIn(candidateIds).stream()
.filter(uv -> POSITIVE_RATINGS.contains(uv.getBewertung()))
.collect(Collectors.groupingBy(
UserVorliebeEntity::getUserId,
Collectors.mapping(UserVorliebeEntity::getItemId, Collectors.toSet())
));
Set<UUID> required = new HashSet<>(requiredItemIds);
return candidates.stream()
.filter(u -> {
Set<UUID> 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<UUID> 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));
}
}

View File

@@ -2,6 +2,7 @@ package de.oaa.xxx.social;
import de.oaa.xxx.social.dto.PinnwandEintragDto;
import de.oaa.xxx.social.entity.PinnwandEintragEntity;
import de.oaa.xxx.social.repository.BlockRepository;
import de.oaa.xxx.social.repository.KommentarRepository;
import de.oaa.xxx.social.repository.PinnwandEintragRepository;
import de.oaa.xxx.social.repository.PinnwandLikeRepository;
@@ -30,19 +31,22 @@ public class PinnwandController {
private final UserRepository userRepository;
private final UserService userService;
private final LikeService likeService;
private final BlockRepository blockRepository;
public PinnwandController(PinnwandEintragRepository eintragRepository,
PinnwandLikeRepository likeRepository,
KommentarRepository kommentarRepository,
UserRepository userRepository,
UserService userService,
LikeService likeService) {
LikeService likeService,
BlockRepository blockRepository) {
this.eintragRepository = eintragRepository;
this.likeRepository = likeRepository;
this.kommentarRepository = kommentarRepository;
this.userRepository = userRepository;
this.userService = userService;
this.likeService = likeService;
this.blockRepository = blockRepository;
}
record CreateEintragRequest(UUID profilUserId, String text) {}
@@ -66,6 +70,11 @@ public class PinnwandController {
if (request.text() == null || request.text().isBlank()) return ResponseEntity.badRequest().build();
if (request.text().length() > 1000) return ResponseEntity.badRequest().build();
// Blockiert? Profilinhaber kann keine Einträge von blockierten Personen erhalten
if (blockRepository.findByBlockerIdAndBlockedId(request.profilUserId(), myId).isPresent()) {
return ResponseEntity.status(403).build();
}
PinnwandEintragEntity entity = new PinnwandEintragEntity();
entity.setEintragId(UUID.randomUUID());
entity.setProfilUserId(request.profilUserId());

View File

@@ -1,15 +1,19 @@
package de.oaa.xxx.social;
import de.oaa.xxx.dating.DatingMatchRepository;
import de.oaa.xxx.social.dto.ConversationSummary;
import de.oaa.xxx.social.dto.FriendshipDto;
import de.oaa.xxx.social.dto.MessageDto;
import de.oaa.xxx.social.dto.UserProfile;
import de.oaa.xxx.social.entity.BlockEntity;
import de.oaa.xxx.social.entity.FriendshipEntity;
import de.oaa.xxx.social.entity.FriendshipEntity.Status;
import de.oaa.xxx.social.entity.MessageCause;
import de.oaa.xxx.social.entity.MessageEntity;
import de.oaa.xxx.social.repository.BlockRepository;
import de.oaa.xxx.social.repository.FriendshipRepository;
import de.oaa.xxx.social.repository.MessageRepository;
import de.oaa.xxx.subscription.SubscriptionLimitService;
import de.oaa.xxx.support.SupportUserService;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
@@ -33,6 +37,9 @@ public class SocialController {
private final UserRepository userRepository;
private final FriendshipRepository friendshipRepository;
private final MessageRepository messageRepository;
private final BlockRepository blockRepository;
private final DatingMatchRepository datingMatchRepository;
private final SubscriptionLimitService subscriptionLimitService;
private final SseService sseService;
private final SystemMessageService systemMessageService;
private final UserService userService;
@@ -40,12 +47,18 @@ public class SocialController {
public SocialController(UserRepository userRepository,
FriendshipRepository friendshipRepository,
MessageRepository messageRepository,
BlockRepository blockRepository,
DatingMatchRepository datingMatchRepository,
SubscriptionLimitService subscriptionLimitService,
SseService sseService,
SystemMessageService systemMessageService,
UserService userService) {
this.userRepository = userRepository;
this.friendshipRepository = friendshipRepository;
this.messageRepository = messageRepository;
this.blockRepository = blockRepository;
this.datingMatchRepository = datingMatchRepository;
this.subscriptionLimitService = subscriptionLimitService;
this.sseService = sseService;
this.systemMessageService = systemMessageService;
this.userService = userService;
@@ -202,7 +215,7 @@ public class SocialController {
// ── Messages ──
@PostMapping("/messages")
public ResponseEntity<Void> sendMessage(@RequestBody SendMessageBody body, Principal principal) {
public ResponseEntity<Map<String, String>> sendMessage(@RequestBody SendMessageBody body, Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
if (body.text() == null || body.text().isBlank()) return ResponseEntity.badRequest().build();
@@ -212,6 +225,23 @@ public class SocialController {
return ResponseEntity.status(403).build();
}
// Blockiert? (in beide Richtungen)
if (blockRepository.existsBlock(myId, body.receiverId())) {
return ResponseEntity.status(403).body(Map.of("reason", "BLOCKED"));
}
// Erste Nachricht in dieser Konversation → Bedingungen prüfen
if (!messageRepository.conversationExists(myId, body.receiverId())) {
boolean areFriends = friendshipRepository.findExisting(myId, body.receiverId())
.filter(f -> f.getStatus() == Status.ACCEPTED).isPresent();
boolean haveMatch = datingMatchRepository.existsByUsers(myId, body.receiverId());
boolean hasPro = subscriptionLimitService.hasActivePaidSubscription(myId);
if (!areFriends && !haveMatch && !hasPro) {
return ResponseEntity.status(403).body(Map.of("reason", "FIRST_MESSAGE_RESTRICTED"));
}
}
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(myId);
@@ -297,6 +327,54 @@ public class SocialController {
return ResponseEntity.ok(Map.of("messages", messages.stream().map(this::toMessageDto).toList(), "hasMore", hasMore));
}
// ── Block ──
@PostMapping("/block/{userId}")
public ResponseEntity<Void> blockUser(@PathVariable("userId") UUID targetId, Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
if (myId.equals(targetId)) return ResponseEntity.badRequest().build();
// Bereits blockiert?
if (blockRepository.findByBlockerIdAndBlockedId(myId, targetId).isPresent()) {
return ResponseEntity.status(409).build();
}
// Block speichern
BlockEntity block = new BlockEntity();
block.setBlockId(UUID.randomUUID());
block.setBlockerId(myId);
block.setBlockedId(targetId);
block.setBlockedAt(LocalDateTime.now());
blockRepository.save(block);
LOGGER.info("User {} hat User {} blockiert", myId, targetId);
// Gesamte Konversation löschen
messageRepository.deleteConversation(myId, targetId);
// Bestehende Freundschaft aufheben
friendshipRepository.findExisting(myId, targetId).ifPresent(friendshipRepository::delete);
return ResponseEntity.status(201).build();
}
@DeleteMapping("/block/{userId}")
public ResponseEntity<Void> unblockUser(@PathVariable("userId") UUID targetId, Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
if (blockRepository.findByBlockerIdAndBlockedId(myId, targetId).isEmpty()) {
return ResponseEntity.notFound().build();
}
blockRepository.deleteByBlockerIdAndBlockedId(myId, targetId);
LOGGER.info("User {} hat User {} entblockt", myId, targetId);
return ResponseEntity.noContent().build();
}
@GetMapping("/block/{userId}")
public ResponseEntity<Map<String, Boolean>> getBlockStatus(@PathVariable("userId") UUID targetId, Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
boolean blockedByMe = blockRepository.findByBlockerIdAndBlockedId(myId, targetId).isPresent();
return ResponseEntity.ok(Map.of("blockedByMe", blockedByMe));
}
// ── Helpers ──
private UserProfile toUserProfileWithStatus(UserEntity user, UUID myId) {
@@ -348,7 +426,8 @@ public class SocialController {
user.getSichtbarkeitXp(),
user.getSichtbarkeitLockhistorie(),
user.getSichtbarkeitVorlieben(),
user.isProfilBeiVeroeffentlichungenSichtbar());
user.isProfilBeiVeroeffentlichungenSichtbar(),
user.isDatingAktiv());
}
private MessageDto toMessageDto(MessageEntity m) {

View File

@@ -32,12 +32,13 @@ public record UserProfile(
Sichtbarkeit sichtbarkeitXp,
Sichtbarkeit sichtbarkeitLockhistorie,
Sichtbarkeit sichtbarkeitVorlieben,
boolean profilBeiVeroeffentlichungenSichtbar
boolean profilBeiVeroeffentlichungenSichtbar,
boolean datingAktiv
) {
/** Compact constructor for contexts where profile details are not needed (friend list etc.) */
public UserProfile(UUID userId, String name, String profilePicture, String profilePictureHq, String friendStatus) {
this(userId, name, profilePicture, profilePictureHq, friendStatus,
null, null, null, null, null, null, null, 0, 0, 0,
null, null, null, null, null, null, null, null, false);
null, null, null, null, null, null, null, null, false, false);
}
}

View File

@@ -0,0 +1,28 @@
package de.oaa.xxx.social.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "user_block", uniqueConstraints = @UniqueConstraint(columnNames = {"blockerId", "blockedId"}))
public class BlockEntity {
@Id
@Column
private UUID blockId;
@Column(nullable = false)
private UUID blockerId;
@Column(nullable = false)
private UUID blockedId;
@Column(nullable = false)
private LocalDateTime blockedAt;
}

View File

@@ -0,0 +1,25 @@
package de.oaa.xxx.social.repository;
import de.oaa.xxx.social.entity.BlockEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import java.util.UUID;
public interface BlockRepository extends JpaRepository<BlockEntity, UUID> {
Optional<BlockEntity> findByBlockerIdAndBlockedId(UUID blockerId, UUID blockedId);
/** True if either user has blocked the other. */
@Query("SELECT COUNT(b) > 0 FROM BlockEntity b WHERE (b.blockerId = :a AND b.blockedId = :b) OR (b.blockerId = :b AND b.blockedId = :a)")
boolean existsBlock(@Param("a") UUID a, @Param("b") UUID b);
@Modifying
@Transactional
@Query("DELETE FROM BlockEntity b WHERE b.blockerId = :blockerId AND b.blockedId = :blockedId")
void deleteByBlockerIdAndBlockedId(@Param("blockerId") UUID blockerId, @Param("blockedId") UUID blockedId);
}

View File

@@ -36,6 +36,14 @@ public interface MessageRepository extends JpaRepository<MessageEntity, UUID> {
@Query("UPDATE MessageEntity m SET m.readAt = :now WHERE m.senderId = :partnerId AND m.receiverId = :userId AND m.readAt IS NULL AND m.systemMessage = false")
void markAsRead(@Param("userId") UUID userId, @Param("partnerId") UUID partnerId, @Param("now") LocalDateTime now);
@Query("SELECT COUNT(m) > 0 FROM MessageEntity m WHERE ((m.senderId = :a AND m.receiverId = :b) OR (m.senderId = :b AND m.receiverId = :a)) AND m.systemMessage = false")
boolean conversationExists(@Param("a") UUID a, @Param("b") UUID b);
@Modifying
@Transactional
@Query("DELETE FROM MessageEntity m WHERE (m.senderId = :a AND m.receiverId = :b) OR (m.senderId = :b AND m.receiverId = :a)")
void deleteConversation(@Param("a") UUID a, @Param("b") UUID b);
// ── Notification queries (systemMessage = true) ───────────────────────────
/** Ungelesene zuerst, dann nach sentAt absteigend, max. 10 Einträge. */

View File

@@ -6,7 +6,7 @@ public enum Neigung {
SWITCHER("Switcher"),
EHER_DOMINANT("eher dominant"),
DOMINANT("dominant"),
KEINES("keines");
KEINES("weder noch");
private final String label;

View File

@@ -5,6 +5,7 @@ import lombok.Setter;
import java.time.LocalDate;
import java.time.Period;
import java.util.List;
import java.util.UUID;
@Getter
@@ -28,6 +29,10 @@ public class User {
private String datingStadt;
private Double datingLat;
private Double datingLon;
private List<String> datingGeschlechter;
private Integer datingMaxDistanzKm;
private Integer datingMinAlter;
private Integer datingMaxAlter;
public Integer getAlter() {
return geburtsdatum != null ? Period.between(geburtsdatum, LocalDate.now()).getYears() : null;

View File

@@ -91,7 +91,9 @@ public class UserController {
record TtlockUserConfigRequest(String username, String password, Integer lockId) {}
record ProfileRequest(Integer groesse, Integer gewicht,
Geschlecht geschlecht, Neigung neigung, Beziehungsstatus beziehungsstatus, String beschreibung) {}
record DatingRequest(boolean datingAktiv, String datingStadt, Double datingLat, Double datingLon) {}
record DatingRequest(boolean datingAktiv, String datingStadt, Double datingLat, Double datingLon,
List<String> datingGeschlechter,
Integer datingMaxDistanzKm, Integer datingMinAlter, Integer datingMaxAlter) {}
record PrivacyRequest(
Sichtbarkeit sichtbarkeitGrunddaten,
Sichtbarkeit sichtbarkeitGalerie,
@@ -113,6 +115,17 @@ public class UserController {
user.setDatingStadt(request.datingAktiv() ? request.datingStadt().trim() : null);
user.setDatingLat(request.datingAktiv() ? request.datingLat() : null);
user.setDatingLon(request.datingAktiv() ? request.datingLon() : null);
if (request.datingGeschlechter() != null && !request.datingGeschlechter().isEmpty()) {
String joined = request.datingGeschlechter().stream()
.filter(g -> { try { Geschlecht.valueOf(g); return true; } catch (IllegalArgumentException e) { return false; } })
.collect(Collectors.joining(","));
user.setDatingGeschlechter(joined.isBlank() ? null : joined);
} else {
user.setDatingGeschlechter(null);
}
if (request.datingMaxDistanzKm() != null) user.setDatingMaxDistanzKm(Math.max(1, Math.min(500, request.datingMaxDistanzKm())));
if (request.datingMinAlter() != null) user.setDatingMinAlter(Math.max(18, Math.min(99, request.datingMinAlter())));
if (request.datingMaxAlter() != null) user.setDatingMaxAlter(Math.max(18, Math.min(99, request.datingMaxAlter())));
userRepository.save(user);
LOGGER.info("User {} hat Dating-Einstellungen aktualisiert: aktiv={}", user.getUserId(), request.datingAktiv());
return ResponseEntity.ok().build();

View File

@@ -6,6 +6,8 @@ import lombok.Setter;
import java.time.LocalDate;
import java.time.Period;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
@Getter
@@ -112,6 +114,22 @@ public class UserEntity {
@Column
private Double datingLon;
/** Komma-separierte Geschlecht-Enum-Namen, z.B. "WEIBLICH,MAENNLICH". Null = keine Einschränkung. */
@Column(length = 60)
private String datingGeschlechter;
/** Standard-Filter: maximale Entfernung in km. Null = kein Vorgabewert gespeichert. */
@Column
private Integer datingMaxDistanzKm;
/** Standard-Filter: Mindestalter. Null = kein Vorgabewert gespeichert. */
@Column
private Integer datingMinAlter;
/** Standard-Filter: Höchstalter. Null = kein Vorgabewert gespeichert. */
@Column
private Integer datingMaxAlter;
public Integer getAlter() {
return geburtsdatum != null ? Period.between(geburtsdatum, LocalDate.now()).getYears() : null;
}
@@ -138,6 +156,13 @@ public class UserEntity {
user.setDatingStadt(datingStadt);
user.setDatingLat(datingLat);
user.setDatingLon(datingLon);
user.setDatingGeschlechter(
datingGeschlechter != null && !datingGeschlechter.isBlank()
? Arrays.asList(datingGeschlechter.split(","))
: null);
user.setDatingMaxDistanzKm(datingMaxDistanzKm);
user.setDatingMinAlter(datingMinAlter);
user.setDatingMaxAlter(datingMaxAlter);
return user;
}
}

View File

@@ -11,4 +11,5 @@ public interface UserRepository extends JpaRepository<UserEntity, UUID> {
Optional<UserEntity> findByEmail(String email);
Optional<UserEntity> findByName(String name);
List<UserEntity> findByNameContainingIgnoreCase(String name);
List<UserEntity> findByDatingAktiv(boolean datingAktiv);
}

View File

@@ -60,6 +60,8 @@ public class UserService {
private final KommentarRepository kommentarRepository;
private final KommentarLikeRepository kommentarLikeRepository;
private final NotificationPreferenceRepository notificationPreferenceRepository;
private final de.oaa.xxx.dating.DatingLikeRepository datingLikeRepository;
private final de.oaa.xxx.dating.DatingMatchRepository datingMatchRepository;
public UserService(UserRepository userRepository,
AufgabenGruppeRepository aufgabenGruppeRepository,
@@ -80,7 +82,9 @@ public class UserService {
PinnwandLikeRepository pinnwandLikeRepository,
KommentarRepository kommentarRepository,
KommentarLikeRepository kommentarLikeRepository,
NotificationPreferenceRepository notificationPreferenceRepository) {
NotificationPreferenceRepository notificationPreferenceRepository,
de.oaa.xxx.dating.DatingLikeRepository datingLikeRepository,
de.oaa.xxx.dating.DatingMatchRepository datingMatchRepository) {
this.userRepository = userRepository;
this.aufgabenGruppeRepository = aufgabenGruppeRepository;
this.aufgabeRepository = aufgabeRepository;
@@ -101,6 +105,8 @@ public class UserService {
this.kommentarRepository = kommentarRepository;
this.kommentarLikeRepository = kommentarLikeRepository;
this.notificationPreferenceRepository = notificationPreferenceRepository;
this.datingLikeRepository = datingLikeRepository;
this.datingMatchRepository = datingMatchRepository;
}
/**
@@ -187,7 +193,11 @@ public class UserService {
kommentarRepository.deleteByAuthorId(userId);
kommentarLikeRepository.deleteByUserId(userId);
// 6. User löschen
// 6. Dating-Likes und -Matches löschen
datingLikeRepository.deleteByLikerIdOrLikedId(userId, userId);
datingMatchRepository.deleteAllByUser(userId);
// 7. User löschen
userRepository.delete(user);
}

View File

@@ -9,4 +9,5 @@ import java.util.UUID;
public interface UserVorliebeRepository extends JpaRepository<UserVorliebeEntity, UUID> {
List<UserVorliebeEntity> findByUserId(UUID userId);
Optional<UserVorliebeEntity> findByUserIdAndItemId(UUID userId, UUID itemId);
List<UserVorliebeEntity> findByUserIdIn(java.util.Collection<UUID> userIds);
}