Weiter an den 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-06 22:48:34 +02:00
parent 0f9f109067
commit 5ffb99c9b5
81 changed files with 2817 additions and 352 deletions

View File

@@ -0,0 +1,176 @@
package de.oaa.xxx.location;
import de.oaa.xxx.location.entity.LocationAdminEntity;
import de.oaa.xxx.location.repository.LocationAdminRepository;
import de.oaa.xxx.location.repository.LocationRepository;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
import jakarta.transaction.Transactional;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/locations/{locationId}/admins")
public class LocationAdminController {
record AdminDto(UUID userId, String name, String profilePicture, boolean isOwner) {}
record AddAdminRequest(UUID userId) {}
record TransferOwnerRequest(UUID userId) {}
private final LocationRepository locationRepo;
private final LocationAdminRepository adminRepo;
private final UserRepository userRepo;
private final UserService userService;
public LocationAdminController(LocationRepository locationRepo,
LocationAdminRepository adminRepo,
UserRepository userRepo,
UserService userService) {
this.locationRepo = locationRepo;
this.adminRepo = adminRepo;
this.userRepo = userRepo;
this.userService = userService;
}
// ── Hilfsmethoden ────────────────────────────────────────────────────────
private boolean isAdmin(UUID locationId, UUID userId, de.oaa.xxx.location.entity.LocationEntity loc) {
return loc.getOwnerId().equals(userId)
|| adminRepo.existsByLocationIdAndUserId(locationId, userId);
}
private AdminDto toDto(UUID userId, de.oaa.xxx.location.entity.LocationEntity loc) {
return userRepo.findById(userId).map(u -> new AdminDto(
u.getUserId(), u.getName(), u.getProfilePicture(),
u.getUserId().equals(loc.getOwnerId())))
.orElse(null);
}
// ── Admins auflisten ─────────────────────────────────────────────────────
@GetMapping
public ResponseEntity<List<AdminDto>> list(
@PathVariable UUID locationId,
Principal principal) {
userService.requireUser(principal);
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
var loc = locOpt.get();
// Inhaber ist immer erster Eintrag
List<AdminDto> admins = new java.util.ArrayList<>();
userRepo.findById(loc.getOwnerId()).ifPresent(owner ->
admins.add(new AdminDto(owner.getUserId(), owner.getName(), owner.getProfilePicture(), true)));
adminRepo.findByLocationId(locationId).stream()
.filter(a -> !a.getUserId().equals(loc.getOwnerId())) // Inhaber nicht doppelt
.map(a -> toDto(a.getUserId(), loc))
.filter(java.util.Objects::nonNull)
.forEach(admins::add);
return ResponseEntity.ok(admins);
}
// ── Admin hinzufügen ──────────────────────────────────────────────────────
@PostMapping
public ResponseEntity<AdminDto> add(
@PathVariable UUID locationId,
@RequestBody AddAdminRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
var loc = locOpt.get();
if (!isAdmin(locationId, myId, loc)) return ResponseEntity.status(403).build();
if (req.userId() == null || !userRepo.existsById(req.userId()))
return ResponseEntity.badRequest().build();
// Inhaber muss nicht eingetragen werden
if (req.userId().equals(loc.getOwnerId()))
return ResponseEntity.badRequest().build();
// Bereits Admin?
if (adminRepo.existsByLocationIdAndUserId(locationId, req.userId()))
return ResponseEntity.status(409).build();
LocationAdminEntity entity = new LocationAdminEntity();
entity.setAdminId(UUID.randomUUID());
entity.setLocationId(locationId);
entity.setUserId(req.userId());
entity.setAddedAt(LocalDateTime.now());
adminRepo.save(entity);
return ResponseEntity.status(201).body(toDto(req.userId(), loc));
}
// ── Admin entfernen ───────────────────────────────────────────────────────
@Transactional
@DeleteMapping("/{userId}")
public ResponseEntity<Void> remove(
@PathVariable UUID locationId,
@PathVariable UUID userId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
var loc = locOpt.get();
if (!isAdmin(locationId, myId, loc)) return ResponseEntity.status(403).build();
// Inhaber darf nicht entfernt werden
if (userId.equals(loc.getOwnerId())) return ResponseEntity.status(403).build();
adminRepo.deleteByLocationIdAndUserId(locationId, userId);
return ResponseEntity.noContent().build();
}
// ── Inhaberwechsel ────────────────────────────────────────────────────────
@Transactional
@PutMapping("/transfer-owner")
public ResponseEntity<Map<String, Object>> transferOwner(
@PathVariable UUID locationId,
@RequestBody TransferOwnerRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
var loc = locOpt.get();
// Nur der aktuelle Inhaber darf übertragen
if (!loc.getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
if (req.userId() == null || !userRepo.existsById(req.userId()))
return ResponseEntity.badRequest().build();
if (req.userId().equals(myId)) return ResponseEntity.badRequest().build();
// Neuer Inhaber als Admin eintragen (falls noch nicht), alter Inhaber wird normaler Admin
if (!adminRepo.existsByLocationIdAndUserId(locationId, myId)) {
LocationAdminEntity a = new LocationAdminEntity();
a.setAdminId(UUID.randomUUID());
a.setLocationId(locationId);
a.setUserId(myId);
a.setAddedAt(LocalDateTime.now());
adminRepo.save(a);
}
// Neuen Inhaber aus Admin-Liste entfernen (er ist jetzt Owner)
adminRepo.deleteByLocationIdAndUserId(locationId, req.userId());
loc.setOwnerId(req.userId());
locationRepo.save(loc);
return ResponseEntity.ok(Map.of("newOwnerId", req.userId()));
}
}

View File

@@ -0,0 +1,73 @@
package de.oaa.xxx.location;
import de.oaa.xxx.location.entity.LocationEntity;
import de.oaa.xxx.location.repository.LocationInboxLockRepository;
import de.oaa.xxx.location.repository.LocationRepository;
import de.oaa.xxx.social.entity.MessageEntity;
import de.oaa.xxx.social.repository.MessageRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
@Service
public class LocationChatCleanupService {
private static final Logger LOGGER = LoggerFactory.getLogger(LocationChatCleanupService.class);
private final LocationRepository locationRepo;
private final MessageRepository messageRepo;
private final LocationInboxLockRepository lockRepo;
public LocationChatCleanupService(LocationRepository locationRepo,
MessageRepository messageRepo,
LocationInboxLockRepository lockRepo) {
this.locationRepo = locationRepo;
this.messageRepo = messageRepo;
this.lockRepo = lockRepo;
}
/** Täglich um 03:00 Uhr: Location-Chats löschen, die seit mehr als einem Monat inaktiv sind. */
@Scheduled(cron = "0 0 3 * * *")
@Transactional
public void cleanupInactiveChats() {
LocalDateTime cutoff = LocalDateTime.now().minusMonths(1);
List<LocationEntity> locations = locationRepo.findByVirtualUserIdIsNotNull();
int deleted = 0;
for (LocationEntity loc : locations) {
UUID virtualId = loc.getVirtualUserId();
// Alle Nachrichten dieser Location (in beide Richtungen)
List<MessageEntity> allMessages = messageRepo.findAllByUser(virtualId);
if (allMessages.isEmpty()) continue;
// Neueste Nachricht pro Gesprächspartner (Besucher)
Map<UUID, LocalDateTime> latestByVisitor = new HashMap<>();
for (MessageEntity m : allMessages) {
UUID visitor = m.getSenderId().equals(virtualId) ? m.getReceiverId() : m.getSenderId();
latestByVisitor.merge(visitor, m.getSentAt(),
(a, b) -> a.isAfter(b) ? a : b);
}
for (Map.Entry<UUID, LocalDateTime> entry : latestByVisitor.entrySet()) {
if (entry.getValue().isBefore(cutoff)) {
UUID visitorId = entry.getKey();
messageRepo.deleteConversation(virtualId, visitorId);
lockRepo.findByLocationIdAndVisitorId(loc.getLocationId(), visitorId)
.ifPresent(lockRepo::delete);
deleted++;
LOGGER.info("Inaktiver Location-Chat gelöscht: location={} visitor={}", loc.getLocationId(), visitorId);
}
}
}
if (deleted > 0) {
LOGGER.info("Location-Chat-Cleanup abgeschlossen: {} Konversation(en) gelöscht.", deleted);
}
}
}

View File

@@ -1,7 +1,13 @@
package de.oaa.xxx.location;
import de.oaa.xxx.location.entity.*;
import de.oaa.xxx.location.entity.LocationInboxLockEntity;
import de.oaa.xxx.location.repository.*;
import de.oaa.xxx.location.repository.LocationInboxLockRepository;
import de.oaa.xxx.social.SseService;
import de.oaa.xxx.social.entity.MessageEntity;
import de.oaa.xxx.social.repository.MessageRepository;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
import jakarta.transaction.Transactional;
import org.slf4j.Logger;
@@ -12,6 +18,7 @@ import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.*;
import java.util.Collections;
import java.util.stream.Collectors;
@RestController
@@ -22,13 +29,20 @@ public class LocationController {
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 static final int LOCK_TIMEOUT_MINUTES = 60;
private final LocationRepository locationRepo;
private final LocationImageRepository imageRepo;
private final LocationOpeningHoursRepository hoursRepo;
private final LocationEventRepository eventRepo;
private final LocationEventRepository eventRepo;
private final LocationEventAttendeeRepository attendeeRepo;
private final LocationFollowRepository followRepo;
private final UserService userService;
private final LocationFollowRepository followRepo;
private final LocationAdminRepository adminRepo;
private final UserRepository userRepo;
private final UserService userService;
private final MessageRepository messageRepo;
private final SseService sseService;
private final LocationInboxLockRepository lockRepo;
public LocationController(LocationRepository locationRepo,
LocationImageRepository imageRepo,
@@ -36,26 +50,38 @@ public class LocationController {
LocationEventRepository eventRepo,
LocationEventAttendeeRepository attendeeRepo,
LocationFollowRepository followRepo,
UserService userService) {
LocationAdminRepository adminRepo,
UserRepository userRepo,
UserService userService,
MessageRepository messageRepo,
SseService sseService,
LocationInboxLockRepository lockRepo) {
this.locationRepo = locationRepo;
this.imageRepo = imageRepo;
this.hoursRepo = hoursRepo;
this.eventRepo = eventRepo;
this.attendeeRepo = attendeeRepo;
this.followRepo = followRepo;
this.adminRepo = adminRepo;
this.userRepo = userRepo;
this.userService = userService;
this.messageRepo = messageRepo;
this.sseService = sseService;
this.lockRepo = lockRepo;
}
// ── DTOs ─────────────────────────────────────────────────────────────────
record IdsResult(List<UUID> ids, int total) {}
record LocationPreviewDto(UUID locationId, String name, String profilePictureLq, double distanzKm) {}
record LocationPreviewDto(UUID locationId, String name, String profilePictureHq, double distanzKm) {}
record OpeningHourDto(int dayOfWeek, String openTime, String closeTime, boolean closed) {}
record GalleryImageDto(UUID imageId, String imageData) {}
record AdminDto(UUID userId, String name, String profilePicture, boolean isOwner) {}
record LocationDetailDto(
UUID locationId,
UUID ownerId,
@@ -71,7 +97,10 @@ public class LocationController {
LocalDateTime createdAt,
List<GalleryImageDto> gallery,
List<OpeningHourDto> openingHours,
boolean following
boolean following,
List<AdminDto> admins,
boolean isAdmin,
UUID virtualUserId
) {}
record CreateRequest(
@@ -99,6 +128,21 @@ public class LocationController {
record GalleryUploadRequest(String imageData) {}
record LocationVirtualInfoDto(UUID locationId, UUID virtualUserId, String name,
String profilePictureLq, String profilePictureHq) {}
/** Löst eine virtuelle Benutzer-ID zur Location-Info auf (für Nachrichtenfenster) */
@GetMapping("/virtual/{virtualUserId}")
public ResponseEntity<LocationVirtualInfoDto> getByVirtualUserId(
@PathVariable UUID virtualUserId, Principal principal) {
userService.requireUser(principal);
return locationRepo.findByVirtualUserId(virtualUserId)
.map(l -> ResponseEntity.ok(new LocationVirtualInfoDto(
l.getLocationId(), l.getVirtualUserId(), l.getName(),
l.getProfilePictureLq(), l.getProfilePictureHq())))
.orElse(ResponseEntity.notFound().build());
}
// ── Suche / IDs ──────────────────────────────────────────────────────────
/**
@@ -147,7 +191,7 @@ public class LocationController {
.map(l -> new LocationPreviewDto(
l.getLocationId(),
l.getName(),
l.getProfilePictureLq(),
l.getProfilePictureHq(),
l.getLat() != null && l.getLon() != null
? Math.round(haversineKm(refLat, refLon, l.getLat(), l.getLon()) * 10.0) / 10.0
: -1))
@@ -207,6 +251,7 @@ public class LocationController {
loc.setCity(req.city());
loc.setOwnershipConfirmed(req.ownershipConfirmed());
loc.setCreatedAt(LocalDateTime.now());
loc.setVirtualUserId(UUID.randomUUID());
locationRepo.save(loc);
LOGGER.info("User {} hat Location {} angelegt", myId, loc.getLocationId());
@@ -257,6 +302,7 @@ public class LocationController {
imageRepo.deleteByLocationId(locationId);
hoursRepo.deleteByLocationId(locationId);
followRepo.deleteByLocationId(locationId);
adminRepo.deleteByLocationId(locationId);
locationRepo.deleteById(locationId);
LOGGER.info("User {} hat Location {} gelöscht", myId, locationId);
@@ -398,6 +444,12 @@ public class LocationController {
// ── Helpers ───────────────────────────────────────────────────────────────
private LocationDetailDto toDetail(LocationEntity l, UUID myId) {
// Lazy-Init virtualUserId für Bestandsdaten ohne virtuelle ID
if (l.getVirtualUserId() == null) {
l.setVirtualUserId(UUID.randomUUID());
locationRepo.save(l);
}
List<GalleryImageDto> gallery = imageRepo.findByLocationIdOrderByUploadedAtAsc(l.getLocationId()).stream()
.map(i -> new GalleryImageDto(i.getImageId(), i.getImageData()))
.toList();
@@ -405,12 +457,241 @@ public class LocationController {
.map(h -> new OpeningHourDto(h.getDayOfWeek(), h.getOpenTime(), h.getCloseTime(), h.isClosed()))
.toList();
boolean following = followRepo.findByUserIdAndLocationId(myId, l.getLocationId()).isPresent();
boolean isAdmin = l.getOwnerId().equals(myId)
|| adminRepo.existsByLocationIdAndUserId(l.getLocationId(), myId);
// Inhaber zuerst, dann weitere Admins
List<AdminDto> admins = new ArrayList<>();
userRepo.findById(l.getOwnerId()).ifPresent(owner ->
admins.add(new AdminDto(owner.getUserId(), owner.getName(), owner.getProfilePicture(), true)));
adminRepo.findByLocationId(l.getLocationId()).stream()
.filter(a -> !a.getUserId().equals(l.getOwnerId()))
.forEach(a -> userRepo.findById(a.getUserId()).ifPresent(u ->
admins.add(new AdminDto(u.getUserId(), u.getName(), u.getProfilePicture(), false))));
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);
gallery, hours, following, admins, isAdmin, l.getVirtualUserId());
}
// ── Location-Posteingang (Admin) ─────────────────────────────────────────────
record InboxSummaryDto(UUID senderId, String senderName, String senderPicture,
String lastMessage, LocalDateTime sentAt, long unreadCount) {}
record InboxConversationDto(
List<de.oaa.xxx.social.dto.MessageDto> messages,
boolean canReply,
boolean lockedByMe,
String lockedByName // null wenn frei oder von mir gesperrt
) {}
record ReplyRequest(String text) {}
/** Alle Konversationen, die Besucher mit dieser Location geführt haben */
@GetMapping("/{locationId}/inbox")
public ResponseEntity<List<InboxSummaryDto>> getInbox(
@PathVariable UUID locationId, Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
LocationEntity loc = locOpt.get();
if (!loc.getOwnerId().equals(myId) && !adminRepo.existsByLocationIdAndUserId(locationId, myId)) {
return ResponseEntity.status(403).build();
}
UUID virtualId = loc.getVirtualUserId();
if (virtualId == null) return ResponseEntity.ok(List.of());
List<MessageEntity> allMessages = messageRepo.findAllByUser(virtualId);
// Neueste Nachricht pro Gesprächspartner (Besucher)
Map<UUID, MessageEntity> latestByPartner = new LinkedHashMap<>();
for (MessageEntity m : allMessages) {
UUID partnerId = m.getSenderId().equals(virtualId) ? m.getReceiverId() : m.getSenderId();
latestByPartner.putIfAbsent(partnerId, m);
}
List<InboxSummaryDto> summaries = new ArrayList<>();
for (Map.Entry<UUID, MessageEntity> entry : latestByPartner.entrySet()) {
UUID partnerId = entry.getKey();
MessageEntity lastMsg = entry.getValue();
var userOpt = userRepo.findById(partnerId);
if (userOpt.isEmpty()) continue;
var user = userOpt.get();
long unread = allMessages.stream()
.filter(m -> m.getSenderId().equals(partnerId)
&& m.getReceiverId().equals(virtualId)
&& m.getReadAt() == null)
.count();
String preview = lastMsg.getText().startsWith("data:image/")
? "📷 Bild"
: lastMsg.getText().substring(0, Math.min(80, lastMsg.getText().length()));
summaries.add(new InboxSummaryDto(partnerId, user.getName(), user.getProfilePicture(),
preview, lastMsg.getSentAt(), unread));
}
return ResponseEntity.ok(summaries);
}
/** Konversation zwischen Location und einem Besucher inkl. Lock-Status (Admin-Sicht) */
@GetMapping("/{locationId}/inbox/{userId}")
public ResponseEntity<InboxConversationDto> getInboxConversation(
@PathVariable UUID locationId,
@PathVariable UUID userId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
LocationEntity loc = locOpt.get();
if (!loc.getOwnerId().equals(myId) && !adminRepo.existsByLocationIdAndUserId(locationId, myId)) {
return ResponseEntity.status(403).build();
}
UUID virtualId = loc.getVirtualUserId();
if (virtualId == null) return ResponseEntity.ok(new InboxConversationDto(List.of(), true, false, null));
List<MessageEntity> messages = new ArrayList<>(
messageRepo.findConversation(virtualId, userId, org.springframework.data.domain.PageRequest.of(0, 100)));
Collections.reverse(messages);
messageRepo.markAsRead(virtualId, userId, LocalDateTime.now());
List<de.oaa.xxx.social.dto.MessageDto> dtos = messages.stream()
.map(m -> {
String senderName = userRepo.findById(m.getSenderId())
.map(u -> u.getName())
.orElse(loc.getName());
return new de.oaa.xxx.social.dto.MessageDto(
m.getMessageId(), m.getSenderId(), senderName,
m.getReceiverId(), m.getText(), m.getSentAt(), m.getReadAt() != null);
})
.toList();
// Lock-Status ermitteln
var lockInfo = resolveLockStatus(locationId, userId, myId);
return ResponseEntity.ok(new InboxConversationDto(dtos, lockInfo[0].equals("true"), lockInfo[1].equals("true"), lockInfo[2]));
}
/** Sperre für eine Konversation anfordern (Admin beginnt zu antworten) */
@PostMapping("/{locationId}/inbox/{userId}/lock")
public ResponseEntity<Map<String, Object>> acquireLock(
@PathVariable UUID locationId,
@PathVariable UUID userId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
LocationEntity loc = locOpt.get();
if (!loc.getOwnerId().equals(myId) && !adminRepo.existsByLocationIdAndUserId(locationId, myId)) {
return ResponseEntity.status(403).build();
}
var existingOpt = lockRepo.findByLocationIdAndVisitorId(locationId, userId);
LocalDateTime now = LocalDateTime.now();
if (existingOpt.isPresent()) {
LocationInboxLockEntity lock = existingOpt.get();
boolean expired = lock.getLockedAt().isBefore(now.minusMinutes(LOCK_TIMEOUT_MINUTES));
if (!expired && !lock.getLockedByUserId().equals(myId)) {
// Aktiv gesperrt durch einen anderen Admin
String lockerName = userRepo.findById(lock.getLockedByUserId())
.map(u -> u.getName()).orElse("einem anderen Admin");
return ResponseEntity.status(409).body(Map.of("lockedByName", lockerName));
}
// Abgelaufen oder bereits meine Sperre → erneuern
lock.setLockedByUserId(myId);
lock.setLockedAt(now);
lockRepo.save(lock);
} else {
LocationInboxLockEntity lock = new LocationInboxLockEntity();
lock.setLockId(UUID.randomUUID());
lock.setLocationId(locationId);
lock.setVisitorId(userId);
lock.setLockedByUserId(myId);
lock.setLockedAt(now);
lockRepo.save(lock);
}
return ResponseEntity.ok(Map.of("acquired", true));
}
/** Antwort als Location an einen Besucher senden (mit Lock-Prüfung) */
@PostMapping("/{locationId}/inbox/{userId}/reply")
public ResponseEntity<Map<String, Object>> replyAsLocation(
@PathVariable UUID locationId,
@PathVariable UUID userId,
@RequestBody ReplyRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
LocationEntity loc = locOpt.get();
if (!loc.getOwnerId().equals(myId) && !adminRepo.existsByLocationIdAndUserId(locationId, myId)) {
return ResponseEntity.status(403).build();
}
if (req.text() == null || req.text().isBlank()) return ResponseEntity.badRequest().build();
UUID virtualId = loc.getVirtualUserId();
if (virtualId == null) return ResponseEntity.badRequest().build();
// Lock prüfen
LocalDateTime now = LocalDateTime.now();
var existingLockOpt = lockRepo.findByLocationIdAndVisitorId(locationId, userId);
if (existingLockOpt.isPresent()) {
LocationInboxLockEntity lock = existingLockOpt.get();
boolean expired = lock.getLockedAt().isBefore(now.minusMinutes(LOCK_TIMEOUT_MINUTES));
if (!expired && !lock.getLockedByUserId().equals(myId)) {
String lockerName = userRepo.findById(lock.getLockedByUserId())
.map(u -> u.getName()).orElse("einem anderen Admin");
return ResponseEntity.status(409).body(Map.of("lockedByName", lockerName));
}
// Abgelaufen oder meine Sperre → erneuern
lock.setLockedByUserId(myId);
lock.setLockedAt(now);
lockRepo.save(lock);
} else {
// Noch keine Sperre → automatisch erwerben
LocationInboxLockEntity lock = new LocationInboxLockEntity();
lock.setLockId(UUID.randomUUID());
lock.setLocationId(locationId);
lock.setVisitorId(userId);
lock.setLockedByUserId(myId);
lock.setLockedAt(now);
lockRepo.save(lock);
}
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(virtualId);
msg.setReceiverId(userId);
msg.setText(req.text().trim());
msg.setSentAt(now);
messageRepo.save(msg);
LOGGER.debug("Location {} hat Antwort an User {} gesendet", locationId, userId);
long unread = messageRepo.countUnread(userId);
sseService.push(userId, "DM", Map.of("unreadCount", unread, "senderId", virtualId.toString()));
return ResponseEntity.status(201).build();
}
/**
* Ermittelt den Lock-Status für eine Konversation.
* Gibt ein String-Array zurück: [canReply, lockedByMe, lockedByName|null]
*/
private String[] resolveLockStatus(UUID locationId, UUID visitorId, UUID myId) {
var lockOpt = lockRepo.findByLocationIdAndVisitorId(locationId, visitorId);
if (lockOpt.isEmpty()) return new String[]{"true", "false", null};
LocationInboxLockEntity lock = lockOpt.get();
boolean expired = lock.getLockedAt().isBefore(LocalDateTime.now().minusMinutes(LOCK_TIMEOUT_MINUTES));
if (expired) return new String[]{"true", "false", null};
if (lock.getLockedByUserId().equals(myId)) return new String[]{"true", "true", null};
String lockerName = userRepo.findById(lock.getLockedByUserId())
.map(u -> u.getName()).orElse("einem anderen Admin");
return new String[]{"false", "false", lockerName};
}
static double haversineKm(double lat1, double lon1, double lat2, double lon2) {

View File

@@ -27,10 +27,16 @@ 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.LocationAdminRepository;
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.feed.entity.FeedPostEntity;
import de.oaa.xxx.feed.repository.FeedPostRepository;
import de.oaa.xxx.gruppe.BeitragTyp;
import de.oaa.xxx.social.SystemMessageService;
import de.oaa.xxx.social.entity.MessageCause;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
@@ -42,25 +48,41 @@ 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 LocationRepository locationRepo;
private final LocationEventRepository eventRepo;
private final LocationEventAttendeeRepository attendeeRepo;
private final LocationFollowRepository followRepo;
private final UserRepository userRepo;
private final UserService userService;
private final LocationFollowRepository followRepo;
private final LocationAdminRepository adminRepo;
private final UserRepository userRepo;
private final UserService userService;
private final SystemMessageService systemMessageService;
private final FeedPostRepository feedPostRepo;
public LocationEventController(LocationRepository locationRepo,
LocationEventRepository eventRepo,
LocationEventAttendeeRepository attendeeRepo,
LocationFollowRepository followRepo,
LocationAdminRepository adminRepo,
UserRepository userRepo,
UserService userService) {
this.locationRepo = locationRepo;
this.eventRepo = eventRepo;
this.attendeeRepo = attendeeRepo;
this.followRepo = followRepo;
this.userRepo = userRepo;
this.userService = userService;
UserService userService,
SystemMessageService systemMessageService,
FeedPostRepository feedPostRepo) {
this.locationRepo = locationRepo;
this.eventRepo = eventRepo;
this.attendeeRepo = attendeeRepo;
this.followRepo = followRepo;
this.adminRepo = adminRepo;
this.userRepo = userRepo;
this.userService = userService;
this.systemMessageService = systemMessageService;
this.feedPostRepo = feedPostRepo;
}
private boolean isLocationAdmin(UUID locationId, UUID userId) {
return locationRepo.findById(locationId)
.map(l -> l.getOwnerId().equals(userId)
|| adminRepo.existsByLocationIdAndUserId(locationId, userId))
.orElse(false);
}
// ── DTOs ─────────────────────────────────────────────────────────────────
@@ -91,6 +113,7 @@ public class LocationEventController {
LocalDateTime startAt,
LocalDateTime createdAt,
boolean attendingMe,
boolean isAdmin,
List<AttendeeDto> attendees
) {}
@@ -143,6 +166,25 @@ public class LocationEventController {
event.setCreatedAt(LocalDateTime.now());
eventRepo.save(event);
// Feed-Post automatisch anlegen
try {
String locationName = locOpt.get().getName();
String dateStr = event.getStartAt().format(
java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy 'um' HH:mm 'Uhr'"));
FeedPostEntity feedPost = new FeedPostEntity();
feedPost.setPostId(UUID.randomUUID());
feedPost.setAuthorId(myId);
feedPost.setText("📍 Neue Veranstaltung bei " + locationName + ": \"" + event.getTitle() + "\" - " + dateStr);
feedPost.setBilder(event.getImageData() != null ? List.of(event.getImageData()) : List.of());
feedPost.setBeitragTyp(BeitragTyp.TEXT);
feedPost.setPublic(true);
feedPost.setCreatedAt(LocalDateTime.now());
feedPost.setTargetUrl("/community/event-detail.html?id=" + event.getEventId());
feedPostRepo.save(feedPost);
} catch (Exception ex) {
LOGGER.warn("Feed-Post für Event {} konnte nicht angelegt werden: {}", event.getEventId(), ex.getMessage());
}
LOGGER.info("Location {} hat Event {} angelegt", locationId, event.getEventId());
return ResponseEntity.status(201).body(toDetail(event, locOpt.get().getName(), myId));
}
@@ -157,17 +199,23 @@ public class LocationEventController {
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 (!isLocationAdmin(locationId, 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();
// Veranstaltungen in der Vergangenheit dürfen nicht bearbeitet werden
if (evtOpt.get().getStartAt().isBefore(LocalDateTime.now())) return ResponseEntity.status(422).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());
if (req.startAt() != null) {
if (req.startAt().isBefore(LocalDateTime.now())) return ResponseEntity.status(422).build();
event.setStartAt(req.startAt());
}
eventRepo.save(event);
return ResponseEntity.ok(toDetail(event, locOpt.get().getName(), myId));
@@ -183,14 +231,27 @@ public class LocationEventController {
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 (!isLocationAdmin(locationId, 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();
var evt = evtOpt.get();
if (!evt.getLocationId().equals(locationId)) return ResponseEntity.status(400).build();
// Alle Teilnehmenden sammeln und benachrichtigen
List<LocationEventAttendeeEntity> attendees = attendeeRepo.findByEventIdOrderByRegisteredAtAsc(eventId);
String locationName = locOpt.get().getName();
String notifyText = "Die Veranstaltung \"" + evt.getTitle() + "\" bei " + locationName + " wurde abgesagt.";
String targetUrl = "/community/location-detail.html?id=" + locationId;
attendeeRepo.deleteByEventId(eventId);
eventRepo.delete(evtOpt.get());
eventRepo.delete(evt);
attendees.stream()
.map(LocationEventAttendeeEntity::getUserId)
.filter(uid -> !uid.equals(myId))
.forEach(uid -> systemMessageService.send(myId, uid, notifyText, targetUrl, MessageCause.EVENT_CANCELLED));
return ResponseEntity.noContent().build();
}
@@ -236,6 +297,78 @@ public class LocationEventController {
return ResponseEntity.ok(Map.of("attending", attending, "attendeeCount", count));
}
// ── Meine angemeldeten Events (für Home) ─────────────────────────────────
@GetMapping("/location-events/attending-next")
public ResponseEntity<List<EventPreviewDto>> getAttendingNext(Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
List<UUID> myEventIds = attendeeRepo.findByUserId(myId).stream()
.map(LocationEventAttendeeEntity::getEventId)
.toList();
if (myEventIds.isEmpty()) return ResponseEntity.ok(List.of());
Map<UUID, de.oaa.xxx.location.entity.LocationEntity> locationById = new java.util.HashMap<>();
List<EventPreviewDto> result = eventRepo
.findUpcomingByEventIds(myEventIds, LocalDateTime.now())
.stream()
.map(e -> {
var loc = locationById.computeIfAbsent(e.getLocationId(),
id -> locationRepo.findById(id).orElse(null));
String locName = loc != null ? loc.getName() : "";
return toPreview(e, locName, 0, 0, myId);
})
.toList();
return ResponseEntity.ok(result);
}
// ── Nächste Events je abonnierter Location (für Home) ────────────────────
@GetMapping("/location-events/followed-next")
public ResponseEntity<List<EventPreviewDto>> getFollowedNext(Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
List<UUID> followedIds = followRepo.findByUserId(myId).stream()
.map(LocationFollowEntity::getLocationId)
.toList();
if (followedIds.isEmpty()) return ResponseEntity.ok(List.of());
// Events ausschließen, bei denen der User bereits angemeldet ist
Set<UUID> attendingEventIds = attendeeRepo.findByUserId(myId).stream()
.map(LocationEventAttendeeEntity::getEventId)
.collect(Collectors.toSet());
Map<UUID, de.oaa.xxx.location.entity.LocationEntity> locationById =
locationRepo.findAllById(followedIds).stream()
.collect(Collectors.toMap(
de.oaa.xxx.location.entity.LocationEntity::getLocationId,
l -> l));
// Ein Event pro Location: je das nächste, das noch nicht begonnen hat und nicht attending
List<EventPreviewDto> result = eventRepo
.findUpcomingByLocationIds(followedIds, LocalDateTime.now())
.stream()
.filter(e -> !attendingEventIds.contains(e.getEventId()))
.collect(Collectors.toMap(
LocationEventEntity::getLocationId,
e -> e,
(existing, replacement) -> existing))
.values().stream()
.sorted(Comparator.comparing(LocationEventEntity::getStartAt))
.map(e -> {
var loc = locationById.get(e.getLocationId());
String locName = loc != null ? loc.getName() : "";
return toPreview(e, locName, 0, 0, myId);
})
.toList();
return ResponseEntity.ok(result);
}
// ── Event-Suche (IDs + Batch) ─────────────────────────────────────────────
/**
@@ -257,7 +390,7 @@ public class LocationEventController {
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
// Abonnierte Locations - deren Events werden immer eingeschlossen
Set<UUID> followedLocationIds = followRepo.findByUserId(myId).stream()
.map(LocationFollowEntity::getLocationId)
.collect(Collectors.toSet());
@@ -374,11 +507,12 @@ public class LocationEventController {
.toList();
boolean attendingMe = attendeeRepo.findByEventIdAndUserId(e.getEventId(), myId).isPresent();
boolean isAdmin = isLocationAdmin(e.getLocationId(), myId);
return new EventDetailDto(
e.getEventId(), e.getLocationId(), locationName,
e.getTitle(), e.getDescription(), e.getImageData(),
e.getStartAt(), e.getCreatedAt(),
attendingMe, attendees);
attendingMe, isAdmin, attendees);
}
}

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_admin")
public class LocationAdminEntity {
@Id
@Column
private UUID adminId;
@Column(nullable = false)
private UUID locationId;
@Column(nullable = false)
private UUID userId;
@Column(nullable = false)
private LocalDateTime addedAt;
}

View File

@@ -52,4 +52,8 @@ public class LocationEntity {
@Column(nullable = false)
private LocalDateTime createdAt;
/** Virtuelle Benutzer-ID für das Nachrichtensystem (einmalig generiert, unveränderlich) */
@Column(unique = true)
private UUID virtualUserId;
}

View File

@@ -0,0 +1,35 @@
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_inbox_lock",
uniqueConstraints = @UniqueConstraint(columnNames = {"location_id", "visitor_id"}))
public class LocationInboxLockEntity {
@Id
@Column
private UUID lockId;
@Column(name = "location_id", nullable = false)
private UUID locationId;
/** Die kontaktierende Person */
@Column(name = "visitor_id", nullable = false)
private UUID visitorId;
/** Der Admin/Inhaber, der gerade antwortet */
@Column(name = "locked_by_user_id", nullable = false)
private UUID lockedByUserId;
/** Letzte Aktivität des Lock-Inhabers (wird bei jeder Antwort erneuert) */
@Column(nullable = false)
private LocalDateTime lockedAt;
}

View File

@@ -0,0 +1,17 @@
package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationAdminEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface LocationAdminRepository extends JpaRepository<LocationAdminEntity, UUID> {
List<LocationAdminEntity> findByLocationId(UUID locationId);
Optional<LocationAdminEntity> findByLocationIdAndUserId(UUID locationId, UUID userId);
boolean existsByLocationIdAndUserId(UUID locationId, UUID userId);
void deleteByLocationId(UUID locationId);
void deleteByLocationIdAndUserId(UUID locationId, UUID userId);
}

View File

@@ -11,6 +11,8 @@ public interface LocationEventAttendeeRepository extends JpaRepository<LocationE
List<LocationEventAttendeeEntity> findByEventIdOrderByRegisteredAtAsc(UUID eventId);
List<LocationEventAttendeeEntity> findByUserId(UUID userId);
Optional<LocationEventAttendeeEntity> findByEventIdAndUserId(UUID eventId, UUID userId);
long countByEventId(UUID eventId);

View File

@@ -3,8 +3,10 @@ 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 org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
@@ -12,6 +14,12 @@ public interface LocationEventRepository extends JpaRepository<LocationEventEnti
List<LocationEventEntity> findByLocationIdOrderByStartAtAsc(UUID locationId);
@Query("SELECT e FROM LocationEventEntity e WHERE e.locationId IN :locationIds AND e.startAt >= :from ORDER BY e.startAt ASC")
List<LocationEventEntity> findUpcomingByLocationIds(@Param("locationIds") Collection<UUID> locationIds, @Param("from") LocalDateTime from);
@Query("SELECT e FROM LocationEventEntity e WHERE e.eventId IN :eventIds AND e.startAt >= :from ORDER BY e.startAt ASC")
List<LocationEventEntity> findUpcomingByEventIds(@Param("eventIds") Collection<UUID> eventIds, @Param("from") LocalDateTime from);
/** Alle zukünftigen Events mit Koordinaten ihrer Location (für Umkreis-Suche) */
@Query("""
SELECT e FROM LocationEventEntity e

View File

@@ -0,0 +1,12 @@
package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationInboxLockEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface LocationInboxLockRepository extends JpaRepository<LocationInboxLockEntity, UUID> {
Optional<LocationInboxLockEntity> findByLocationIdAndVisitorId(UUID locationId, UUID visitorId);
}

View File

@@ -4,6 +4,7 @@ import de.oaa.xxx.location.entity.LocationEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface LocationRepository extends JpaRepository<LocationEntity, UUID> {
@@ -12,4 +13,8 @@ public interface LocationRepository extends JpaRepository<LocationEntity, UUID>
/** Alle Locations mit gesetzten Koordinaten (für Umkreissuche) */
List<LocationEntity> findByLatIsNotNullAndLonIsNotNull();
Optional<LocationEntity> findByVirtualUserId(UUID virtualUserId);
List<LocationEntity> findByVirtualUserIdIsNotNull();
}