diff --git a/bin/main/Ideen.txt b/bin/main/Ideen.txt index 851ad46..12c52cd 100644 --- a/bin/main/Ideen.txt +++ b/bin/main/Ideen.txt @@ -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. - \ No newline at end of file + + + 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? diff --git a/bin/main/de/oaa/xxx/config/SecurityConfig.class b/bin/main/de/oaa/xxx/config/SecurityConfig.class index 6235a30..34ffe48 100644 Binary files a/bin/main/de/oaa/xxx/config/SecurityConfig.class and b/bin/main/de/oaa/xxx/config/SecurityConfig.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$BatchRequest.class b/bin/main/de/oaa/xxx/location/LocationController$BatchRequest.class new file mode 100644 index 0000000..2a18645 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationController$BatchRequest.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$CreateRequest.class b/bin/main/de/oaa/xxx/location/LocationController$CreateRequest.class new file mode 100644 index 0000000..c521098 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationController$CreateRequest.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$GalleryImageDto.class b/bin/main/de/oaa/xxx/location/LocationController$GalleryImageDto.class new file mode 100644 index 0000000..4e1ef8c Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationController$GalleryImageDto.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$GalleryUploadRequest.class b/bin/main/de/oaa/xxx/location/LocationController$GalleryUploadRequest.class new file mode 100644 index 0000000..af2f3b4 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationController$GalleryUploadRequest.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$IdsResult.class b/bin/main/de/oaa/xxx/location/LocationController$IdsResult.class new file mode 100644 index 0000000..7520eed Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationController$IdsResult.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$LocationDetailDto.class b/bin/main/de/oaa/xxx/location/LocationController$LocationDetailDto.class new file mode 100644 index 0000000..7343c5e Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationController$LocationDetailDto.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$LocationPreviewDto.class b/bin/main/de/oaa/xxx/location/LocationController$LocationPreviewDto.class new file mode 100644 index 0000000..c902301 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationController$LocationPreviewDto.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$OpeningHourDto.class b/bin/main/de/oaa/xxx/location/LocationController$OpeningHourDto.class new file mode 100644 index 0000000..ae79af5 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationController$OpeningHourDto.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$UpdateRequest.class b/bin/main/de/oaa/xxx/location/LocationController$UpdateRequest.class new file mode 100644 index 0000000..05556ad Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationController$UpdateRequest.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController.class b/bin/main/de/oaa/xxx/location/LocationController.class new file mode 100644 index 0000000..5d80846 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationController.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationEventController$AttendeeDto.class b/bin/main/de/oaa/xxx/location/LocationEventController$AttendeeDto.class new file mode 100644 index 0000000..f086d51 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationEventController$AttendeeDto.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationEventController$BatchRequest.class b/bin/main/de/oaa/xxx/location/LocationEventController$BatchRequest.class new file mode 100644 index 0000000..daaa187 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationEventController$BatchRequest.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationEventController$CreateEventRequest.class b/bin/main/de/oaa/xxx/location/LocationEventController$CreateEventRequest.class new file mode 100644 index 0000000..a1cea74 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationEventController$CreateEventRequest.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationEventController$EventDetailDto.class b/bin/main/de/oaa/xxx/location/LocationEventController$EventDetailDto.class new file mode 100644 index 0000000..62cb579 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationEventController$EventDetailDto.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationEventController$EventPreviewDto.class b/bin/main/de/oaa/xxx/location/LocationEventController$EventPreviewDto.class new file mode 100644 index 0000000..3364532 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationEventController$EventPreviewDto.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationEventController$IdsResult.class b/bin/main/de/oaa/xxx/location/LocationEventController$IdsResult.class new file mode 100644 index 0000000..9aed8db Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationEventController$IdsResult.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationEventController$UpdateEventRequest.class b/bin/main/de/oaa/xxx/location/LocationEventController$UpdateEventRequest.class new file mode 100644 index 0000000..d63f5a1 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationEventController$UpdateEventRequest.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationEventController.class b/bin/main/de/oaa/xxx/location/LocationEventController.class new file mode 100644 index 0000000..4beaf73 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationEventController.class differ diff --git a/bin/main/de/oaa/xxx/location/entity/LocationEntity.class b/bin/main/de/oaa/xxx/location/entity/LocationEntity.class new file mode 100644 index 0000000..d24031d Binary files /dev/null and b/bin/main/de/oaa/xxx/location/entity/LocationEntity.class differ diff --git a/bin/main/de/oaa/xxx/location/entity/LocationEventAttendeeEntity.class b/bin/main/de/oaa/xxx/location/entity/LocationEventAttendeeEntity.class new file mode 100644 index 0000000..9816eaa Binary files /dev/null and b/bin/main/de/oaa/xxx/location/entity/LocationEventAttendeeEntity.class differ diff --git a/bin/main/de/oaa/xxx/location/entity/LocationEventEntity.class b/bin/main/de/oaa/xxx/location/entity/LocationEventEntity.class new file mode 100644 index 0000000..fffd609 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/entity/LocationEventEntity.class differ diff --git a/bin/main/de/oaa/xxx/location/entity/LocationFollowEntity.class b/bin/main/de/oaa/xxx/location/entity/LocationFollowEntity.class new file mode 100644 index 0000000..ff04eb7 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/entity/LocationFollowEntity.class differ diff --git a/bin/main/de/oaa/xxx/location/entity/LocationImageEntity.class b/bin/main/de/oaa/xxx/location/entity/LocationImageEntity.class new file mode 100644 index 0000000..94a6766 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/entity/LocationImageEntity.class differ diff --git a/bin/main/de/oaa/xxx/location/entity/LocationOpeningHoursEntity.class b/bin/main/de/oaa/xxx/location/entity/LocationOpeningHoursEntity.class new file mode 100644 index 0000000..8f726ae Binary files /dev/null and b/bin/main/de/oaa/xxx/location/entity/LocationOpeningHoursEntity.class differ diff --git a/bin/main/de/oaa/xxx/location/repository/LocationEventAttendeeRepository.class b/bin/main/de/oaa/xxx/location/repository/LocationEventAttendeeRepository.class new file mode 100644 index 0000000..346c42d Binary files /dev/null and b/bin/main/de/oaa/xxx/location/repository/LocationEventAttendeeRepository.class differ diff --git a/bin/main/de/oaa/xxx/location/repository/LocationEventRepository.class b/bin/main/de/oaa/xxx/location/repository/LocationEventRepository.class new file mode 100644 index 0000000..10b185f Binary files /dev/null and b/bin/main/de/oaa/xxx/location/repository/LocationEventRepository.class differ diff --git a/bin/main/de/oaa/xxx/location/repository/LocationFollowRepository.class b/bin/main/de/oaa/xxx/location/repository/LocationFollowRepository.class new file mode 100644 index 0000000..7100bc2 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/repository/LocationFollowRepository.class differ diff --git a/bin/main/de/oaa/xxx/location/repository/LocationImageRepository.class b/bin/main/de/oaa/xxx/location/repository/LocationImageRepository.class new file mode 100644 index 0000000..58e8703 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/repository/LocationImageRepository.class differ diff --git a/bin/main/de/oaa/xxx/location/repository/LocationOpeningHoursRepository.class b/bin/main/de/oaa/xxx/location/repository/LocationOpeningHoursRepository.class new file mode 100644 index 0000000..a9675ef Binary files /dev/null and b/bin/main/de/oaa/xxx/location/repository/LocationOpeningHoursRepository.class differ diff --git a/bin/main/de/oaa/xxx/location/repository/LocationRepository.class b/bin/main/de/oaa/xxx/location/repository/LocationRepository.class new file mode 100644 index 0000000..f8942dd Binary files /dev/null and b/bin/main/de/oaa/xxx/location/repository/LocationRepository.class differ diff --git a/bin/main/de/oaa/xxx/user/User.class b/bin/main/de/oaa/xxx/user/User.class index bf5650f..53d9347 100644 Binary files a/bin/main/de/oaa/xxx/user/User.class and b/bin/main/de/oaa/xxx/user/User.class differ diff --git a/bin/main/de/oaa/xxx/user/UserController$LocationFilterRequest.class b/bin/main/de/oaa/xxx/user/UserController$LocationFilterRequest.class new file mode 100644 index 0000000..6eb084c Binary files /dev/null and b/bin/main/de/oaa/xxx/user/UserController$LocationFilterRequest.class differ diff --git a/bin/main/de/oaa/xxx/user/UserController.class b/bin/main/de/oaa/xxx/user/UserController.class index 50527a9..5a7ab60 100644 Binary files a/bin/main/de/oaa/xxx/user/UserController.class and b/bin/main/de/oaa/xxx/user/UserController.class differ diff --git a/bin/main/de/oaa/xxx/user/UserEntity.class b/bin/main/de/oaa/xxx/user/UserEntity.class index 723c568..cb36ec9 100644 Binary files a/bin/main/de/oaa/xxx/user/UserEntity.class and b/bin/main/de/oaa/xxx/user/UserEntity.class differ diff --git a/bin/main/static/community/event-detail.html b/bin/main/static/community/event-detail.html new file mode 100644 index 0000000..5ccece3 --- /dev/null +++ b/bin/main/static/community/event-detail.html @@ -0,0 +1,177 @@ + + + + + + + Veranstaltung – xXx Sphere + + + + + +
+ ← Veranstaltungen + +
+

