diff --git a/bin/main/de/oaa/xxx/feed/FeedController$FeedPage.class b/bin/main/de/oaa/xxx/feed/FeedController$FeedPage.class index fa4406c..61b7dd5 100644 Binary files a/bin/main/de/oaa/xxx/feed/FeedController$FeedPage.class and b/bin/main/de/oaa/xxx/feed/FeedController$FeedPage.class differ diff --git a/bin/main/de/oaa/xxx/feed/FeedController$VoteRequest.class b/bin/main/de/oaa/xxx/feed/FeedController$VoteRequest.class index 1e18477..8bf8cb7 100644 Binary files a/bin/main/de/oaa/xxx/feed/FeedController$VoteRequest.class and b/bin/main/de/oaa/xxx/feed/FeedController$VoteRequest.class differ diff --git a/bin/main/de/oaa/xxx/feed/FeedController.class b/bin/main/de/oaa/xxx/feed/FeedController.class index 26cd24d..a360759 100644 Binary files a/bin/main/de/oaa/xxx/feed/FeedController.class and b/bin/main/de/oaa/xxx/feed/FeedController.class differ diff --git a/bin/main/de/oaa/xxx/feed/dto/FeedItemDto.class b/bin/main/de/oaa/xxx/feed/dto/FeedItemDto.class index 1fc50ea..afcc64c 100644 Binary files a/bin/main/de/oaa/xxx/feed/dto/FeedItemDto.class and b/bin/main/de/oaa/xxx/feed/dto/FeedItemDto.class differ diff --git a/bin/main/de/oaa/xxx/feed/entity/FeedPostEntity.class b/bin/main/de/oaa/xxx/feed/entity/FeedPostEntity.class index 61dc820..eeae38f 100644 Binary files a/bin/main/de/oaa/xxx/feed/entity/FeedPostEntity.class and b/bin/main/de/oaa/xxx/feed/entity/FeedPostEntity.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationAdminController$AddAdminRequest.class b/bin/main/de/oaa/xxx/location/LocationAdminController$AddAdminRequest.class new file mode 100644 index 0000000..ffc5fd1 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationAdminController$AddAdminRequest.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationAdminController$AdminDto.class b/bin/main/de/oaa/xxx/location/LocationAdminController$AdminDto.class new file mode 100644 index 0000000..80836bc Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationAdminController$AdminDto.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationAdminController$TransferOwnerRequest.class b/bin/main/de/oaa/xxx/location/LocationAdminController$TransferOwnerRequest.class new file mode 100644 index 0000000..f6532aa Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationAdminController$TransferOwnerRequest.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationAdminController.class b/bin/main/de/oaa/xxx/location/LocationAdminController.class new file mode 100644 index 0000000..04aee2e Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationAdminController.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationChatCleanupService.class b/bin/main/de/oaa/xxx/location/LocationChatCleanupService.class new file mode 100644 index 0000000..cb13670 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationChatCleanupService.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$AdminDto.class b/bin/main/de/oaa/xxx/location/LocationController$AdminDto.class new file mode 100644 index 0000000..039d6ae Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationController$AdminDto.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$BatchRequest.class b/bin/main/de/oaa/xxx/location/LocationController$BatchRequest.class index 2a18645..0c6c3c8 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationController$BatchRequest.class 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 index c521098..ffc7ced 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationController$CreateRequest.class 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 index 4e1ef8c..141e5b4 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationController$GalleryImageDto.class 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 index af2f3b4..cfe915c 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationController$GalleryUploadRequest.class 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 index 7520eed..ebf0280 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationController$IdsResult.class and b/bin/main/de/oaa/xxx/location/LocationController$IdsResult.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$InboxConversationDto.class b/bin/main/de/oaa/xxx/location/LocationController$InboxConversationDto.class new file mode 100644 index 0000000..f7fcdeb Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationController$InboxConversationDto.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$InboxSummaryDto.class b/bin/main/de/oaa/xxx/location/LocationController$InboxSummaryDto.class new file mode 100644 index 0000000..ebb4f2f Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationController$InboxSummaryDto.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$LocationDetailDto.class b/bin/main/de/oaa/xxx/location/LocationController$LocationDetailDto.class index 7343c5e..5899461 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationController$LocationDetailDto.class 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 index c902301..3bf2b57 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationController$LocationPreviewDto.class and b/bin/main/de/oaa/xxx/location/LocationController$LocationPreviewDto.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$LocationVirtualInfoDto.class b/bin/main/de/oaa/xxx/location/LocationController$LocationVirtualInfoDto.class new file mode 100644 index 0000000..858879c Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationController$LocationVirtualInfoDto.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$OpeningHourDto.class b/bin/main/de/oaa/xxx/location/LocationController$OpeningHourDto.class index ae79af5..9614215 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationController$OpeningHourDto.class and b/bin/main/de/oaa/xxx/location/LocationController$OpeningHourDto.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$ReplyRequest.class b/bin/main/de/oaa/xxx/location/LocationController$ReplyRequest.class new file mode 100644 index 0000000..251909f Binary files /dev/null and b/bin/main/de/oaa/xxx/location/LocationController$ReplyRequest.class differ diff --git a/bin/main/de/oaa/xxx/location/LocationController$UpdateRequest.class b/bin/main/de/oaa/xxx/location/LocationController$UpdateRequest.class index 05556ad..19cdbb7 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationController$UpdateRequest.class 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 index 5d80846..7334dca 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationController.class 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 index f086d51..1a82d2b 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationEventController$AttendeeDto.class 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 index daaa187..8e45441 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationEventController$BatchRequest.class 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 index a1cea74..7156353 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationEventController$CreateEventRequest.class 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 index 62cb579..2cf6d50 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationEventController$EventDetailDto.class 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 index 3364532..f9c35cd 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationEventController$EventPreviewDto.class 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 index 9aed8db..faa6b8e 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationEventController$IdsResult.class 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 index d63f5a1..82ffc99 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationEventController$UpdateEventRequest.class 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 index 4beaf73..878a9e5 100644 Binary files a/bin/main/de/oaa/xxx/location/LocationEventController.class and b/bin/main/de/oaa/xxx/location/LocationEventController.class differ diff --git a/bin/main/de/oaa/xxx/location/entity/LocationAdminEntity.class b/bin/main/de/oaa/xxx/location/entity/LocationAdminEntity.class new file mode 100644 index 0000000..3c21331 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/entity/LocationAdminEntity.class differ diff --git a/bin/main/de/oaa/xxx/location/entity/LocationEntity.class b/bin/main/de/oaa/xxx/location/entity/LocationEntity.class index d24031d..8865e65 100644 Binary files a/bin/main/de/oaa/xxx/location/entity/LocationEntity.class and b/bin/main/de/oaa/xxx/location/entity/LocationEntity.class differ diff --git a/bin/main/de/oaa/xxx/location/entity/LocationInboxLockEntity.class b/bin/main/de/oaa/xxx/location/entity/LocationInboxLockEntity.class new file mode 100644 index 0000000..116f2b8 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/entity/LocationInboxLockEntity.class differ diff --git a/bin/main/de/oaa/xxx/location/repository/LocationAdminRepository.class b/bin/main/de/oaa/xxx/location/repository/LocationAdminRepository.class new file mode 100644 index 0000000..3c32d34 Binary files /dev/null and b/bin/main/de/oaa/xxx/location/repository/LocationAdminRepository.class differ diff --git a/bin/main/de/oaa/xxx/location/repository/LocationEventAttendeeRepository.class b/bin/main/de/oaa/xxx/location/repository/LocationEventAttendeeRepository.class index 346c42d..0f0326c 100644 Binary files a/bin/main/de/oaa/xxx/location/repository/LocationEventAttendeeRepository.class 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 index 10b185f..ee750ee 100644 Binary files a/bin/main/de/oaa/xxx/location/repository/LocationEventRepository.class and b/bin/main/de/oaa/xxx/location/repository/LocationEventRepository.class differ diff --git a/bin/main/de/oaa/xxx/location/repository/LocationInboxLockRepository.class b/bin/main/de/oaa/xxx/location/repository/LocationInboxLockRepository.class new file mode 100644 index 0000000..cc0dadf Binary files /dev/null and b/bin/main/de/oaa/xxx/location/repository/LocationInboxLockRepository.class differ diff --git a/bin/main/de/oaa/xxx/location/repository/LocationRepository.class b/bin/main/de/oaa/xxx/location/repository/LocationRepository.class index f8942dd..a320d1a 100644 Binary files a/bin/main/de/oaa/xxx/location/repository/LocationRepository.class and b/bin/main/de/oaa/xxx/location/repository/LocationRepository.class differ diff --git a/bin/main/de/oaa/xxx/social/SocialController$FriendRequestBody.class b/bin/main/de/oaa/xxx/social/SocialController$FriendRequestBody.class index 343fbe8..cb05d11 100644 Binary files a/bin/main/de/oaa/xxx/social/SocialController$FriendRequestBody.class and b/bin/main/de/oaa/xxx/social/SocialController$FriendRequestBody.class differ diff --git a/bin/main/de/oaa/xxx/social/SocialController$FriendshipActionBody.class b/bin/main/de/oaa/xxx/social/SocialController$FriendshipActionBody.class index a7852b8..3dc7dde 100644 Binary files a/bin/main/de/oaa/xxx/social/SocialController$FriendshipActionBody.class and b/bin/main/de/oaa/xxx/social/SocialController$FriendshipActionBody.class differ diff --git a/bin/main/de/oaa/xxx/social/SocialController$SendMessageBody.class b/bin/main/de/oaa/xxx/social/SocialController$SendMessageBody.class index 81c809c..235eb47 100644 Binary files a/bin/main/de/oaa/xxx/social/SocialController$SendMessageBody.class and b/bin/main/de/oaa/xxx/social/SocialController$SendMessageBody.class differ diff --git a/bin/main/de/oaa/xxx/social/SocialController.class b/bin/main/de/oaa/xxx/social/SocialController.class index caea811..16baf51 100644 Binary files a/bin/main/de/oaa/xxx/social/SocialController.class and b/bin/main/de/oaa/xxx/social/SocialController.class differ diff --git a/bin/main/de/oaa/xxx/social/SystemMessageService.class b/bin/main/de/oaa/xxx/social/SystemMessageService.class index ea5b96c..6f47d2a 100644 Binary files a/bin/main/de/oaa/xxx/social/SystemMessageService.class and b/bin/main/de/oaa/xxx/social/SystemMessageService.class differ diff --git a/bin/main/de/oaa/xxx/social/entity/MessageCause.class b/bin/main/de/oaa/xxx/social/entity/MessageCause.class index a315a68..14f292b 100644 Binary files a/bin/main/de/oaa/xxx/social/entity/MessageCause.class and b/bin/main/de/oaa/xxx/social/entity/MessageCause.class differ diff --git a/bin/main/static/community/event-detail.html b/bin/main/static/community/event-detail.html index 5ccece3..228ee31 100644 --- a/bin/main/static/community/event-detail.html +++ b/bin/main/static/community/event-detail.html @@ -20,7 +20,7 @@ .evt-date { font-size:0.88rem; color:var(--color-muted); margin-bottom:0.5rem; } .evt-desc { font-size:0.93rem; line-height:1.55; white-space:pre-wrap; word-break:break-word; margin-top:0.5rem; } - .attend-btn { display:inline-flex; align-items:center; gap:0.4rem; margin-top:0.75rem; } + .attend-btn { display:inline-flex; align-items:center; gap:0.4rem; margin-top:0.75rem; flex-wrap:wrap; } .section-title { font-size:1rem; font-weight:700; margin:1.5rem 0 0.75rem; } .gender-group { margin-bottom:1.25rem; } @@ -31,23 +31,92 @@ .attendee-avatar { width:28px; height:28px; border-radius:50%; background:var(--color-secondary); object-fit:cover; flex-shrink:0; overflow:hidden; display:flex; align-items:center; justify-content:center; font-size:0.8rem; } .attendee-avatar img { width:100%; height:100%; object-fit:cover; } .count-badge { background:var(--color-secondary); border-radius:12px; padding:0.15rem 0.6rem; font-size:0.78rem; color:var(--color-muted); margin-left:0.25rem; display:inline-block; } + + /* Modal */ + .modal-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:200; align-items:center; justify-content:center; } + .modal-overlay.open { display:flex; } + .modal { background:var(--color-card); border-radius:12px; width:min(520px,95vw); max-height:90vh; overflow-y:auto; padding:1.5rem; } + .modal h3 { margin:0 0 1rem; } + .modal-footer { display:flex; gap:0.75rem; justify-content:flex-end; margin-top:1.25rem; flex-wrap:wrap; } + .img-preview { width:80px; height:80px; border-radius:8px; object-fit:cover; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:1.5rem; flex-shrink:0; overflow:hidden; border:1px solid var(--color-secondary); } + .img-preview img { width:100%; height:100%; object-fit:cover; } + .img-row { display:flex; gap:0.75rem; align-items:center; margin-bottom:0.5rem; } - -
- ← Veranstaltungen + -
-

