Weiter am Dating gearbeitet
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
This commit is contained in:
194
src/main/java/de/oaa/xxx/dating/DatingController.java
Normal file
194
src/main/java/de/oaa/xxx/dating/DatingController.java
Normal 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();
|
||||
}
|
||||
}
|
||||
29
src/main/java/de/oaa/xxx/dating/DatingLikeEntity.java
Normal file
29
src/main/java/de/oaa/xxx/dating/DatingLikeEntity.java
Normal 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;
|
||||
}
|
||||
21
src/main/java/de/oaa/xxx/dating/DatingLikeRepository.java
Normal file
21
src/main/java/de/oaa/xxx/dating/DatingLikeRepository.java
Normal 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);
|
||||
}
|
||||
28
src/main/java/de/oaa/xxx/dating/DatingMatchEntity.java
Normal file
28
src/main/java/de/oaa/xxx/dating/DatingMatchEntity.java
Normal 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;
|
||||
}
|
||||
27
src/main/java/de/oaa/xxx/dating/DatingMatchRepository.java
Normal file
27
src/main/java/de/oaa/xxx/dating/DatingMatchRepository.java
Normal 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);
|
||||
}
|
||||
28
src/main/java/de/oaa/xxx/dating/DatingPassEntity.java
Normal file
28
src/main/java/de/oaa/xxx/dating/DatingPassEntity.java
Normal 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;
|
||||
}
|
||||
16
src/main/java/de/oaa/xxx/dating/DatingPassRepository.java
Normal file
16
src/main/java/de/oaa/xxx/dating/DatingPassRepository.java
Normal 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);
|
||||
}
|
||||
347
src/main/java/de/oaa/xxx/dating/DatingService.java
Normal file
347
src/main/java/de/oaa/xxx/dating/DatingService.java
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
28
src/main/java/de/oaa/xxx/social/entity/BlockEntity.java
Normal file
28
src/main/java/de/oaa/xxx/social/entity/BlockEntity.java
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -6,7 +6,7 @@ public enum Neigung {
|
||||
SWITCHER("Switcher"),
|
||||
EHER_DOMINANT("eher dominant"),
|
||||
DOMINANT("dominant"),
|
||||
KEINES("keines");
|
||||
KEINES("weder noch");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
9
src/main/resources/sql/fix_sichtbarkeit_niemand.sql
Normal file
9
src/main/resources/sql/fix_sichtbarkeit_niemand.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Ersetzt ungültigen Wert 'NIEMAND' durch 'NUR_ICH' in allen Sichtbarkeit-Spalten der user-Tabelle.
|
||||
UPDATE `user` SET sichtbarkeit_grunddaten = 'NUR_ICH' WHERE sichtbarkeit_grunddaten = 'NIEMAND';
|
||||
UPDATE `user` SET sichtbarkeit_galerie = 'NUR_ICH' WHERE sichtbarkeit_galerie = 'NIEMAND';
|
||||
UPDATE `user` SET sichtbarkeit_freunde = 'NUR_ICH' WHERE sichtbarkeit_freunde = 'NIEMAND';
|
||||
UPDATE `user` SET sichtbarkeit_feed = 'NUR_ICH' WHERE sichtbarkeit_feed = 'NIEMAND';
|
||||
UPDATE `user` SET sichtbarkeit_pinnwand = 'NUR_ICH' WHERE sichtbarkeit_pinnwand = 'NIEMAND';
|
||||
UPDATE `user` SET sichtbarkeit_xp = 'NUR_ICH' WHERE sichtbarkeit_xp = 'NIEMAND';
|
||||
UPDATE `user` SET sichtbarkeit_lockhistorie = 'NUR_ICH' WHERE sichtbarkeit_lockhistorie = 'NIEMAND';
|
||||
UPDATE `user` SET sichtbarkeit_vorlieben = 'NUR_ICH' WHERE sichtbarkeit_vorlieben = 'NIEMAND';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -536,6 +536,7 @@
|
||||
let isOwnProfile = false;
|
||||
let profileData = null;
|
||||
let avatarSrc = null;
|
||||
let blockedByMe = false;
|
||||
|
||||
let allImages = [];
|
||||
let galleryOffset = 0;
|
||||
@@ -575,10 +576,11 @@
|
||||
|
||||
async function loadProfile() {
|
||||
try {
|
||||
const [me, profile, images] = await Promise.all([
|
||||
const [me, profile, images, blockStatus] = await Promise.all([
|
||||
fetch('/login/me').then(r => r.ok ? r.json() : null),
|
||||
fetch('/social/users/' + targetUserId).then(r => r.ok ? r.json() : null),
|
||||
fetch('/social/profile-images?userId=' + targetUserId).then(r => r.ok ? r.json() : [])
|
||||
fetch('/social/profile-images?userId=' + targetUserId).then(r => r.ok ? r.json() : []),
|
||||
fetch('/social/block/' + targetUserId).then(r => r.ok ? r.json() : { blockedByMe: false })
|
||||
]);
|
||||
|
||||
document.getElementById('loadingHint').style.display = 'none';
|
||||
@@ -592,6 +594,7 @@
|
||||
isOwnProfile = !previewMode && me && me.userId === profile.userId;
|
||||
profileData = profile;
|
||||
allImages = images;
|
||||
blockedByMe = blockStatus?.blockedByMe ?? false;
|
||||
|
||||
// Profilbesuch tracken (nur fremde Profile, kein Preview-Modus)
|
||||
if (!isOwnProfile && !previewMode && myUserId) {
|
||||
@@ -689,17 +692,26 @@
|
||||
actions.innerHTML = '';
|
||||
} else {
|
||||
let html = '';
|
||||
if (profile.friendStatus === 'FRIEND') {
|
||||
if (!blockedByMe) {
|
||||
html += `<a href="/community/nachrichten.html?userId=${profile.userId}" class="btn">✉ Nachricht</a>`;
|
||||
} else if (profile.friendStatus === 'PENDING_SENT') {
|
||||
html += `<button disabled>Anfrage gesendet</button>`;
|
||||
} else if (profile.friendStatus === 'PENDING_RECEIVED') {
|
||||
html += `<button id="friendActionBtn" onclick="acceptFriend()">✓ Anfrage annehmen</button>`;
|
||||
if (profile.friendStatus === 'PENDING_SENT') {
|
||||
html += ` <button disabled>Anfrage gesendet</button>`;
|
||||
} else if (profile.friendStatus === 'PENDING_RECEIVED') {
|
||||
html += ` <button id="friendActionBtn" onclick="acceptFriend()">✓ Anfrage annehmen</button>`;
|
||||
} else if (profile.friendStatus !== 'FRIEND') {
|
||||
html += ` <button id="friendActionBtn" onclick="addFriend()">+ Freund hinzufügen</button>`;
|
||||
}
|
||||
html += ` <button onclick="openMeldungDialog('PROFIL','${profile.userId}')" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);border-radius:6px;padding:0.4rem 0.8rem;cursor:pointer;font-size:0.85rem;">⚑ Melden</button>`;
|
||||
html += ` <button onclick="confirmBlock('${profile.userId}','${esc(profile.name)}')" style="background:none;border:1px solid #7a1a1a;color:#c0392b;border-radius:6px;padding:0.4rem 0.8rem;cursor:pointer;font-size:0.85rem;">⊘ Blockieren</button>`;
|
||||
} else {
|
||||
html += `<button id="friendActionBtn" onclick="addFriend()">+ Freund hinzufügen</button>`;
|
||||
html += `<button onclick="unblockUser('${profile.userId}')" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);border-radius:6px;padding:0.4rem 0.8rem;cursor:pointer;font-size:0.85rem;">Blockierung aufheben</button>`;
|
||||
}
|
||||
html += ` <button onclick="openMeldungDialog('PROFIL','${profile.userId}')" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);border-radius:6px;padding:0.4rem 0.8rem;cursor:pointer;font-size:0.85rem;">⚑ Melden</button>`;
|
||||
actions.innerHTML = html;
|
||||
|
||||
// Dating-Like-Button – nur anzeigen wenn Ziel-User Dating aktiviert hat und nicht blockiert
|
||||
if (profile.datingAktiv && !blockedByMe) {
|
||||
loadDatingLikeButton(profile.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -983,6 +995,11 @@
|
||||
|
||||
// ── Pinnwand ──
|
||||
async function loadPinnwand() {
|
||||
// Schreibbereich ausblenden wenn wir die Person blockiert haben oder es das eigene Profil ist
|
||||
if (blockedByMe || isOwnProfile) {
|
||||
document.querySelector('.pinnwand-write').style.display = 'none';
|
||||
}
|
||||
|
||||
const res = await fetch('/social/pinnwand?userId=' + targetUserId);
|
||||
if (!res.ok) return;
|
||||
const eintraege = await res.json();
|
||||
@@ -1131,6 +1148,31 @@
|
||||
body: JSON.stringify({ profilUserId: targetUserId, text })
|
||||
});
|
||||
if (res.ok) { ta.value = ''; await loadPinnwand(); }
|
||||
else if (res.status === 403) {
|
||||
alert('Du kannst auf dieser Pinnwand keinen Eintrag hinterlassen.');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blockieren ──
|
||||
function confirmBlock(userId, userName) {
|
||||
if (!confirm(`Möchtest du ${userName} wirklich blockieren?\n\nDer gesamte bisherige Chat wird gelöscht. Die Person kann dich nicht mehr kontaktieren und keine Pinnwand-Einträge hinterlassen.`)) return;
|
||||
blockUser(userId);
|
||||
}
|
||||
|
||||
async function blockUser(userId) {
|
||||
const res = await fetch('/social/block/' + userId, { method: 'POST' });
|
||||
if (res.ok || res.status === 409) {
|
||||
blockedByMe = true;
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function unblockUser(userId) {
|
||||
const res = await fetch('/social/block/' + userId, { method: 'DELETE' });
|
||||
if (res.ok || res.status === 404) {
|
||||
blockedByMe = false;
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEintrag(eintragId) {
|
||||
@@ -1410,6 +1452,60 @@
|
||||
});
|
||||
|
||||
// esc, fmtDate, toggleEmojiPicker, insertEmoji kommen aus shared.js
|
||||
|
||||
// ── Dating-Like auf Profilseite ──────────────────────────────────────────
|
||||
async function loadDatingLikeButton(targetUserId) {
|
||||
// Nur einblenden wenn eigener User Dating aktiviert hat
|
||||
const meRes = await fetch('/login/me');
|
||||
if (!meRes.ok) return;
|
||||
const me = await meRes.json();
|
||||
if (!me.datingAktiv) return;
|
||||
|
||||
let liked = false;
|
||||
try {
|
||||
const idsRes = await fetch('/dating/liked-by-me');
|
||||
if (idsRes.ok) {
|
||||
const ids = await idsRes.json();
|
||||
liked = ids.includes(targetUserId);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'datingLikeBtn';
|
||||
btn.title = liked ? 'Unlike' : 'Like';
|
||||
btn.style.cssText = 'padding:0.4rem 0.9rem;font-size:0.9rem;' +
|
||||
(liked ? 'background:var(--color-primary);color:#fff;' : 'background:none;border:1px solid var(--color-primary);color:var(--color-primary);') +
|
||||
'border-radius:6px;cursor:pointer;';
|
||||
btn.textContent = liked ? '♥ Liked' : '♥ Liken';
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/dating/like/' + targetUserId, { method: 'POST' });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
liked = data.liked;
|
||||
btn.textContent = liked ? '♥ Liked' : '♥ Liken';
|
||||
btn.title = liked ? 'Unlike' : 'Like';
|
||||
btn.style.background = liked ? 'var(--color-primary)' : 'none';
|
||||
btn.style.color = liked ? '#fff' : 'var(--color-primary)';
|
||||
btn.style.border = liked ? 'none' : '1px solid var(--color-primary)';
|
||||
if (data.newMatch) {
|
||||
const t = document.createElement('div');
|
||||
t.textContent = '🎉 Es ist ein Match!';
|
||||
Object.assign(t.style, {
|
||||
position:'fixed', bottom:'2rem', left:'50%', transform:'translateX(-50%)',
|
||||
background:'var(--color-primary)', color:'#fff', padding:'0.75rem 1.5rem',
|
||||
borderRadius:'8px', fontWeight:'700', zIndex:'999'
|
||||
});
|
||||
document.body.appendChild(t);
|
||||
setTimeout(() => t.remove(), 3500);
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
document.getElementById('profileActions').appendChild(btn);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -529,15 +529,41 @@
|
||||
if (!text) return;
|
||||
input.value = '';
|
||||
try {
|
||||
await fetch('/social/messages', {
|
||||
const res = await fetch('/social/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ receiverId: activePartnerId, text })
|
||||
});
|
||||
if (res.status === 403) {
|
||||
let reason = '';
|
||||
try { const body = await res.json(); reason = body.reason; } catch (_) {}
|
||||
if (reason === 'FIRST_MESSAGE_RESTRICTED') {
|
||||
showThreadNotice('Du kannst diese Person nur anschreiben, wenn ihr befreundet seid, ein Match habt oder du ein Pro-Abo hast.');
|
||||
} else if (reason === 'BLOCKED') {
|
||||
showThreadNotice('Diese Konversation ist nicht mehr möglich.');
|
||||
} else {
|
||||
showThreadNotice('Nachricht konnte nicht gesendet werden.');
|
||||
}
|
||||
// Text wieder zurücksetzen, damit der User ihn nicht verliert
|
||||
input.value = text;
|
||||
return;
|
||||
}
|
||||
await pollNewMessages();
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function showThreadNotice(msg) {
|
||||
const existing = document.getElementById('threadNotice');
|
||||
if (existing) existing.remove();
|
||||
const notice = document.createElement('div');
|
||||
notice.id = 'threadNotice';
|
||||
notice.style.cssText = 'background:rgba(180,0,60,0.12);border:1px solid rgba(180,0,60,0.35);border-radius:8px;padding:0.75rem 1rem;font-size:0.88rem;color:var(--color-text);margin:0.5rem 0;line-height:1.45;';
|
||||
notice.textContent = msg;
|
||||
const container = document.getElementById('threadMessages');
|
||||
container.appendChild(notice);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
document.getElementById('msgInput').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMsg(); }
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -91,6 +91,13 @@
|
||||
} catch(ex) {}
|
||||
});
|
||||
|
||||
es.addEventListener('MATCH', e => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (typeof window._onSseMatch === 'function') window._onSseMatch(data);
|
||||
} catch(ex) {}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
// Vor dem Reconnect prüfen ob noch eingeloggt (verhindert Endlos-Schleife bei abgelaufener Session)
|
||||
|
||||
@@ -488,6 +488,63 @@
|
||||
<input type="checkbox" id="datingAktiv" style="width:1.1rem;height:1.1rem;accent-color:var(--color-primary);cursor:pointer;" onchange="onDatingToggle()">
|
||||
</label>
|
||||
</div>
|
||||
<div id="datingSucheRow" style="display:none;">
|
||||
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
||||
<div class="settings-row-info">
|
||||
<div class="settings-row-label">Interesse an</div>
|
||||
<div class="settings-row-desc">Welche Geschlechter sollen standardmäßig in der Dating-Suche angezeigt werden?</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:1rem;flex-wrap:wrap;">
|
||||
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;margin:0;font-size:0.9rem;color:var(--color-text);">
|
||||
<input type="checkbox" id="sucheWeiblich" value="WEIBLICH"
|
||||
style="width:1rem;height:1rem;accent-color:var(--color-primary);cursor:pointer;">
|
||||
weiblich
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;margin:0;font-size:0.9rem;color:var(--color-text);">
|
||||
<input type="checkbox" id="sucheMaennlich" value="MAENNLICH"
|
||||
style="width:1rem;height:1rem;accent-color:var(--color-primary);cursor:pointer;">
|
||||
männlich
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;margin:0;font-size:0.9rem;color:var(--color-text);">
|
||||
<input type="checkbox" id="sucheDivers" value="DIVERS"
|
||||
style="width:1rem;height:1rem;accent-color:var(--color-primary);cursor:pointer;">
|
||||
divers
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
||||
<div class="settings-row-info">
|
||||
<div class="settings-row-label">Standard-Umkreis</div>
|
||||
<div class="settings-row-desc">Maximale Entfernung als Standardwert im Dating-Filter.</div>
|
||||
</div>
|
||||
<div style="width:100%;max-width:320px;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:var(--color-muted);margin-bottom:0.25rem;">
|
||||
<span>Umkreis</span><span id="datingDistValDisplay" style="color:var(--color-text);">50 km</span>
|
||||
</div>
|
||||
<input type="range" id="datingMaxDistanz" min="5" max="500" value="50" step="5"
|
||||
style="width:100%;accent-color:var(--color-primary);padding:0;background:none;border:none;"
|
||||
oninput="document.getElementById('datingDistValDisplay').textContent=this.value+' km'">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
||||
<div class="settings-row-info">
|
||||
<div class="settings-row-label">Standard-Altersbereich</div>
|
||||
<div class="settings-row-desc">Gesuchter Altersbereich als Standardwert im Dating-Filter.</div>
|
||||
</div>
|
||||
<div style="width:100%;max-width:320px;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:var(--color-muted);margin-bottom:0.25rem;">
|
||||
<span>Alter</span><span id="datingAgeValDisplay" style="color:var(--color-text);">18 – 60</span>
|
||||
</div>
|
||||
<div style="position:relative;height:20px;margin:0.25rem 0;" id="datingAgeSlider">
|
||||
<div style="position:absolute;top:50%;left:0;right:0;height:4px;background:var(--color-secondary);border-radius:2px;transform:translateY(-50%);">
|
||||
<div id="datingAgeRange" style="position:absolute;top:0;height:100%;background:var(--color-primary);border-radius:2px;"></div>
|
||||
</div>
|
||||
<div id="datingThumbMin" tabindex="0" style="position:absolute;top:50%;width:18px;height:18px;background:var(--color-primary);border:2px solid #fff;border-radius:50%;transform:translate(-50%,-50%);cursor:grab;box-shadow:0 1px 4px rgba(0,0,0,0.4);touch-action:none;"></div>
|
||||
<div id="datingThumbMax" tabindex="0" style="position:absolute;top:50%;width:18px;height:18px;background:var(--color-primary);border:2px solid #fff;border-radius:50%;transform:translate(-50%,-50%);cursor:grab;box-shadow:0 1px 4px rgba(0,0,0,0.4);touch-action:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="datingStadtRow" style="display:none;">
|
||||
<div class="settings-row" style="flex-wrap:wrap;gap:0.5rem;">
|
||||
<div class="settings-row-info">
|
||||
@@ -1201,12 +1258,85 @@
|
||||
}
|
||||
if (user.datingLat != null) _datingLat = user.datingLat;
|
||||
if (user.datingLon != null) _datingLon = user.datingLon;
|
||||
document.getElementById('datingStadtRow').style.display = user.datingAktiv ? '' : 'none';
|
||||
const show = user.datingAktiv ? '' : 'none';
|
||||
document.getElementById('datingStadtRow').style.display = show;
|
||||
document.getElementById('datingSucheRow').style.display = show;
|
||||
const aktiveGeschlechter = user.datingGeschlechter || [];
|
||||
document.getElementById('sucheWeiblich').checked = aktiveGeschlechter.includes('WEIBLICH');
|
||||
document.getElementById('sucheMaennlich').checked = aktiveGeschlechter.includes('MAENNLICH');
|
||||
document.getElementById('sucheDivers').checked = aktiveGeschlechter.includes('DIVERS');
|
||||
|
||||
if (user.datingMaxDistanzKm != null) {
|
||||
document.getElementById('datingMaxDistanz').value = user.datingMaxDistanzKm;
|
||||
document.getElementById('datingDistValDisplay').textContent = user.datingMaxDistanzKm + ' km';
|
||||
}
|
||||
if (user.datingMinAlter != null) _datingAgeFrom = user.datingMinAlter;
|
||||
if (user.datingMaxAlter != null) _datingAgeTo = user.datingMaxAlter;
|
||||
updateDatingAgeSlider();
|
||||
}
|
||||
|
||||
// ── Dating-Alters-Slider ──
|
||||
const _DATING_AGE_MIN = 18, _DATING_AGE_MAX = 99;
|
||||
let _datingAgeFrom = 18, _datingAgeTo = 60;
|
||||
|
||||
function _datingAgePct(v) { return (v - _DATING_AGE_MIN) / (_DATING_AGE_MAX - _DATING_AGE_MIN) * 100; }
|
||||
|
||||
function updateDatingAgeSlider() {
|
||||
const lo = _datingAgePct(_datingAgeFrom), hi = _datingAgePct(_datingAgeTo);
|
||||
document.getElementById('datingThumbMin').style.left = lo + '%';
|
||||
document.getElementById('datingThumbMax').style.left = hi + '%';
|
||||
document.getElementById('datingAgeRange').style.left = lo + '%';
|
||||
document.getElementById('datingAgeRange').style.width = (hi - lo) + '%';
|
||||
document.getElementById('datingAgeValDisplay').textContent = _datingAgeFrom + ' – ' + _datingAgeTo;
|
||||
}
|
||||
|
||||
(function initDatingAgeSlider() {
|
||||
const slider = document.getElementById('datingAgeSlider');
|
||||
function makeThumb(thumbId, isMin) {
|
||||
const thumb = document.getElementById(thumbId);
|
||||
function onMove(clientX) {
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
const raw = _DATING_AGE_MIN + Math.round(ratio * (_DATING_AGE_MAX - _DATING_AGE_MIN));
|
||||
if (isMin) _datingAgeFrom = Math.min(raw, _datingAgeTo - 1);
|
||||
else _datingAgeTo = Math.max(raw, _datingAgeFrom + 1);
|
||||
updateDatingAgeSlider();
|
||||
}
|
||||
thumb.addEventListener('mousedown', e => {
|
||||
e.preventDefault();
|
||||
const move = ev => onMove(ev.clientX);
|
||||
const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); };
|
||||
document.addEventListener('mousemove', move);
|
||||
document.addEventListener('mouseup', up);
|
||||
});
|
||||
thumb.addEventListener('touchstart', e => {
|
||||
e.preventDefault();
|
||||
const move = ev => onMove(ev.touches[0].clientX);
|
||||
const end = () => { document.removeEventListener('touchmove', move); document.removeEventListener('touchend', end); };
|
||||
document.addEventListener('touchmove', move, { passive: false });
|
||||
document.addEventListener('touchend', end);
|
||||
}, { passive: false });
|
||||
thumb.addEventListener('keydown', e => {
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
||||
if (isMin) _datingAgeFrom = Math.max(_DATING_AGE_MIN, _datingAgeFrom - 1);
|
||||
else _datingAgeTo = Math.max(_datingAgeFrom + 1, _datingAgeTo - 1);
|
||||
updateDatingAgeSlider(); e.preventDefault();
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
||||
if (isMin) _datingAgeFrom = Math.min(_datingAgeTo - 1, _datingAgeFrom + 1);
|
||||
else _datingAgeTo = Math.min(_DATING_AGE_MAX, _datingAgeTo + 1);
|
||||
updateDatingAgeSlider(); e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
makeThumb('datingThumbMin', true);
|
||||
makeThumb('datingThumbMax', false);
|
||||
updateDatingAgeSlider();
|
||||
})();
|
||||
|
||||
function onDatingToggle() {
|
||||
const aktiv = document.getElementById('datingAktiv').checked;
|
||||
document.getElementById('datingStadtRow').style.display = aktiv ? '' : 'none';
|
||||
const show = document.getElementById('datingAktiv').checked ? '' : 'none';
|
||||
document.getElementById('datingStadtRow').style.display = show;
|
||||
document.getElementById('datingSucheRow').style.display = show;
|
||||
}
|
||||
|
||||
let _stadtSuggestTimer = null;
|
||||
@@ -1312,7 +1442,18 @@
|
||||
const res = await fetch('/user/me/dating', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ datingAktiv: aktiv, datingStadt: stadt || null, datingLat: _datingLat, datingLon: _datingLon })
|
||||
body: JSON.stringify({
|
||||
datingAktiv: aktiv,
|
||||
datingStadt: stadt || null,
|
||||
datingLat: _datingLat,
|
||||
datingLon: _datingLon,
|
||||
datingGeschlechter: ['sucheWeiblich','sucheMaennlich','sucheDivers']
|
||||
.filter(id => document.getElementById(id).checked)
|
||||
.map(id => document.getElementById(id).value),
|
||||
datingMaxDistanzKm: parseInt(document.getElementById('datingMaxDistanz').value),
|
||||
datingMinAlter: _datingAgeFrom,
|
||||
datingMaxAlter: _datingAgeTo
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast();
|
||||
|
||||
@@ -60,6 +60,53 @@
|
||||
width: 100%;
|
||||
}
|
||||
.visitor-time { font-size: 0.68rem; color: var(--color-muted); text-align: center; }
|
||||
|
||||
/* ── Dating: Likes & Matches ── */
|
||||
.dating-strip { display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||
.dating-card {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
|
||||
text-decoration: none; color: var(--color-text); width: 72px;
|
||||
}
|
||||
.dating-card:hover .dating-avatar { border-color: var(--color-primary); }
|
||||
.dating-avatar {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: var(--color-secondary);
|
||||
border: 2px solid var(--color-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.4rem; overflow: hidden; flex-shrink: 0;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.dating-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
.dating-name {
|
||||
font-size: 0.75rem; text-align: center;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 100%;
|
||||
}
|
||||
/* Verschwommene Karte für nicht-Premium */
|
||||
.dating-card-locked {
|
||||
width: 72px; display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
|
||||
cursor: default;
|
||||
}
|
||||
.dating-avatar-blurred {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: var(--color-secondary);
|
||||
border: 2px solid var(--color-secondary);
|
||||
overflow: hidden; flex-shrink: 0; position: relative;
|
||||
}
|
||||
.dating-avatar-blurred img {
|
||||
width: 100%; height: 100%; object-fit: cover; border-radius: 50%;
|
||||
filter: blur(6px); transform: scale(1.1);
|
||||
}
|
||||
.dating-avatar-blurred .lock-icon {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.premium-hint {
|
||||
font-size: 0.65rem; color: var(--color-primary); text-align: center; font-weight: 600;
|
||||
}
|
||||
.match-badge {
|
||||
font-size: 0.65rem; color: var(--color-primary); text-align: center; font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
@@ -105,6 +152,18 @@
|
||||
<div class="section-label">Profilbesucher</div>
|
||||
<div class="visitors-strip" id="visitorsStrip"></div>
|
||||
</div>
|
||||
|
||||
<!-- Wer hat mich geliked (Dating) -->
|
||||
<div id="likesSection" style="display:none;">
|
||||
<div class="section-label">Dating – Wer mag mich ♥</div>
|
||||
<div class="dating-strip" id="likesStrip"></div>
|
||||
</div>
|
||||
|
||||
<!-- Matches -->
|
||||
<div id="matchesSection" style="display:none;">
|
||||
<div class="section-label">Dating – Matches 🎉</div>
|
||||
<div class="dating-strip" id="matchesStrip"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -120,6 +179,10 @@
|
||||
if (user) {
|
||||
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
|
||||
loadVisitors();
|
||||
if (user.datingAktiv) {
|
||||
loadWhoLikesMe();
|
||||
loadMatches();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => { window.location.href = '/login.html'; });
|
||||
@@ -132,6 +195,69 @@
|
||||
return 'vor ' + Math.floor(diff / 86400) + ' Tag' + (Math.floor(diff / 86400) === 1 ? '' : 'en');
|
||||
}
|
||||
|
||||
async function loadWhoLikesMe() {
|
||||
try {
|
||||
const res = await fetch('/dating/who-likes-me');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.total === 0) return;
|
||||
|
||||
const strip = document.getElementById('likesStrip');
|
||||
strip.innerHTML = data.likers.map(l => {
|
||||
const pic = l.profilePicture
|
||||
? `<img src="data:image/png;base64,${l.profilePicture}" alt="">`
|
||||
: '◉';
|
||||
|
||||
if (data.premium && l.userId) {
|
||||
return `
|
||||
<a class="dating-card" href="/community/benutzer.html?userId=${l.userId}">
|
||||
<div class="dating-avatar">${pic}</div>
|
||||
<span class="dating-name">${esc(l.name)}</span>
|
||||
</a>`;
|
||||
} else {
|
||||
return `
|
||||
<div class="dating-card-locked">
|
||||
<div class="dating-avatar-blurred">
|
||||
${pic}
|
||||
<span class="lock-icon">🔒</span>
|
||||
</div>
|
||||
<span class="premium-hint">Premium</span>
|
||||
</div>`;
|
||||
}
|
||||
}).join('');
|
||||
|
||||
document.getElementById('likesSection').style.display = '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function loadMatches() {
|
||||
try {
|
||||
const res = await fetch('/dating/matches');
|
||||
if (!res.ok) return;
|
||||
const matches = await res.json();
|
||||
if (!matches.length) return;
|
||||
|
||||
const strip = document.getElementById('matchesStrip');
|
||||
strip.innerHTML = matches.map(m => `
|
||||
<a class="dating-card" href="/community/benutzer.html?userId=${m.userId}">
|
||||
<div class="dating-avatar">
|
||||
${m.profilePicture
|
||||
? `<img src="data:image/png;base64,${m.profilePicture}" alt="${esc(m.name)}">`
|
||||
: '◉'}
|
||||
</div>
|
||||
<span class="dating-name">${esc(m.name)}</span>
|
||||
<span class="match-badge">♥ Match</span>
|
||||
</a>
|
||||
`).join('');
|
||||
document.getElementById('matchesSection').style.display = '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
async function loadVisitors() {
|
||||
try {
|
||||
const res = await fetch('/social/profile-visits/my-visitors');
|
||||
|
||||
Reference in New Issue
Block a user