Wird geladen…

+
+
+ + + + + + diff --git a/bin/main/static/community/events.html b/bin/main/static/community/events.html new file mode 100644 index 0000000..efe6967 --- /dev/null +++ b/bin/main/static/community/events.html @@ -0,0 +1,582 @@ + + + + + + + Veranstaltungen – xXx Sphere + + + + + + + + + + +
+
+
+

Filter

+ +
+
+
+ Veranstaltungen von abonnierten Locations werden immer angezeigt, unabhängig vom Umkreis. +
+ +
+ +
+ + +
+
+ +
+ + +
+
+ +
+ +
+
+
+ +
+ +
+
+ + +
+ +
+
+
+ + + + + + diff --git a/bin/main/static/community/location-detail.html b/bin/main/static/community/location-detail.html new file mode 100644 index 0000000..aa3ebf8 --- /dev/null +++ b/bin/main/static/community/location-detail.html @@ -0,0 +1,628 @@ + + + + + + + Location – xXx Sphere + + + + + +
+ ← Locations + +
+

Wird geladen…

+
+
+ + + + + + + + + + + + + + + diff --git a/bin/main/static/community/locations.html b/bin/main/static/community/locations.html new file mode 100644 index 0000000..4b2b6d8 --- /dev/null +++ b/bin/main/static/community/locations.html @@ -0,0 +1,647 @@ + + + + + + + Locations – xXx Sphere + + + + + + + + + + +
+
+
+

