An den Verantaltungen und Locations gearbeitet
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled

This commit is contained in:
2026-04-05 23:04:09 +02:00
parent b81ad25c9f
commit 0f9f109067
65 changed files with 5260 additions and 3 deletions

View File

@@ -1,3 +1,5 @@
Slomo und Speedup Card
Sammeln von Erfahrung
TODO: Im Time Lock, wenn im Spinning Wheel tasks drin sind, dürfen keine sonst keine Tasks gefordert sein und umgekehrt
@@ -33,4 +35,8 @@ Ich kann Spieler einladen zu spielen, dann kriegt die Person eine E-Mail und mus
Die interessantesten wären wohl Würfel und Countdown, da sie mehr Spannung erzeugen ohne den Ablauf zu sehr zu unterbrechen.
wenn ich dates erfasse kann ich diese auch zu einer Verantstaltung machen,
hier kann ich die auswählen, zu denen ich "Ich bin dabei" gedrückt habe, das
Date wird dann auf den Standort und Zeitpunkt festgelegt. fragen?

View File

@@ -71,11 +71,15 @@ public class SecurityConfig {
.requestMatchers("/games/chastity/joinlock.html").authenticated()
.requestMatchers("/community/benachrichtigungen.html").authenticated()
.requestMatchers("/community/abonnements.html").authenticated()
.requestMatchers("/community/locations.html").authenticated()
.requestMatchers("/community/location-detail.html").authenticated()
.requestMatchers("/community/events.html").authenticated()
.requestMatchers("/community/event-detail.html").authenticated()
.requestMatchers("/gruppen/**").authenticated()
.requestMatchers("/feed/**").authenticated()
.requestMatchers("/notifications/**").authenticated()
.requestMatchers("/events/**").authenticated()
.requestMatchers("/*.html").permitAll()
.requestMatchers("/*.html").permitAll()
.requestMatchers("/**/*.html").permitAll()
.requestMatchers("/help/*.html").permitAll()
.requestMatchers("/css/**").permitAll()

View File

@@ -0,0 +1,425 @@
package de.oaa.xxx.location;
import de.oaa.xxx.location.entity.*;
import de.oaa.xxx.location.repository.*;
import de.oaa.xxx.user.UserService;
import jakarta.transaction.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/locations")
public class LocationController {
private static final Logger LOGGER = LoggerFactory.getLogger(LocationController.class);
private static final int MAX_GALLERY_IMAGES = 20;
private static final int MAX_BATCH_SIZE = 50;
private final LocationRepository locationRepo;
private final LocationImageRepository imageRepo;
private final LocationOpeningHoursRepository hoursRepo;
private final LocationEventRepository eventRepo;
private final LocationEventAttendeeRepository attendeeRepo;
private final LocationFollowRepository followRepo;
private final UserService userService;
public LocationController(LocationRepository locationRepo,
LocationImageRepository imageRepo,
LocationOpeningHoursRepository hoursRepo,
LocationEventRepository eventRepo,
LocationEventAttendeeRepository attendeeRepo,
LocationFollowRepository followRepo,
UserService userService) {
this.locationRepo = locationRepo;
this.imageRepo = imageRepo;
this.hoursRepo = hoursRepo;
this.eventRepo = eventRepo;
this.attendeeRepo = attendeeRepo;
this.followRepo = followRepo;
this.userService = userService;
}
// ── DTOs ─────────────────────────────────────────────────────────────────
record IdsResult(List<UUID> ids, int total) {}
record LocationPreviewDto(UUID locationId, String name, String profilePictureLq, double distanzKm) {}
record OpeningHourDto(int dayOfWeek, String openTime, String closeTime, boolean closed) {}
record GalleryImageDto(UUID imageId, String imageData) {}
record LocationDetailDto(
UUID locationId,
UUID ownerId,
String name,
String description,
String profilePictureHq,
String profilePictureLq,
Double lat,
Double lon,
String street,
String city,
boolean ownershipConfirmed,
LocalDateTime createdAt,
List<GalleryImageDto> gallery,
List<OpeningHourDto> openingHours,
boolean following
) {}
record CreateRequest(
String name,
String description,
String profilePictureLq,
String profilePictureHq,
Double lat,
Double lon,
String street,
String city,
boolean ownershipConfirmed
) {}
record UpdateRequest(
String name,
String description,
String profilePictureLq,
String profilePictureHq,
Double lat,
Double lon,
String street,
String city
) {}
record GalleryUploadRequest(String imageData) {}
// ── Suche / IDs ──────────────────────────────────────────────────────────
/**
* Gibt alle Location-IDs zurück, sortiert nach Entfernung vom angegebenen Punkt.
*/
@GetMapping("/ids")
public ResponseEntity<IdsResult> getIds(
@RequestParam double lat,
@RequestParam double lon,
@RequestParam(defaultValue = "50") int maxDistanceKm,
Principal principal) {
userService.requireUser(principal);
List<UUID> sorted = locationRepo.findByLatIsNotNullAndLonIsNotNull().stream()
.filter(l -> haversineKm(lat, lon, l.getLat(), l.getLon()) <= maxDistanceKm)
.sorted(Comparator.comparingDouble(l -> haversineKm(lat, lon, l.getLat(), l.getLon())))
.map(LocationEntity::getLocationId)
.collect(Collectors.toList());
return ResponseEntity.ok(new IdsResult(sorted, sorted.size()));
}
/**
* Batch-Laden von Location-Previews (Name + LQ-Bild + Distanz).
* Benötigt lat/lon für die Distanzberechnung.
*/
@PostMapping("/batch")
public ResponseEntity<List<LocationPreviewDto>> getBatch(
@RequestBody BatchRequest request,
Principal principal) {
userService.requireUser(principal);
if (request.ids() == null || request.ids().isEmpty()) return ResponseEntity.ok(List.of());
List<UUID> ids = request.ids().stream().filter(Objects::nonNull).limit(MAX_BATCH_SIZE).toList();
Map<UUID, LocationEntity> byId = locationRepo.findAllById(ids).stream()
.collect(Collectors.toMap(LocationEntity::getLocationId, l -> l));
double refLat = request.lat() != null ? request.lat() : 0;
double refLon = request.lon() != null ? request.lon() : 0;
List<LocationPreviewDto> result = ids.stream()
.map(byId::get)
.filter(Objects::nonNull)
.map(l -> new LocationPreviewDto(
l.getLocationId(),
l.getName(),
l.getProfilePictureLq(),
l.getLat() != null && l.getLon() != null
? Math.round(haversineKm(refLat, refLon, l.getLat(), l.getLon()) * 10.0) / 10.0
: -1))
.toList();
return ResponseEntity.ok(result);
}
record BatchRequest(List<UUID> ids, Double lat, Double lon) {}
/**
* Meine eigenen Locations (IDs, neueste zuerst).
*/
@GetMapping("/mine")
public ResponseEntity<IdsResult> getMine(Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
List<UUID> ids = locationRepo.findByOwnerIdOrderByCreatedAtDesc(myId).stream()
.map(LocationEntity::getLocationId)
.toList();
return ResponseEntity.ok(new IdsResult(ids, ids.size()));
}
// ── Detail ───────────────────────────────────────────────────────────────
@GetMapping("/{locationId}")
public ResponseEntity<LocationDetailDto> getDetail(
@PathVariable UUID locationId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
return locationRepo.findById(locationId)
.map(l -> ResponseEntity.ok(toDetail(l, myId)))
.orElse(ResponseEntity.notFound().build());
}
// ── CRUD ─────────────────────────────────────────────────────────────────
@PostMapping
public ResponseEntity<LocationDetailDto> create(
@RequestBody CreateRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
if (req.name() == null || req.name().isBlank()) return ResponseEntity.badRequest().build();
LocationEntity loc = new LocationEntity();
loc.setLocationId(UUID.randomUUID());
loc.setOwnerId(myId);
loc.setName(req.name().trim());
loc.setDescription(req.description() != null ? req.description().trim() : null);
loc.setProfilePictureLq(req.profilePictureLq());
loc.setProfilePictureHq(req.profilePictureHq());
loc.setLat(req.lat());
loc.setLon(req.lon());
loc.setStreet(req.street());
loc.setCity(req.city());
loc.setOwnershipConfirmed(req.ownershipConfirmed());
loc.setCreatedAt(LocalDateTime.now());
locationRepo.save(loc);
LOGGER.info("User {} hat Location {} angelegt", myId, loc.getLocationId());
return ResponseEntity.status(201).body(toDetail(loc, myId));
}
@PutMapping("/{locationId}")
public ResponseEntity<LocationDetailDto> update(
@PathVariable UUID locationId,
@RequestBody UpdateRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var opt = locationRepo.findById(locationId);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
LocationEntity loc = opt.get();
if (!loc.getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
if (req.name() != null && !req.name().isBlank()) loc.setName(req.name().trim());
if (req.description() != null) loc.setDescription(req.description().trim());
if (req.profilePictureLq() != null) loc.setProfilePictureLq(req.profilePictureLq());
if (req.profilePictureHq() != null) loc.setProfilePictureHq(req.profilePictureHq());
if (req.lat() != null) loc.setLat(req.lat());
if (req.lon() != null) loc.setLon(req.lon());
if (req.street() != null) loc.setStreet(req.street());
if (req.city() != null) loc.setCity(req.city());
locationRepo.save(loc);
return ResponseEntity.ok(toDetail(loc, myId));
}
@Transactional
@DeleteMapping("/{locationId}")
public ResponseEntity<Void> delete(
@PathVariable UUID locationId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var opt = locationRepo.findById(locationId);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
if (!opt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
// Kaskade: Events + Attendees + Bilder + Öffnungszeiten + Follows löschen
eventRepo.findByLocationIdOrderByStartAtAsc(locationId).forEach(e -> {
attendeeRepo.deleteByEventId(e.getEventId());
});
eventRepo.deleteByLocationId(locationId);
imageRepo.deleteByLocationId(locationId);
hoursRepo.deleteByLocationId(locationId);
followRepo.deleteByLocationId(locationId);
locationRepo.deleteById(locationId);
LOGGER.info("User {} hat Location {} gelöscht", myId, locationId);
return ResponseEntity.noContent().build();
}
// ── Galerie ───────────────────────────────────────────────────────────────
@PostMapping("/{locationId}/gallery")
public ResponseEntity<GalleryImageDto> uploadGalleryImage(
@PathVariable UUID locationId,
@RequestBody GalleryUploadRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var opt = locationRepo.findById(locationId);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
if (!opt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
if (req.imageData() == null || req.imageData().isBlank()) return ResponseEntity.badRequest().build();
if (imageRepo.countByLocationId(locationId) >= MAX_GALLERY_IMAGES) return ResponseEntity.status(422).build();
LocationImageEntity img = new LocationImageEntity();
img.setImageId(UUID.randomUUID());
img.setLocationId(locationId);
img.setImageData(req.imageData());
img.setUploadedAt(LocalDateTime.now());
imageRepo.save(img);
return ResponseEntity.status(201).body(new GalleryImageDto(img.getImageId(), img.getImageData()));
}
@Transactional
@DeleteMapping("/{locationId}/gallery/{imageId}")
public ResponseEntity<Void> deleteGalleryImage(
@PathVariable UUID locationId,
@PathVariable UUID imageId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!locOpt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
var imgOpt = imageRepo.findById(imageId);
if (imgOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!imgOpt.get().getLocationId().equals(locationId)) return ResponseEntity.status(400).build();
imageRepo.delete(imgOpt.get());
return ResponseEntity.noContent().build();
}
// ── Öffnungszeiten ────────────────────────────────────────────────────────
@GetMapping("/{locationId}/opening-hours")
public ResponseEntity<List<OpeningHourDto>> getOpeningHours(
@PathVariable UUID locationId,
Principal principal) {
userService.requireUser(principal);
if (!locationRepo.existsById(locationId)) return ResponseEntity.notFound().build();
List<OpeningHourDto> dtos = hoursRepo.findByLocationIdOrderByDayOfWeek(locationId).stream()
.map(h -> new OpeningHourDto(h.getDayOfWeek(), h.getOpenTime(), h.getCloseTime(), h.isClosed()))
.toList();
return ResponseEntity.ok(dtos);
}
/**
* Setzt die Öffnungszeiten einer Location vollständig neu (kompletter Ersatz).
*/
@Transactional
@PutMapping("/{locationId}/opening-hours")
public ResponseEntity<List<OpeningHourDto>> setOpeningHours(
@PathVariable UUID locationId,
@RequestBody List<OpeningHourDto> hours,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var opt = locationRepo.findById(locationId);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
if (!opt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
hoursRepo.deleteByLocationId(locationId);
List<LocationOpeningHoursEntity> saved = new ArrayList<>();
for (OpeningHourDto dto : hours) {
if (dto.dayOfWeek() < 1 || dto.dayOfWeek() > 7) continue;
LocationOpeningHoursEntity e = new LocationOpeningHoursEntity();
e.setHoursId(UUID.randomUUID());
e.setLocationId(locationId);
e.setDayOfWeek(dto.dayOfWeek());
e.setOpenTime(dto.openTime());
e.setCloseTime(dto.closeTime());
e.setClosed(dto.closed());
saved.add(hoursRepo.save(e));
}
return ResponseEntity.ok(saved.stream()
.map(h -> new OpeningHourDto(h.getDayOfWeek(), h.getOpenTime(), h.getCloseTime(), h.isClosed()))
.toList());
}
// ── Follow ────────────────────────────────────────────────────────────────
@PostMapping("/{locationId}/follow")
public ResponseEntity<Map<String, Object>> toggleFollow(
@PathVariable UUID locationId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
if (!locationRepo.existsById(locationId)) return ResponseEntity.notFound().build();
var existing = followRepo.findByUserIdAndLocationId(myId, locationId);
boolean following;
if (existing.isPresent()) {
followRepo.delete(existing.get());
following = false;
} else {
var f = new de.oaa.xxx.location.entity.LocationFollowEntity();
f.setFollowId(UUID.randomUUID());
f.setLocationId(locationId);
f.setUserId(myId);
f.setFollowedAt(LocalDateTime.now());
followRepo.save(f);
following = true;
}
return ResponseEntity.ok(Map.of("following", following));
}
@GetMapping("/followed")
public ResponseEntity<IdsResult> getFollowed(Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
List<UUID> ids = followRepo.findByUserId(myId).stream()
.map(de.oaa.xxx.location.entity.LocationFollowEntity::getLocationId)
.toList();
return ResponseEntity.ok(new IdsResult(ids, ids.size()));
}
// ── Helpers ───────────────────────────────────────────────────────────────
private LocationDetailDto toDetail(LocationEntity l, UUID myId) {
List<GalleryImageDto> gallery = imageRepo.findByLocationIdOrderByUploadedAtAsc(l.getLocationId()).stream()
.map(i -> new GalleryImageDto(i.getImageId(), i.getImageData()))
.toList();
List<OpeningHourDto> hours = hoursRepo.findByLocationIdOrderByDayOfWeek(l.getLocationId()).stream()
.map(h -> new OpeningHourDto(h.getDayOfWeek(), h.getOpenTime(), h.getCloseTime(), h.isClosed()))
.toList();
boolean following = followRepo.findByUserIdAndLocationId(myId, l.getLocationId()).isPresent();
return new LocationDetailDto(
l.getLocationId(), l.getOwnerId(), l.getName(), l.getDescription(),
l.getProfilePictureHq(), l.getProfilePictureLq(),
l.getLat(), l.getLon(), l.getStreet(), l.getCity(),
l.isOwnershipConfirmed(), l.getCreatedAt(),
gallery, hours, following);
}
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

@@ -0,0 +1,384 @@
package de.oaa.xxx.location;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.location.entity.LocationEventAttendeeEntity;
import de.oaa.xxx.location.entity.LocationEventEntity;
import de.oaa.xxx.location.entity.LocationFollowEntity;
import de.oaa.xxx.location.repository.LocationEventAttendeeRepository;
import de.oaa.xxx.location.repository.LocationEventRepository;
import de.oaa.xxx.location.repository.LocationFollowRepository;
import de.oaa.xxx.location.repository.LocationRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
import jakarta.transaction.Transactional;
@RestController
public class LocationEventController {
private static final Logger LOGGER = LoggerFactory.getLogger(LocationEventController.class);
private static final int MAX_BATCH_SIZE = 50;
private final LocationRepository locationRepo;
private final LocationEventRepository eventRepo;
private final LocationEventAttendeeRepository attendeeRepo;
private final LocationFollowRepository followRepo;
private final UserRepository userRepo;
private final UserService userService;
public LocationEventController(LocationRepository locationRepo,
LocationEventRepository eventRepo,
LocationEventAttendeeRepository attendeeRepo,
LocationFollowRepository followRepo,
UserRepository userRepo,
UserService userService) {
this.locationRepo = locationRepo;
this.eventRepo = eventRepo;
this.attendeeRepo = attendeeRepo;
this.followRepo = followRepo;
this.userRepo = userRepo;
this.userService = userService;
}
// ── DTOs ─────────────────────────────────────────────────────────────────
record IdsResult(List<UUID> ids, int total) {}
record EventPreviewDto(
UUID eventId,
UUID locationId,
String locationName,
String title,
String imageData,
LocalDateTime startAt,
double distanzKm,
long attendeeCount,
boolean attendingMe
) {}
record AttendeeDto(UUID userId, String name, String profilePictureLq, String geschlecht) {}
record EventDetailDto(
UUID eventId,
UUID locationId,
String locationName,
String title,
String description,
String imageData,
LocalDateTime startAt,
LocalDateTime createdAt,
boolean attendingMe,
List<AttendeeDto> attendees
) {}
record CreateEventRequest(String title, String description, String imageData, LocalDateTime startAt) {}
record UpdateEventRequest(String title, String description, String imageData, LocalDateTime startAt) {}
record BatchRequest(List<UUID> ids, Double lat, Double lon) {}
// ── Events einer Location ─────────────────────────────────────────────────
@GetMapping("/locations/{locationId}/events")
public ResponseEntity<List<EventPreviewDto>> getEventsForLocation(
@PathVariable UUID locationId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
if (!locationRepo.existsById(locationId)) return ResponseEntity.notFound().build();
var location = locationRepo.findById(locationId).orElseThrow();
double refLat = location.getLat() != null ? location.getLat() : 0;
double refLon = location.getLon() != null ? location.getLon() : 0;
List<EventPreviewDto> dtos = eventRepo.findByLocationIdOrderByStartAtAsc(locationId).stream()
.map(e -> toPreview(e, location.getName(), refLat, refLon, myId))
.toList();
return ResponseEntity.ok(dtos);
}
@PostMapping("/locations/{locationId}/events")
public ResponseEntity<EventDetailDto> createEvent(
@PathVariable UUID locationId,
@RequestBody CreateEventRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!locOpt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
if (req.title() == null || req.title().isBlank()) return ResponseEntity.badRequest().build();
if (req.startAt() == null) return ResponseEntity.badRequest().build();
LocationEventEntity event = new LocationEventEntity();
event.setEventId(UUID.randomUUID());
event.setLocationId(locationId);
event.setTitle(req.title().trim());
event.setDescription(req.description() != null ? req.description().trim() : null);
event.setImageData(req.imageData());
event.setStartAt(req.startAt());
event.setCreatedAt(LocalDateTime.now());
eventRepo.save(event);
LOGGER.info("Location {} hat Event {} angelegt", locationId, event.getEventId());
return ResponseEntity.status(201).body(toDetail(event, locOpt.get().getName(), myId));
}
@PutMapping("/locations/{locationId}/events/{eventId}")
public ResponseEntity<EventDetailDto> updateEvent(
@PathVariable UUID locationId,
@PathVariable UUID eventId,
@RequestBody UpdateEventRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!locOpt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
var evtOpt = eventRepo.findById(eventId);
if (evtOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!evtOpt.get().getLocationId().equals(locationId)) return ResponseEntity.status(400).build();
LocationEventEntity event = evtOpt.get();
if (req.title() != null && !req.title().isBlank()) event.setTitle(req.title().trim());
if (req.description() != null) event.setDescription(req.description().trim());
if (req.imageData() != null) event.setImageData(req.imageData());
if (req.startAt() != null) event.setStartAt(req.startAt());
eventRepo.save(event);
return ResponseEntity.ok(toDetail(event, locOpt.get().getName(), myId));
}
@Transactional
@DeleteMapping("/locations/{locationId}/events/{eventId}")
public ResponseEntity<Void> deleteEvent(
@PathVariable UUID locationId,
@PathVariable UUID eventId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!locOpt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
var evtOpt = eventRepo.findById(eventId);
if (evtOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!evtOpt.get().getLocationId().equals(locationId)) return ResponseEntity.status(400).build();
attendeeRepo.deleteByEventId(eventId);
eventRepo.delete(evtOpt.get());
return ResponseEntity.noContent().build();
}
// ── Event-Detail und Teilnahme ────────────────────────────────────────────
@GetMapping("/location-events/{eventId}")
public ResponseEntity<EventDetailDto> getEvent(
@PathVariable UUID eventId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var evtOpt = eventRepo.findById(eventId);
if (evtOpt.isEmpty()) return ResponseEntity.notFound().build();
String locationName = locationRepo.findById(evtOpt.get().getLocationId())
.map(l -> l.getName()).orElse("");
return ResponseEntity.ok(toDetail(evtOpt.get(), locationName, myId));
}
@PostMapping("/location-events/{eventId}/attend")
public ResponseEntity<Map<String, Object>> toggleAttend(
@PathVariable UUID eventId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
if (!eventRepo.existsById(eventId)) return ResponseEntity.notFound().build();
var existing = attendeeRepo.findByEventIdAndUserId(eventId, myId);
boolean attending;
if (existing.isPresent()) {
attendeeRepo.delete(existing.get());
attending = false;
} else {
LocationEventAttendeeEntity a = new LocationEventAttendeeEntity();
a.setAttendeeId(UUID.randomUUID());
a.setEventId(eventId);
a.setUserId(myId);
a.setRegisteredAt(LocalDateTime.now());
attendeeRepo.save(a);
attending = true;
}
long count = attendeeRepo.countByEventId(eventId);
return ResponseEntity.ok(Map.of("attending", attending, "attendeeCount", count));
}
// ── Event-Suche (IDs + Batch) ─────────────────────────────────────────────
/**
* Liefert Event-IDs sortiert nach Datum (nächste zuerst).
* Immer enthalten: Events von abonnierten Locations.
* Optional: Events im Umkreis von lat/lon (wenn angegeben).
*/
@GetMapping("/location-events/ids")
public ResponseEntity<IdsResult> searchIds(
@RequestParam(required = false) Double lat,
@RequestParam(required = false) Double lon,
@RequestParam(defaultValue = "50") int maxDistanceKm,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
LocalDateTime fromDt = from != null ? LocalDateTime.parse(from) : LocalDateTime.now();
LocalDateTime toDt = to != null ? LocalDateTime.parse(to) : fromDt.plusMonths(3);
// Abonnierte Locations deren Events werden immer eingeschlossen
Set<UUID> followedLocationIds = followRepo.findByUserId(myId).stream()
.map(LocationFollowEntity::getLocationId)
.collect(Collectors.toSet());
Map<UUID, double[]> locationCoords = new HashMap<>();
List<UUID> sorted = eventRepo.findInTimeRange(fromDt, toDt).stream()
.filter(e -> {
// Immer einschließen wenn Location abonniert
if (followedLocationIds.contains(e.getLocationId())) return true;
// Ohne Koordinaten kein Umkreis-Filter
if (lat == null || lon == null) return false;
double[] coords = locationCoords.computeIfAbsent(e.getLocationId(), locId ->
locationRepo.findById(locId)
.filter(l -> l.getLat() != null && l.getLon() != null)
.map(l -> new double[]{l.getLat(), l.getLon()})
.orElse(null));
if (coords == null) return false;
return LocationController.haversineKm(lat, lon, coords[0], coords[1]) <= maxDistanceKm;
})
.sorted(Comparator.comparing(LocationEventEntity::getStartAt))
.map(LocationEventEntity::getEventId)
.collect(Collectors.toList());
return ResponseEntity.ok(new IdsResult(sorted, sorted.size()));
}
/**
* Batch-Laden von Event-Previews.
*/
@PostMapping("/location-events/batch")
public ResponseEntity<List<EventPreviewDto>> getBatch(
@RequestBody BatchRequest request,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
if (request.ids() == null || request.ids().isEmpty()) return ResponseEntity.ok(List.of());
List<UUID> ids = request.ids().stream().filter(Objects::nonNull).limit(MAX_BATCH_SIZE).toList();
Map<UUID, LocationEventEntity> byId = eventRepo.findAllById(ids).stream()
.collect(Collectors.toMap(LocationEventEntity::getEventId, Function.identity()));
double refLat = request.lat() != null ? request.lat() : 0;
double refLon = request.lon() != null ? request.lon() : 0;
// Locations für Namens-Lookup und Distanzberechnung
Set<UUID> locationIds = byId.values().stream()
.map(LocationEventEntity::getLocationId).collect(Collectors.toSet());
Map<UUID, de.oaa.xxx.location.entity.LocationEntity> locationById =
locationRepo.findAllById(locationIds).stream()
.collect(Collectors.toMap(
de.oaa.xxx.location.entity.LocationEntity::getLocationId, Function.identity()));
List<EventPreviewDto> result = ids.stream()
.map(byId::get)
.filter(Objects::nonNull)
.map(e -> {
var loc = locationById.get(e.getLocationId());
String locName = loc != null ? loc.getName() : "";
double dist = (loc != null && loc.getLat() != null && loc.getLon() != null)
? Math.round(LocationController.haversineKm(refLat, refLon, loc.getLat(), loc.getLon()) * 10.0) / 10.0
: -1;
return toPreview(e, locName, refLat, refLon, myId);
})
.toList();
return ResponseEntity.ok(result);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private EventPreviewDto toPreview(LocationEventEntity e, String locationName,
double refLat, double refLon, UUID myId) {
var loc = locationRepo.findById(e.getLocationId()).orElse(null);
double dist = (loc != null && loc.getLat() != null && loc.getLon() != null)
? Math.round(LocationController.haversineKm(refLat, refLon, loc.getLat(), loc.getLon()) * 10.0) / 10.0
: -1;
long count = attendeeRepo.countByEventId(e.getEventId());
boolean mine = attendeeRepo.findByEventIdAndUserId(e.getEventId(), myId).isPresent();
return new EventPreviewDto(e.getEventId(), e.getLocationId(), locationName,
e.getTitle(), e.getImageData(), e.getStartAt(), dist, count, mine);
}
private EventDetailDto toDetail(LocationEventEntity e, String locationName, UUID myId) {
List<LocationEventAttendeeEntity> attendeeEntities =
attendeeRepo.findByEventIdOrderByRegisteredAtAsc(e.getEventId());
Set<UUID> attendeeUserIds = attendeeEntities.stream()
.map(LocationEventAttendeeEntity::getUserId).collect(Collectors.toSet());
Map<UUID, UserEntity> userById = attendeeUserIds.isEmpty()
? Map.of()
: userRepo.findAllById(attendeeUserIds).stream()
.collect(Collectors.toMap(UserEntity::getUserId, Function.identity()));
// Sortierung: Alphabetisch nach Geschlecht-Name, dann nach Registrierungszeit
List<AttendeeDto> attendees = attendeeEntities.stream()
.map(a -> {
UserEntity u = userById.get(a.getUserId());
if (u == null) return null;
String geschlecht = u.getGeschlecht() != null ? u.getGeschlecht().name() : "UNBEKANNT";
return new AttendeeDto(u.getUserId(), u.getName(), u.getProfilePicture(), geschlecht);
})
.filter(Objects::nonNull)
.sorted(Comparator
.comparing((AttendeeDto a) -> {
// Sortierreihenfolge: WEIBLICH → MAENNLICH → DIVERS → rest
return switch (a.geschlecht()) {
case "WEIBLICH" -> 0;
case "MAENNLICH" -> 1;
case "DIVERS" -> 2;
default -> 3;
};
})
.thenComparing(AttendeeDto::name))
.toList();
boolean attendingMe = attendeeRepo.findByEventIdAndUserId(e.getEventId(), myId).isPresent();
return new EventDetailDto(
e.getEventId(), e.getLocationId(), locationName,
e.getTitle(), e.getDescription(), e.getImageData(),
e.getStartAt(), e.getCreatedAt(),
attendingMe, attendees);
}
}

View File

@@ -0,0 +1,55 @@
package de.oaa.xxx.location.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "location")
public class LocationEntity {
@Id
@Column
private UUID locationId;
@Column(nullable = false)
private UUID ownerId;
@Column(nullable = false, length = 200)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
/** LQ-Profilbild (klein, für Listenansicht) base64 */
@Column(columnDefinition = "TEXT")
private String profilePictureLq;
/** HQ-Profilbild (max 1024×1024) base64 */
@Column(columnDefinition = "MEDIUMTEXT")
private String profilePictureHq;
@Column
private Double lat;
@Column
private Double lon;
@Column(length = 200)
private String street;
@Column(length = 200)
private String city;
/** Eigentümerschaft wurde vom Anleger bestätigt */
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
private boolean ownershipConfirmed = false;
@Column(nullable = false)
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,29 @@
package de.oaa.xxx.location.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "location_event_attendee",
uniqueConstraints = @UniqueConstraint(columnNames = {"event_id", "user_id"}))
public class LocationEventAttendeeEntity {
@Id
@Column
private UUID attendeeId;
@Column(nullable = false, name = "event_id")
private UUID eventId;
@Column(nullable = false, name = "user_id")
private UUID userId;
@Column(nullable = false)
private LocalDateTime registeredAt;
}

View File

@@ -0,0 +1,38 @@
package de.oaa.xxx.location.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "location_event")
public class LocationEventEntity {
@Id
@Column
private UUID eventId;
@Column(nullable = false)
private UUID locationId;
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
/** base64-Profilbild der Veranstaltung, max 1024px */
@Column(columnDefinition = "MEDIUMTEXT")
private String imageData;
@Column(nullable = false)
private LocalDateTime startAt;
@Column(nullable = false)
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,31 @@
package de.oaa.xxx.location.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "location_follow")
public class LocationFollowEntity {
@Id
@Column
private UUID followId;
@Column(nullable = false)
private UUID locationId;
@Column(nullable = false)
private UUID userId;
@Column(nullable = false)
private LocalDateTime followedAt;
}

View File

@@ -0,0 +1,29 @@
package de.oaa.xxx.location.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "location_image")
public class LocationImageEntity {
@Id
@Column
private UUID imageId;
@Column(nullable = false)
private UUID locationId;
/** base64-kodiertes Bild, max 1024px */
@Column(nullable = false, columnDefinition = "MEDIUMTEXT")
private String imageData;
@Column(nullable = false)
private LocalDateTime uploadedAt;
}

View File

@@ -0,0 +1,40 @@
package de.oaa.xxx.location.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "location_opening_hours",
uniqueConstraints = @UniqueConstraint(columnNames = {"location_id", "day_of_week"}))
public class LocationOpeningHoursEntity {
@Id
@Column
private UUID hoursId;
@Column(nullable = false, name = "location_id")
private UUID locationId;
/**
* Wochentag: 1 = Montag, 2 = Dienstag, ..., 7 = Sonntag
*/
@Column(nullable = false, name = "day_of_week")
private int dayOfWeek;
/** Öffnungszeit als "HH:mm"-String, null = kein Eintrag */
@Column(length = 5)
private String openTime;
/** Schließzeit als "HH:mm"-String, null = kein Eintrag */
@Column(length = 5)
private String closeTime;
/** true = an diesem Tag geschlossen */
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
private boolean closed = false;
}

View File

@@ -0,0 +1,21 @@
package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationEventAttendeeEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface LocationEventAttendeeRepository extends JpaRepository<LocationEventAttendeeEntity, UUID> {
List<LocationEventAttendeeEntity> findByEventIdOrderByRegisteredAtAsc(UUID eventId);
Optional<LocationEventAttendeeEntity> findByEventIdAndUserId(UUID eventId, UUID userId);
long countByEventId(UUID eventId);
void deleteByEventId(UUID eventId);
void deleteByUserId(UUID userId);
}

View File

@@ -0,0 +1,24 @@
package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationEventEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public interface LocationEventRepository extends JpaRepository<LocationEventEntity, UUID> {
List<LocationEventEntity> findByLocationIdOrderByStartAtAsc(UUID locationId);
/** Alle zukünftigen Events mit Koordinaten ihrer Location (für Umkreis-Suche) */
@Query("""
SELECT e FROM LocationEventEntity e
WHERE e.startAt >= :from
AND e.startAt <= :to
""")
List<LocationEventEntity> findInTimeRange(LocalDateTime from, LocalDateTime to);
void deleteByLocationId(UUID locationId);
}

View File

@@ -0,0 +1,14 @@
package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationFollowEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface LocationFollowRepository extends JpaRepository<LocationFollowEntity, UUID> {
Optional<LocationFollowEntity> findByUserIdAndLocationId(UUID userId, UUID locationId);
List<LocationFollowEntity> findByUserId(UUID userId);
void deleteByLocationId(UUID locationId);
}

View File

@@ -0,0 +1,16 @@
package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationImageEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface LocationImageRepository extends JpaRepository<LocationImageEntity, UUID> {
List<LocationImageEntity> findByLocationIdOrderByUploadedAtAsc(UUID locationId);
long countByLocationId(UUID locationId);
void deleteByLocationId(UUID locationId);
}

View File

@@ -0,0 +1,14 @@
package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationOpeningHoursEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface LocationOpeningHoursRepository extends JpaRepository<LocationOpeningHoursEntity, UUID> {
List<LocationOpeningHoursEntity> findByLocationIdOrderByDayOfWeek(UUID locationId);
void deleteByLocationId(UUID locationId);
}

View File

@@ -0,0 +1,15 @@
package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface LocationRepository extends JpaRepository<LocationEntity, UUID> {
List<LocationEntity> findByOwnerIdOrderByCreatedAtDesc(UUID ownerId);
/** Alle Locations mit gesetzten Koordinaten (für Umkreissuche) */
List<LocationEntity> findByLatIsNotNullAndLonIsNotNull();
}

View File

@@ -33,6 +33,10 @@ public class User {
private Integer datingMaxDistanzKm;
private Integer datingMinAlter;
private Integer datingMaxAlter;
private String filterCity;
private Double filterLat;
private Double filterLon;
private Integer filterMaxDistKm;
public Integer getAlter() {
return geburtsdatum != null ? Period.between(geburtsdatum, LocalDate.now()).getYears() : null;

View File

@@ -500,4 +500,17 @@ public class UserController {
return ResponseEntity.internalServerError().build();
}
}
record LocationFilterRequest(String filterCity, Double filterLat, Double filterLon, Integer filterMaxDistKm) {}
@PutMapping("/me/location-filter")
public ResponseEntity<Void> updateLocationFilter(@RequestBody LocationFilterRequest request, Principal principal) {
var user = userService.requireUser(principal);
if (request.filterCity() != null) user.setFilterCity(request.filterCity());
if (request.filterLat() != null) user.setFilterLat(request.filterLat());
if (request.filterLon() != null) user.setFilterLon(request.filterLon());
if (request.filterMaxDistKm() != null) user.setFilterMaxDistKm(Math.max(1, Math.min(500, request.filterMaxDistKm())));
userRepository.save(user);
return ResponseEntity.ok().build();
}
}

View File

@@ -134,6 +134,19 @@ public class UserEntity {
@Column
private Integer datingMaxAlter;
// ── Locations/Events-Filter ──
@Column(length = 200)
private String filterCity;
@Column
private Double filterLat;
@Column
private Double filterLon;
@Column
private Integer filterMaxDistKm;
public Integer getAlter() {
return geburtsdatum != null ? Period.between(geburtsdatum, LocalDate.now()).getYears() : null;
}
@@ -167,6 +180,10 @@ public class UserEntity {
user.setDatingMaxDistanzKm(datingMaxDistanzKm);
user.setDatingMinAlter(datingMinAlter);
user.setDatingMaxAlter(datingMaxAlter);
user.setFilterCity(filterCity);
user.setFilterLat(filterLat);
user.setFilterLon(filterLon);
user.setFilterMaxDistKm(filterMaxDistKm);
return user;
}
}