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

@@ -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();
}