Wird geladen…

+ + + + + + + + + +
+
+ ← Veranstaltungen +
+

Wird geladen…

+
- + + @@ -178,6 +182,8 @@ if (user) { document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!'; loadVisitors(); + loadMyEvents(); + loadLocEvents(); if (user.datingAktiv) { loadWhoLikesMe(); loadMatches(); @@ -257,6 +263,47 @@ return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } + function renderEventCards(events, listId, sectionId) { + if (!events.length) return; + const list = document.getElementById(listId); + list.innerHTML = events.map(e => { + const thumb = e.imageData + ? `` + : '🗓'; + const date = new Date(e.startAt); + const dateStr = date.toLocaleDateString('de-DE', { weekday:'short', day:'numeric', month:'short' }) + + ', ' + date.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr'; + return ` + +
${thumb}
+
+
${esc(e.locationName)}
+
${esc(e.title)}
+
${dateStr}
+
+
`; + }).join(''); + document.getElementById(sectionId).style.display = ''; + } + + async function loadMyEvents() { + try { + const res = await fetch('/location-events/attending-next'); + if (!res.ok) return; + const events = await res.json(); + renderEventCards(events, 'myEventsList', 'myEventsSection'); + } catch (_) {} + } + + async function loadLocEvents() { + try { + const res = await fetch('/location-events/followed-next'); + if (!res.ok) return; + const events = await res.json(); + renderEventCards(events, 'locEventsList', 'locEventsSection'); + } catch (_) {} + } + async function loadVisitors() { try { const res = await fetch('/social/profile-visits/my-visitors'); diff --git a/src/main/java/de/oaa/xxx/feed/FeedController.java b/src/main/java/de/oaa/xxx/feed/FeedController.java index f4007e1..2a65fbe 100644 --- a/src/main/java/de/oaa/xxx/feed/FeedController.java +++ b/src/main/java/de/oaa/xxx/feed/FeedController.java @@ -8,6 +8,8 @@ import java.util.List; import java.util.UUID; import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; import org.springframework.http.ResponseEntity; @@ -47,9 +49,6 @@ import de.oaa.xxx.user.UserEntity; import de.oaa.xxx.user.UserRepository; import de.oaa.xxx.user.UserService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - @RestController @RequestMapping("/feed") public class FeedController { @@ -365,7 +364,8 @@ public class FeedController { p.getCreatedAt(), likeCount, likedByMe, kommentarCount, optionen, myVoteOptionIds, - p.isPublic() + p.isPublic(), + p.getTargetUrl() ); } @@ -402,7 +402,8 @@ public class FeedController { b.getCreatedAt(), likeCount, likedByMe, kommentarCount, optionen, myVoteOptionIds, - false + false, + null ); } } diff --git a/src/main/java/de/oaa/xxx/feed/dto/FeedItemDto.java b/src/main/java/de/oaa/xxx/feed/dto/FeedItemDto.java index 67e1122..f2e1c7b 100644 --- a/src/main/java/de/oaa/xxx/feed/dto/FeedItemDto.java +++ b/src/main/java/de/oaa/xxx/feed/dto/FeedItemDto.java @@ -24,5 +24,6 @@ public record FeedItemDto( long kommentarCount, List optionen, List myVoteOptionIds, - boolean isPublic + boolean isPublic, + String targetUrl ) {} diff --git a/src/main/java/de/oaa/xxx/feed/entity/FeedPostEntity.java b/src/main/java/de/oaa/xxx/feed/entity/FeedPostEntity.java index cb682f8..fc0a6ec 100644 --- a/src/main/java/de/oaa/xxx/feed/entity/FeedPostEntity.java +++ b/src/main/java/de/oaa/xxx/feed/entity/FeedPostEntity.java @@ -42,4 +42,7 @@ public class FeedPostEntity { @Column(nullable = false) private LocalDateTime createdAt; + + @Column(columnDefinition = "TEXT") + private String targetUrl; } diff --git a/src/main/java/de/oaa/xxx/location/LocationAdminController.java b/src/main/java/de/oaa/xxx/location/LocationAdminController.java new file mode 100644 index 0000000..3ad47fc --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/LocationAdminController.java @@ -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( + @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 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 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 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> 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())); + } +} diff --git a/src/main/java/de/oaa/xxx/location/LocationChatCleanupService.java b/src/main/java/de/oaa/xxx/location/LocationChatCleanupService.java new file mode 100644 index 0000000..96d5c32 --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/LocationChatCleanupService.java @@ -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 locations = locationRepo.findByVirtualUserIdIsNotNull(); + int deleted = 0; + + for (LocationEntity loc : locations) { + UUID virtualId = loc.getVirtualUserId(); + + // Alle Nachrichten dieser Location (in beide Richtungen) + List allMessages = messageRepo.findAllByUser(virtualId); + if (allMessages.isEmpty()) continue; + + // Neueste Nachricht pro Gesprächspartner (Besucher) + Map 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 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); + } + } +} diff --git a/src/main/java/de/oaa/xxx/location/LocationController.java b/src/main/java/de/oaa/xxx/location/LocationController.java index ac861a2..518a535 100644 --- a/src/main/java/de/oaa/xxx/location/LocationController.java +++ b/src/main/java/de/oaa/xxx/location/LocationController.java @@ -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 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 gallery, List openingHours, - boolean following + boolean following, + List 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 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 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 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 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> 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 allMessages = messageRepo.findAllByUser(virtualId); + + // Neueste Nachricht pro Gesprächspartner (Besucher) + Map latestByPartner = new LinkedHashMap<>(); + for (MessageEntity m : allMessages) { + UUID partnerId = m.getSenderId().equals(virtualId) ? m.getReceiverId() : m.getSenderId(); + latestByPartner.putIfAbsent(partnerId, m); + } + + List summaries = new ArrayList<>(); + for (Map.Entry 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 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 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 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> 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> 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) { diff --git a/src/main/java/de/oaa/xxx/location/LocationEventController.java b/src/main/java/de/oaa/xxx/location/LocationEventController.java index 0dadea7..1255c32 100644 --- a/src/main/java/de/oaa/xxx/location/LocationEventController.java +++ b/src/main/java/de/oaa/xxx/location/LocationEventController.java @@ -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 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 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> getAttendingNext(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + List myEventIds = attendeeRepo.findByUserId(myId).stream() + .map(LocationEventAttendeeEntity::getEventId) + .toList(); + + if (myEventIds.isEmpty()) return ResponseEntity.ok(List.of()); + + Map locationById = new java.util.HashMap<>(); + + List 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> getFollowedNext(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + List 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 attendingEventIds = attendeeRepo.findByUserId(myId).stream() + .map(LocationEventAttendeeEntity::getEventId) + .collect(Collectors.toSet()); + + Map 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 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 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); } } diff --git a/src/main/java/de/oaa/xxx/location/entity/LocationAdminEntity.java b/src/main/java/de/oaa/xxx/location/entity/LocationAdminEntity.java new file mode 100644 index 0000000..128c9c8 --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/entity/LocationAdminEntity.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_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; +} diff --git a/src/main/java/de/oaa/xxx/location/entity/LocationEntity.java b/src/main/java/de/oaa/xxx/location/entity/LocationEntity.java index 6bcaae4..2cad207 100644 --- a/src/main/java/de/oaa/xxx/location/entity/LocationEntity.java +++ b/src/main/java/de/oaa/xxx/location/entity/LocationEntity.java @@ -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; } diff --git a/src/main/java/de/oaa/xxx/location/entity/LocationInboxLockEntity.java b/src/main/java/de/oaa/xxx/location/entity/LocationInboxLockEntity.java new file mode 100644 index 0000000..17899af --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/entity/LocationInboxLockEntity.java @@ -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; +} diff --git a/src/main/java/de/oaa/xxx/location/repository/LocationAdminRepository.java b/src/main/java/de/oaa/xxx/location/repository/LocationAdminRepository.java new file mode 100644 index 0000000..c049f68 --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/repository/LocationAdminRepository.java @@ -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 { + + List findByLocationId(UUID locationId); + Optional findByLocationIdAndUserId(UUID locationId, UUID userId); + boolean existsByLocationIdAndUserId(UUID locationId, UUID userId); + void deleteByLocationId(UUID locationId); + void deleteByLocationIdAndUserId(UUID locationId, UUID userId); +} diff --git a/src/main/java/de/oaa/xxx/location/repository/LocationEventAttendeeRepository.java b/src/main/java/de/oaa/xxx/location/repository/LocationEventAttendeeRepository.java index c559fdf..6d407eb 100644 --- a/src/main/java/de/oaa/xxx/location/repository/LocationEventAttendeeRepository.java +++ b/src/main/java/de/oaa/xxx/location/repository/LocationEventAttendeeRepository.java @@ -11,6 +11,8 @@ public interface LocationEventAttendeeRepository extends JpaRepository findByEventIdOrderByRegisteredAtAsc(UUID eventId); + List findByUserId(UUID userId); + Optional findByEventIdAndUserId(UUID eventId, UUID userId); long countByEventId(UUID eventId); diff --git a/src/main/java/de/oaa/xxx/location/repository/LocationEventRepository.java b/src/main/java/de/oaa/xxx/location/repository/LocationEventRepository.java index c0e4beb..0158ed4 100644 --- a/src/main/java/de/oaa/xxx/location/repository/LocationEventRepository.java +++ b/src/main/java/de/oaa/xxx/location/repository/LocationEventRepository.java @@ -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 findByLocationIdOrderByStartAtAsc(UUID locationId); + @Query("SELECT e FROM LocationEventEntity e WHERE e.locationId IN :locationIds AND e.startAt >= :from ORDER BY e.startAt ASC") + List findUpcomingByLocationIds(@Param("locationIds") Collection 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 findUpcomingByEventIds(@Param("eventIds") Collection eventIds, @Param("from") LocalDateTime from); + /** Alle zukünftigen Events mit Koordinaten ihrer Location (für Umkreis-Suche) */ @Query(""" SELECT e FROM LocationEventEntity e diff --git a/src/main/java/de/oaa/xxx/location/repository/LocationInboxLockRepository.java b/src/main/java/de/oaa/xxx/location/repository/LocationInboxLockRepository.java new file mode 100644 index 0000000..091d45b --- /dev/null +++ b/src/main/java/de/oaa/xxx/location/repository/LocationInboxLockRepository.java @@ -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 { + + Optional findByLocationIdAndVisitorId(UUID locationId, UUID visitorId); +} diff --git a/src/main/java/de/oaa/xxx/location/repository/LocationRepository.java b/src/main/java/de/oaa/xxx/location/repository/LocationRepository.java index a22b9f4..69fb8af 100644 --- a/src/main/java/de/oaa/xxx/location/repository/LocationRepository.java +++ b/src/main/java/de/oaa/xxx/location/repository/LocationRepository.java @@ -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 { @@ -12,4 +13,8 @@ public interface LocationRepository extends JpaRepository /** Alle Locations mit gesetzten Koordinaten (für Umkreissuche) */ List findByLatIsNotNullAndLonIsNotNull(); + + Optional findByVirtualUserId(UUID virtualUserId); + + List findByVirtualUserIdIsNotNull(); } diff --git a/src/main/java/de/oaa/xxx/social/SocialController.java b/src/main/java/de/oaa/xxx/social/SocialController.java index f4f6558..afadf6a 100644 --- a/src/main/java/de/oaa/xxx/social/SocialController.java +++ b/src/main/java/de/oaa/xxx/social/SocialController.java @@ -1,6 +1,9 @@ package de.oaa.xxx.social; import de.oaa.xxx.dating.DatingMatchRepository; +import de.oaa.xxx.location.entity.LocationEntity; +import de.oaa.xxx.location.repository.LocationAdminRepository; +import de.oaa.xxx.location.repository.LocationRepository; import de.oaa.xxx.social.dto.ConversationSummary; import de.oaa.xxx.social.dto.FriendshipDto; import de.oaa.xxx.social.dto.MessageDto; @@ -43,6 +46,8 @@ public class SocialController { private final SseService sseService; private final SystemMessageService systemMessageService; private final UserService userService; + private final LocationRepository locationRepository; + private final LocationAdminRepository locationAdminRepository; public SocialController(UserRepository userRepository, FriendshipRepository friendshipRepository, @@ -52,7 +57,9 @@ public class SocialController { SubscriptionLimitService subscriptionLimitService, SseService sseService, SystemMessageService systemMessageService, - UserService userService) { + UserService userService, + LocationRepository locationRepository, + LocationAdminRepository locationAdminRepository) { this.userRepository = userRepository; this.friendshipRepository = friendshipRepository; this.messageRepository = messageRepository; @@ -62,6 +69,8 @@ public class SocialController { this.sseService = sseService; this.systemMessageService = systemMessageService; this.userService = userService; + this.locationRepository = locationRepository; + this.locationAdminRepository = locationAdminRepository; } record FriendRequestBody(UUID receiverId) {} @@ -216,7 +225,8 @@ public class SocialController { @PostMapping("/messages") public ResponseEntity> sendMessage(@RequestBody SendMessageBody body, Principal principal) { - UUID myId = userService.requireUser(principal).getUserId(); + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); if (body.text() == null || body.text().isBlank()) return ResponseEntity.badRequest().build(); @@ -225,6 +235,31 @@ public class SocialController { return ResponseEntity.status(403).build(); } + // Location-Nachricht: keine Freundschafts-/Abo-Prüfung, alle Admins benachrichtigen + var locationOpt = locationRepository.findByVirtualUserId(body.receiverId()); + if (locationOpt.isPresent()) { + LocationEntity loc = locationOpt.get(); + MessageEntity msg = new MessageEntity(); + msg.setMessageId(UUID.randomUUID()); + msg.setSenderId(myId); + msg.setReceiverId(body.receiverId()); + msg.setText(body.text().trim()); + msg.setSentAt(LocalDateTime.now()); + messageRepository.save(msg); + LOGGER.debug("User {} hat Location {} (virtualId {}) kontaktiert", myId, loc.getLocationId(), body.receiverId()); + + // Alle Admins + Inhaber per Systembenachrichtigung informieren + Set adminIds = new java.util.LinkedHashSet<>(); + adminIds.add(loc.getOwnerId()); + locationAdminRepository.findByLocationId(loc.getLocationId()).forEach(a -> adminIds.add(a.getUserId())); + String notifText = me.getName() + " hat deine Location \"" + loc.getName() + "\" kontaktiert."; + String targetUrl = "/community/location-detail.html?id=" + loc.getLocationId() + "&chatWith=" + myId; + for (UUID adminId : adminIds) { + systemMessageService.send(myId, adminId, notifText, targetUrl, MessageCause.LOCATION_MESSAGE); + } + return ResponseEntity.status(201).build(); + } + // Blockiert? (in beide Richtungen) if (blockRepository.existsBlock(myId, body.receiverId())) { return ResponseEntity.status(403).body(Map.of("reason", "BLOCKED")); @@ -272,10 +307,20 @@ public class SocialController { for (Map.Entry entry : latestByPartner.entrySet()) { UUID partnerId = entry.getKey(); MessageEntity lastMsg = entry.getValue(); - var partnerOpt = userRepository.findById(partnerId); - if (partnerOpt.isEmpty()) continue; - UserProfile partnerProfile = toUserProfileWithStatus(partnerOpt.get(), myId); + UserProfile partnerProfile; + var userOpt = userRepository.findById(partnerId); + if (userOpt.isPresent()) { + partnerProfile = toUserProfileWithStatus(userOpt.get(), myId); + } else { + // Kein User → prüfen ob es eine Location-virtualUserId ist + var locOpt = locationRepository.findByVirtualUserId(partnerId); + if (locOpt.isEmpty()) continue; + LocationEntity loc = locOpt.get(); + partnerProfile = new UserProfile(partnerId, loc.getName(), + loc.getProfilePictureLq(), loc.getProfilePictureHq(), "LOCATION"); + } + MessageDto lastMsgDto = toMessageDto(lastMsg); long unreadCount = allMessages.stream() .filter(m -> m.getSenderId().equals(partnerId) @@ -433,7 +478,9 @@ public class SocialController { private MessageDto toMessageDto(MessageEntity m) { String senderName = userRepository.findById(m.getSenderId()) .map(UserEntity::getName) - .orElse("Unbekannt"); + .orElseGet(() -> locationRepository.findByVirtualUserId(m.getSenderId()) + .map(LocationEntity::getName) + .orElse("Unbekannt")); return new MessageDto( m.getMessageId(), m.getSenderId(), senderName, m.getReceiverId(), m.getText(), m.getSentAt(), m.getReadAt() != null); diff --git a/src/main/java/de/oaa/xxx/social/SystemMessageService.java b/src/main/java/de/oaa/xxx/social/SystemMessageService.java index d2cc71b..a07cbb1 100644 --- a/src/main/java/de/oaa/xxx/social/SystemMessageService.java +++ b/src/main/java/de/oaa/xxx/social/SystemMessageService.java @@ -57,9 +57,10 @@ public class SystemMessageService { .findByUserIdAndCause(receiverId, cause) .orElseGet(() -> NotificationPreferenceEntity.defaultFor(receiverId, cause)); - // FRIENDREQUEST, INVITATION und DATE_INTEREST sind immer nur in-app, kein E-Mail + // Diese Causes sind immer nur in-app, kein E-Mail boolean sendInApp = cause == MessageCause.FRIENDREQUEST || cause == MessageCause.INVITATION - || cause == MessageCause.DATE_INTEREST || pref.isInApp(); + || cause == MessageCause.DATE_INTEREST || cause == MessageCause.LOCATION_MESSAGE + || pref.isInApp(); if (sendInApp) { MessageEntity msg = new MessageEntity(); @@ -103,12 +104,14 @@ public class SystemMessageService { private String causeTitel(MessageCause cause) { return switch (cause) { - case INVITATION -> "XXX The Game – Neue Einladung"; - case GAME_STATE -> "XXX The Game – Spielstatus-Änderung"; - case EMERGENCY -> "XXX The Game – ⚠️ Notfall"; - case FRIENDREQUEST -> "XXX The Game – Neue Freundschaftsanfrage"; - case SUPPORT -> "xXx Sphere – Nachricht vom Support"; - case DATE_INTEREST -> "xXx Sphere – Interesse an deinem Date"; + case INVITATION -> "XXX The Game – Neue Einladung"; + case GAME_STATE -> "XXX The Game – Spielstatus-Änderung"; + case EMERGENCY -> "XXX The Game – ⚠️ Notfall"; + case FRIENDREQUEST -> "XXX The Game – Neue Freundschaftsanfrage"; + case SUPPORT -> "xXx Sphere – Nachricht vom Support"; + case DATE_INTEREST -> "xXx Sphere – Interesse an deinem Date"; + case EVENT_CANCELLED -> "xXx Sphere – Veranstaltung abgesagt"; + case LOCATION_MESSAGE -> "xXx Sphere – Neue Nachricht an deine Location"; }; } diff --git a/src/main/java/de/oaa/xxx/social/entity/MessageCause.java b/src/main/java/de/oaa/xxx/social/entity/MessageCause.java index 603ed30..99a22f8 100644 --- a/src/main/java/de/oaa/xxx/social/entity/MessageCause.java +++ b/src/main/java/de/oaa/xxx/social/entity/MessageCause.java @@ -6,5 +6,7 @@ public enum MessageCause { EMERGENCY, FRIENDREQUEST, SUPPORT, - DATE_INTEREST + DATE_INTEREST, + EVENT_CANCELLED, + LOCATION_MESSAGE } diff --git a/src/main/resources/static/community/event-detail.html b/src/main/resources/static/community/event-detail.html index 5ccece3..228ee31 100644 --- a/src/main/resources/static/community/event-detail.html +++ b/src/main/resources/static/community/event-detail.html @@ -20,7 +20,7 @@ .evt-date { font-size:0.88rem; color:var(--color-muted); margin-bottom:0.5rem; } .evt-desc { font-size:0.93rem; line-height:1.55; white-space:pre-wrap; word-break:break-word; margin-top:0.5rem; } - .attend-btn { display:inline-flex; align-items:center; gap:0.4rem; margin-top:0.75rem; } + .attend-btn { display:inline-flex; align-items:center; gap:0.4rem; margin-top:0.75rem; flex-wrap:wrap; } .section-title { font-size:1rem; font-weight:700; margin:1.5rem 0 0.75rem; } .gender-group { margin-bottom:1.25rem; } @@ -31,23 +31,92 @@ .attendee-avatar { width:28px; height:28px; border-radius:50%; background:var(--color-secondary); object-fit:cover; flex-shrink:0; overflow:hidden; display:flex; align-items:center; justify-content:center; font-size:0.8rem; } .attendee-avatar img { width:100%; height:100%; object-fit:cover; } .count-badge { background:var(--color-secondary); border-radius:12px; padding:0.15rem 0.6rem; font-size:0.78rem; color:var(--color-muted); margin-left:0.25rem; display:inline-block; } + + /* Modal */ + .modal-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:200; align-items:center; justify-content:center; } + .modal-overlay.open { display:flex; } + .modal { background:var(--color-card); border-radius:12px; width:min(520px,95vw); max-height:90vh; overflow-y:auto; padding:1.5rem; } + .modal h3 { margin:0 0 1rem; } + .modal-footer { display:flex; gap:0.75rem; justify-content:flex-end; margin-top:1.25rem; flex-wrap:wrap; } + .img-preview { width:80px; height:80px; border-radius:8px; object-fit:cover; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:1.5rem; flex-shrink:0; overflow:hidden; border:1px solid var(--color-secondary); } + .img-preview img { width:100%; height:100%; object-fit:cover; } + .img-row { display:flex; gap:0.75rem; align-items:center; margin-bottom:0.5rem; } - -
- ← Veranstaltungen + -
-

Wird geladen…

+ + + + + + + + + +
+
+ ← Veranstaltungen +
+

Wird geladen…

+
- + + @@ -178,6 +182,8 @@ if (user) { document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!'; loadVisitors(); + loadMyEvents(); + loadLocEvents(); if (user.datingAktiv) { loadWhoLikesMe(); loadMatches(); @@ -257,6 +263,47 @@ return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } + function renderEventCards(events, listId, sectionId) { + if (!events.length) return; + const list = document.getElementById(listId); + list.innerHTML = events.map(e => { + const thumb = e.imageData + ? `` + : '🗓'; + const date = new Date(e.startAt); + const dateStr = date.toLocaleDateString('de-DE', { weekday:'short', day:'numeric', month:'short' }) + + ', ' + date.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr'; + return ` + +
${thumb}
+
+
${esc(e.locationName)}
+
${esc(e.title)}
+
${dateStr}
+
+
`; + }).join(''); + document.getElementById(sectionId).style.display = ''; + } + + async function loadMyEvents() { + try { + const res = await fetch('/location-events/attending-next'); + if (!res.ok) return; + const events = await res.json(); + renderEventCards(events, 'myEventsList', 'myEventsSection'); + } catch (_) {} + } + + async function loadLocEvents() { + try { + const res = await fetch('/location-events/followed-next'); + if (!res.ok) return; + const events = await res.json(); + renderEventCards(events, 'locEventsList', 'locEventsSection'); + } catch (_) {} + } + async function loadVisitors() { try { const res = await fetch('/social/profile-visits/my-visitors');