Filter

+ +
+
+
+ +
+ + +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ +
+
+ + + + + + + + + diff --git a/bin/main/static/js/sidebar.js b/bin/main/static/js/sidebar.js index 68d4b8b..fed0e99 100644 --- a/bin/main/static/js/sidebar.js +++ b/bin/main/static/js/sidebar.js @@ -52,6 +52,8 @@ { href: '/community/nachrichten.html', icon: I('MESSAGES'), label: 'Nachrichten', badgeId: null }, { href: '/community/benachrichtigungen.html', icon: I('NOTIFICATIONS'), label: 'Benachrichtigungen', badgeId: null }, { href: '/community/gruppen.html', icon: I('GROUPS'), label: 'Gruppen', badgeId: 'socialGruppenBadge'}, + { href: '/community/locations.html', icon: I('LOCATION') || '📍', label: 'Locations', badgeId: null }, + { href: '/community/events.html', icon: I('EVENT') || '🗓', label: 'Veranstaltungen', badgeId: null }, { href: '/games/common/einladungen.html', icon: I('INVITATIONS'), label: 'Einladungen', badgeId: null }, ]; const socialNav = socialLinks.map(({ href, icon, label, badgeId }) => { diff --git a/src/main/java/Ideen.txt b/src/main/java/Ideen.txt index 851ad46..12c52cd 100644 --- a/src/main/java/Ideen.txt +++ b/src/main/java/Ideen.txt @@ -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. - \ No newline at end of file + + + 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? diff --git a/src/main/java/de/oaa/xxx/config/SecurityConfig.java b/src/main/java/de/oaa/xxx/config/SecurityConfig.java index e73b6cf..5f45d91 100644 --- a/src/main/java/de/oaa/xxx/config/SecurityConfig.java +++ b/src/main/java/de/oaa/xxx/config/SecurityConfig.java @@ -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() diff --git a/src/main/java/de/oaa/xxx/location/LocationController.java b/src/main/java/de/oaa/xxx/location/LocationController.java new file mode 100644 index 0000000..ac861a2 --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/LocationController.java @@ -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 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 gallery, + List 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 getIds( + @RequestParam double lat, + @RequestParam double lon, + @RequestParam(defaultValue = "50") int maxDistanceKm, + Principal principal) { + + userService.requireUser(principal); + + List 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> getBatch( + @RequestBody BatchRequest request, + Principal principal) { + + userService.requireUser(principal); + if (request.ids() == null || request.ids().isEmpty()) return ResponseEntity.ok(List.of()); + + List ids = request.ids().stream().filter(Objects::nonNull).limit(MAX_BATCH_SIZE).toList(); + Map 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 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 ids, Double lat, Double lon) {} + + /** + * Meine eigenen Locations (IDs, neueste zuerst). + */ + @GetMapping("/mine") + public ResponseEntity getMine(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + List ids = locationRepo.findByOwnerIdOrderByCreatedAtDesc(myId).stream() + .map(LocationEntity::getLocationId) + .toList(); + return ResponseEntity.ok(new IdsResult(ids, ids.size())); + } + + // ── Detail ─────────────────────────────────────────────────────────────── + + @GetMapping("/{locationId}") + public ResponseEntity 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 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 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 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 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 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> getOpeningHours( + @PathVariable UUID locationId, + Principal principal) { + + userService.requireUser(principal); + if (!locationRepo.existsById(locationId)) return ResponseEntity.notFound().build(); + + List 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> setOpeningHours( + @PathVariable UUID locationId, + @RequestBody List 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 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> 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 getFollowed(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + List 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 gallery = imageRepo.findByLocationIdOrderByUploadedAtAsc(l.getLocationId()).stream() + .map(i -> new GalleryImageDto(i.getImageId(), i.getImageData())) + .toList(); + List 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)); + } +} diff --git a/src/main/java/de/oaa/xxx/location/LocationEventController.java b/src/main/java/de/oaa/xxx/location/LocationEventController.java new file mode 100644 index 0000000..0dadea7 --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/LocationEventController.java @@ -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 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 attendees + ) {} + + record CreateEventRequest(String title, String description, String imageData, LocalDateTime startAt) {} + + record UpdateEventRequest(String title, String description, String imageData, LocalDateTime startAt) {} + + record BatchRequest(List ids, Double lat, Double lon) {} + + // ── Events einer Location ───────────────────────────────────────────────── + + @GetMapping("/locations/{locationId}/events") + public ResponseEntity> 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 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 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 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 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 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> 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 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 followedLocationIds = followRepo.findByUserId(myId).stream() + .map(LocationFollowEntity::getLocationId) + .collect(Collectors.toSet()); + + Map locationCoords = new HashMap<>(); + List 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> 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 ids = request.ids().stream().filter(Objects::nonNull).limit(MAX_BATCH_SIZE).toList(); + Map 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 locationIds = byId.values().stream() + .map(LocationEventEntity::getLocationId).collect(Collectors.toSet()); + Map locationById = + locationRepo.findAllById(locationIds).stream() + .collect(Collectors.toMap( + de.oaa.xxx.location.entity.LocationEntity::getLocationId, Function.identity())); + + List 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 attendeeEntities = + attendeeRepo.findByEventIdOrderByRegisteredAtAsc(e.getEventId()); + + Set attendeeUserIds = attendeeEntities.stream() + .map(LocationEventAttendeeEntity::getUserId).collect(Collectors.toSet()); + Map 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 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); + } +} diff --git a/src/main/java/de/oaa/xxx/location/entity/LocationEntity.java b/src/main/java/de/oaa/xxx/location/entity/LocationEntity.java new file mode 100644 index 0000000..6bcaae4 --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/entity/LocationEntity.java @@ -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; +} diff --git a/src/main/java/de/oaa/xxx/location/entity/LocationEventAttendeeEntity.java b/src/main/java/de/oaa/xxx/location/entity/LocationEventAttendeeEntity.java new file mode 100644 index 0000000..e9bc234 --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/entity/LocationEventAttendeeEntity.java @@ -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; +} diff --git a/src/main/java/de/oaa/xxx/location/entity/LocationEventEntity.java b/src/main/java/de/oaa/xxx/location/entity/LocationEventEntity.java new file mode 100644 index 0000000..79545ee --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/entity/LocationEventEntity.java @@ -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; +} diff --git a/src/main/java/de/oaa/xxx/location/entity/LocationFollowEntity.java b/src/main/java/de/oaa/xxx/location/entity/LocationFollowEntity.java new file mode 100644 index 0000000..55d9ada --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/entity/LocationFollowEntity.java @@ -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; +} diff --git a/src/main/java/de/oaa/xxx/location/entity/LocationImageEntity.java b/src/main/java/de/oaa/xxx/location/entity/LocationImageEntity.java new file mode 100644 index 0000000..14ca89e --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/entity/LocationImageEntity.java @@ -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; +} diff --git a/src/main/java/de/oaa/xxx/location/entity/LocationOpeningHoursEntity.java b/src/main/java/de/oaa/xxx/location/entity/LocationOpeningHoursEntity.java new file mode 100644 index 0000000..bf6f237 --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/entity/LocationOpeningHoursEntity.java @@ -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; +} diff --git a/src/main/java/de/oaa/xxx/location/repository/LocationEventAttendeeRepository.java b/src/main/java/de/oaa/xxx/location/repository/LocationEventAttendeeRepository.java new file mode 100644 index 0000000..c559fdf --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/repository/LocationEventAttendeeRepository.java @@ -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 { + + List findByEventIdOrderByRegisteredAtAsc(UUID eventId); + + Optional findByEventIdAndUserId(UUID eventId, UUID userId); + + long countByEventId(UUID eventId); + + void deleteByEventId(UUID eventId); + + void deleteByUserId(UUID userId); +} diff --git a/src/main/java/de/oaa/xxx/location/repository/LocationEventRepository.java b/src/main/java/de/oaa/xxx/location/repository/LocationEventRepository.java new file mode 100644 index 0000000..c0e4beb --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/repository/LocationEventRepository.java @@ -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 { + + List 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 findInTimeRange(LocalDateTime from, LocalDateTime to); + + void deleteByLocationId(UUID locationId); +} diff --git a/src/main/java/de/oaa/xxx/location/repository/LocationFollowRepository.java b/src/main/java/de/oaa/xxx/location/repository/LocationFollowRepository.java new file mode 100644 index 0000000..a2f0c54 --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/repository/LocationFollowRepository.java @@ -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 { + Optional findByUserIdAndLocationId(UUID userId, UUID locationId); + List findByUserId(UUID userId); + void deleteByLocationId(UUID locationId); +} diff --git a/src/main/java/de/oaa/xxx/location/repository/LocationImageRepository.java b/src/main/java/de/oaa/xxx/location/repository/LocationImageRepository.java new file mode 100644 index 0000000..45800e5 --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/repository/LocationImageRepository.java @@ -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 { + + List findByLocationIdOrderByUploadedAtAsc(UUID locationId); + + long countByLocationId(UUID locationId); + + void deleteByLocationId(UUID locationId); +} diff --git a/src/main/java/de/oaa/xxx/location/repository/LocationOpeningHoursRepository.java b/src/main/java/de/oaa/xxx/location/repository/LocationOpeningHoursRepository.java new file mode 100644 index 0000000..303feb1 --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/repository/LocationOpeningHoursRepository.java @@ -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 { + + List findByLocationIdOrderByDayOfWeek(UUID locationId); + + void deleteByLocationId(UUID locationId); +} diff --git a/src/main/java/de/oaa/xxx/location/repository/LocationRepository.java b/src/main/java/de/oaa/xxx/location/repository/LocationRepository.java new file mode 100644 index 0000000..a22b9f4 --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/repository/LocationRepository.java @@ -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 { + + List findByOwnerIdOrderByCreatedAtDesc(UUID ownerId); + + /** Alle Locations mit gesetzten Koordinaten (für Umkreissuche) */ + List findByLatIsNotNullAndLonIsNotNull(); +} diff --git a/src/main/java/de/oaa/xxx/user/User.java b/src/main/java/de/oaa/xxx/user/User.java index 823af36..e03dccf 100644 --- a/src/main/java/de/oaa/xxx/user/User.java +++ b/src/main/java/de/oaa/xxx/user/User.java @@ -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; diff --git a/src/main/java/de/oaa/xxx/user/UserController.java b/src/main/java/de/oaa/xxx/user/UserController.java index 87d96e7..153e221 100644 --- a/src/main/java/de/oaa/xxx/user/UserController.java +++ b/src/main/java/de/oaa/xxx/user/UserController.java @@ -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 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(); + } } diff --git a/src/main/java/de/oaa/xxx/user/UserEntity.java b/src/main/java/de/oaa/xxx/user/UserEntity.java index 9f60d2b..6dea4c9 100644 --- a/src/main/java/de/oaa/xxx/user/UserEntity.java +++ b/src/main/java/de/oaa/xxx/user/UserEntity.java @@ -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; } } diff --git a/src/main/resources/static/community/event-detail.html b/src/main/resources/static/community/event-detail.html new file mode 100644 index 0000000..5ccece3 --- /dev/null +++ b/src/main/resources/static/community/event-detail.html @@ -0,0 +1,177 @@ + + + + + + + Veranstaltung – xXx Sphere + + + + + +
+ ← Veranstaltungen + +
+

Wird geladen…

+
+
+ + + + + + diff --git a/src/main/resources/static/community/events.html b/src/main/resources/static/community/events.html new file mode 100644 index 0000000..efe6967 --- /dev/null +++ b/src/main/resources/static/community/events.html @@ -0,0 +1,582 @@ + + + + + + + Veranstaltungen – xXx Sphere + + + + + + + + + + +
+
+
+

Filter

+ +
+
+
+ Veranstaltungen von abonnierten Locations werden immer angezeigt, unabhängig vom Umkreis. +
+ +
+ +
+ + +
+
+ +
+ + +
+
+ +
+ +
+
+
+ +
+ +
+
+ + +
+ +
+
+
+ + + + + + diff --git a/src/main/resources/static/community/location-detail.html b/src/main/resources/static/community/location-detail.html new file mode 100644 index 0000000..aa3ebf8 --- /dev/null +++ b/src/main/resources/static/community/location-detail.html @@ -0,0 +1,628 @@ + + + + + + + Location – xXx Sphere + + + + + +
+ ← Locations + +
+

Wird geladen…

+
+
+ + + + + + + + + + + + + + + diff --git a/src/main/resources/static/community/locations.html b/src/main/resources/static/community/locations.html new file mode 100644 index 0000000..4b2b6d8 --- /dev/null +++ b/src/main/resources/static/community/locations.html @@ -0,0 +1,647 @@ + + + + + + + Locations – xXx Sphere + + + + + + + + + + +
+
+
+

Filter

+ +
+
+
+ +
+ + +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ +
+
+ + + + + + + + + diff --git a/src/main/resources/static/js/sidebar.js b/src/main/resources/static/js/sidebar.js index 68d4b8b..fed0e99 100644 --- a/src/main/resources/static/js/sidebar.js +++ b/src/main/resources/static/js/sidebar.js @@ -52,6 +52,8 @@ { href: '/community/nachrichten.html', icon: I('MESSAGES'), label: 'Nachrichten', badgeId: null }, { href: '/community/benachrichtigungen.html', icon: I('NOTIFICATIONS'), label: 'Benachrichtigungen', badgeId: null }, { href: '/community/gruppen.html', icon: I('GROUPS'), label: 'Gruppen', badgeId: 'socialGruppenBadge'}, + { href: '/community/locations.html', icon: I('LOCATION') || '📍', label: 'Locations', badgeId: null }, + { href: '/community/events.html', icon: I('EVENT') || '🗓', label: 'Veranstaltungen', badgeId: null }, { href: '/games/common/einladungen.html', icon: I('INVITATIONS'), label: 'Einladungen', badgeId: null }, ]; const socialNav = socialLinks.map(({ href, icon, label, badgeId }) => {