From b391dbec8dbb111838e772c16da9bae98587d301 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Tue, 14 Oct 2025 19:28:20 +0200 Subject: [PATCH] Erweiterungen --- .../controller/MessageController.java | 90 ++++++++++++-- .../event/MessageReadStatusChangedEvent.java | 15 +++ .../votianlt/mqtt/MqttV5ClientManager.java | 9 +- .../pages/base/ui/view/MainLayout.java | 93 +++++++++++--- .../pages/service/AppUserService.java | 8 ++ .../votianlt/pages/service/UserService.java | 5 + .../pages/view/MessageDetailsView.java | 115 ++++++++++++------ .../repository/AppUserRepository.java | 4 +- .../service/MessageBadgeUpdateService.java | 77 ++++++++++++ 9 files changed, 354 insertions(+), 62 deletions(-) create mode 100644 src/main/java/de/assecutor/votianlt/event/MessageReadStatusChangedEvent.java create mode 100644 src/main/java/de/assecutor/votianlt/service/MessageBadgeUpdateService.java diff --git a/src/main/java/de/assecutor/votianlt/controller/MessageController.java b/src/main/java/de/assecutor/votianlt/controller/MessageController.java index 02fca94..e402510 100644 --- a/src/main/java/de/assecutor/votianlt/controller/MessageController.java +++ b/src/main/java/de/assecutor/votianlt/controller/MessageController.java @@ -9,6 +9,7 @@ import de.assecutor.votianlt.model.CargoItem; import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.task.BaseTask; import de.assecutor.votianlt.pages.service.AppUserService; +import de.assecutor.votianlt.pages.service.UserService; import de.assecutor.votianlt.repository.AppUserRepository; import de.assecutor.votianlt.repository.CargoItemRepository; import de.assecutor.votianlt.repository.JobRepository; @@ -49,6 +50,9 @@ public class MessageController { // Map to store userId -> clientId mapping for active sessions private final Map userClientIdMapping = new ConcurrentHashMap<>(); + // Map to store clientId -> userId mapping for active sessions (reverse lookup) + private final Map clientIdUserMapping = new ConcurrentHashMap<>(); + private final MqttPublisher mqttPublisher; private final AppUserRepository appUserRepository; @@ -67,12 +71,13 @@ public class MessageController { private final JobHistoryService jobHistoryService; private final EmailService emailService; private final MessageService messageService; + private final UserService userService; public MessageController(MqttPublisher mqttPublisher, AppUserRepository appUserRepository, AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository, TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository, - SignatureRepository signatureRepository, CommentRepository commentRepository, JobHistoryService jobHistoryService, - EmailService emailService, MessageService messageService) { + SignatureRepository signatureRepository, CommentRepository commentRepository, JobHistoryService jobHistoryService, + EmailService emailService, MessageService messageService, UserService userService) { this.mqttPublisher = mqttPublisher; this.appUserRepository = appUserRepository; this.appUserService = appUserService; @@ -86,6 +91,7 @@ public class MessageController { this.jobHistoryService = jobHistoryService; this.emailService = emailService; this.messageService = messageService; + this.userService = userService; } /** @@ -585,7 +591,8 @@ public class MessageController { */ private void storeClientIdMapping(String userId, String clientId) { userClientIdMapping.put(userId, clientId); - log.debug("Stored clientId mapping: userId={} -> clientId={}", userId, clientId); + clientIdUserMapping.put(clientId, userId); + log.debug("Stored clientId mapping: userId={} <-> clientId={}", userId, clientId); } /** @@ -595,6 +602,13 @@ public class MessageController { return userClientIdMapping.get(userId); } + /** + * Get the userId (AppUser ID) for a given clientId + */ + private String getUserIdForClientId(String clientId) { + return clientIdUserMapping.get(clientId); + } + /** * Handle incoming message from a client via MQTT. * Client sends to /server/{clientId}/message with payload: @@ -604,21 +618,83 @@ public class MessageController { * "content": "message payload", * "contentType": "TEXT|IMAGE", * "jobId": "optional job id", - * "jobNumber": "optional job number" + * "jobNumber": "optional job number", + * "clientId": "extracted from topic" * } + * + * Logic: + * 1. Extract clientId from topic (this is the AppUser ID) + * 2. Find AppUser by ID in database + * 3. Get owner (User) from AppUser.owner field + * 4. Set receiver = User ID, sender = AppUser ID */ public void handleIncomingMessage(Map payload) { log.info("MQTT Endpoint '/server/{clientId}/message' called with data: {}", payload); try { ChatMessageInboundPayload inboundPayload = ChatMessageInboundPayload.fromPayload(payload); - messageService.receiveMessageFromClient(inboundPayload); - log.info("Successfully saved incoming message from {} to {}", inboundPayload.sender(), - inboundPayload.receiver()); + + // Extract clientId from payload (added by MqttV5ClientManager from topic) + // The clientId IS the AppUser ID + String clientId = payload.get("clientId") != null ? payload.get("clientId").toString() : null; + + if (clientId == null || clientId.isBlank()) { + log.warn("No clientId found in message payload, cannot process message"); + return; + } + + // Convert clientId (AppUser ID) to ObjectId + ObjectId appUserObjectId; + try { + appUserObjectId = new ObjectId(clientId); + } catch (IllegalArgumentException e) { + log.warn("Invalid clientId/AppUser ID '{}': {}", clientId, e.getMessage()); + return; + } + + // Find AppUser by ID + AppUser appUser = appUserService.findById(appUserObjectId); + if (appUser == null) { + log.warn("AppUser not found for clientId '{}'", clientId); + return; + } + + // Get owner (User) of AppUser from the owner field + ObjectId ownerId = appUser.getOwner(); + if (ownerId == null) { + log.warn("AppUser '{}' has no owner, cannot determine receiver", clientId); + return; + } + + // Verify that owner exists + de.assecutor.votianlt.model.User owner = userService.findById(ownerId); + if (owner == null) { + log.warn("Owner User not found for AppUser '{}'", clientId); + return; + } + + // Convert owner ID to string for receiver field + String ownerIdString = ownerId.toHexString(); + + // Create payload with: + // - sender = AppUser ID (clientId) + // - receiver = User ID (owner's ID as string) + ChatMessageInboundPayload resolvedPayload = new ChatMessageInboundPayload( + clientId, // sender = AppUser ID + ownerIdString, // receiver = User ID + inboundPayload.content(), + inboundPayload.contentType(), + inboundPayload.jobId(), + inboundPayload.jobNumber() + ); + + messageService.receiveMessageFromClient(resolvedPayload); + log.info("Successfully saved incoming message from AppUser '{}' to User '{}'", clientId, ownerIdString); } catch (IllegalArgumentException validationError) { log.warn("Incoming chat message rejected: {}", validationError.getMessage()); } catch (Exception e) { log.error("Error handling incoming message: {}", e.getMessage(), e); } } + } diff --git a/src/main/java/de/assecutor/votianlt/event/MessageReadStatusChangedEvent.java b/src/main/java/de/assecutor/votianlt/event/MessageReadStatusChangedEvent.java new file mode 100644 index 0000000..760f87d --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/event/MessageReadStatusChangedEvent.java @@ -0,0 +1,15 @@ +package de.assecutor.votianlt.event; + +import org.springframework.context.ApplicationEvent; + +/** + * Event published when message read status changes (e.g., messages marked as read) + * This allows UI components like the sidebar badge to update accordingly + */ +public class MessageReadStatusChangedEvent extends ApplicationEvent { + + public MessageReadStatusChangedEvent(Object source) { + super(source); + } +} + diff --git a/src/main/java/de/assecutor/votianlt/mqtt/MqttV5ClientManager.java b/src/main/java/de/assecutor/votianlt/mqtt/MqttV5ClientManager.java index 8826cbd..e55d0c1 100644 --- a/src/main/java/de/assecutor/votianlt/mqtt/MqttV5ClientManager.java +++ b/src/main/java/de/assecutor/votianlt/mqtt/MqttV5ClientManager.java @@ -165,7 +165,14 @@ public class MqttV5ClientManager implements SmartLifecycle { messageController.handleAppLogin(req); } else if (topic.matches("/server/.+/message")) { try { - // Handle incoming message from client + // Extract clientId from topic: /server/{clientId}/message + String[] parts = topic.split("/"); + String clientId = parts.length > 2 ? parts[2] : null; + if (clientId != null && !clientId.isBlank()) { + payload.put("clientId", clientId); + } else { + log.warn("Couldn't extract clientId from topic {} for message", topic); + } messageController.handleIncomingMessage(payload); } catch (Exception e) { log.error("Error handling incoming message on {}: {}", topic, e.getMessage(), e); diff --git a/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java b/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java index 5dd1ca9..f38b1b6 100644 --- a/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java +++ b/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java @@ -1,6 +1,8 @@ package de.assecutor.votianlt.pages.base.ui.view; +import com.vaadin.flow.component.AttachEvent; import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.DetachEvent; import com.vaadin.flow.component.UI; import com.vaadin.flow.component.applayout.AppLayout; import com.vaadin.flow.component.avatar.Avatar; @@ -20,12 +22,15 @@ import com.vaadin.flow.router.Layout; import com.vaadin.flow.server.auth.AnonymousAllowed; import com.vaadin.flow.server.menu.MenuConfiguration; import com.vaadin.flow.server.menu.MenuEntry; +import com.vaadin.flow.shared.Registration; import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.UserInvoiceData; import de.assecutor.votianlt.pages.service.UserInvoiceDataService; import de.assecutor.votianlt.pages.view.EditProfileView; import de.assecutor.votianlt.security.SecurityService; +import de.assecutor.votianlt.service.MessageBadgeUpdateService; import de.assecutor.votianlt.service.MessageService; +import lombok.extern.slf4j.Slf4j; import static com.vaadin.flow.theme.lumo.LumoUtility.*; @@ -34,21 +39,27 @@ import java.util.Optional; import java.util.Set; @AnonymousAllowed - +@Slf4j @Layout("main") public final class MainLayout extends AppLayout { private final SecurityService securityService; private final UserInvoiceDataService userInvoiceDataService; private final MessageService messageService; + private final MessageBadgeUpdateService messageBadgeUpdateService; private Div headerRef; private Scroller navRef; private Component userMenuRef; + private Span messagesBadge; // Reference to the messages badge for dynamic updates + private SideNavItem messagesNavItem; // Reference to the messages nav item + private Registration badgeUpdateRegistration; // Track badge update listener registration - public MainLayout(SecurityService securityService, UserInvoiceDataService userInvoiceDataService, MessageService messageService) { + public MainLayout(SecurityService securityService, UserInvoiceDataService userInvoiceDataService, + MessageService messageService, MessageBadgeUpdateService messageBadgeUpdateService) { this.securityService = securityService; this.userInvoiceDataService = userInvoiceDataService; this.messageService = messageService; + this.messageBadgeUpdateService = messageBadgeUpdateService; setPrimarySection(Section.DRAWER); // Always build the drawer; keep references and toggle visibility on attach and @@ -162,24 +173,44 @@ public final class MainLayout extends AppLayout { item = new SideNavItem(menuEntry.title(), menuEntry.path()); } if ("Nachrichten".equals(menuEntry.title())) { - long unreadCount = resolveUnreadMessageCount(); - if (unreadCount > 0) { - Span badge = new Span(String.valueOf(unreadCount)); - badge.getElement().getThemeList().add("badge"); - badge.getStyle().set("background-color", "var(--lumo-primary-color)"); - badge.getStyle().set("color", "#ffffff"); - badge.getStyle().set("border-radius", "12px"); - badge.getStyle().set("padding", "2px 8px"); - badge.getStyle().set("font-size", "12px"); - badge.getStyle().set("font-weight", "bold"); - badge.getStyle().set("min-width", "20px"); - badge.getStyle().set("text-align", "center"); + messagesNavItem = item; + } + return item; + } - item.setSuffixComponent(badge); + /** + * Updates the messages badge with the current unread count + */ + private void updateMessagesBadge() { + if (messagesNavItem == null) { + return; + } + + long unreadCount = resolveUnreadMessageCount(); + + if (unreadCount > 0) { + if (messagesBadge == null) { + messagesBadge = new Span(String.valueOf(unreadCount)); + messagesBadge.getElement().getThemeList().add("badge"); + messagesBadge.getStyle().set("background-color", "var(--lumo-primary-color)"); + messagesBadge.getStyle().set("color", "#ffffff"); + messagesBadge.getStyle().set("border-radius", "12px"); + messagesBadge.getStyle().set("padding", "2px 8px"); + messagesBadge.getStyle().set("font-size", "12px"); + messagesBadge.getStyle().set("font-weight", "bold"); + messagesBadge.getStyle().set("min-width", "20px"); + messagesBadge.getStyle().set("text-align", "center"); + messagesNavItem.setSuffixComponent(messagesBadge); + } else { + messagesBadge.setText(String.valueOf(unreadCount)); + messagesBadge.setVisible(true); + } + } else { + if (messagesBadge != null) { + messagesNavItem.setSuffixComponent(null); + messagesBadge = null; } } - - return item; } private long resolveUnreadMessageCount() { @@ -191,6 +222,7 @@ public final class MainLayout extends AppLayout { try { User currentUser = securityService.getCurrentDatabaseUser(); + if (currentUser != null) { String email = Optional.ofNullable(currentUser.getEmail()).map(String::trim).orElse(""); if (!email.isBlank()) { @@ -217,6 +249,7 @@ public final class MainLayout extends AppLayout { for (String receiver : candidateReceivers) { unread += messageService.getUnreadMessageCount(receiver); } + return unread; } @@ -269,4 +302,28 @@ public final class MainLayout extends AppLayout { return false; } -} + @Override + protected void onAttach(AttachEvent attachEvent) { + super.onAttach(attachEvent); + UI ui = attachEvent.getUI(); + + // Update badge immediately when layout is attached + updateMessagesBadge(); + + // Register listener for badge updates + badgeUpdateRegistration = messageBadgeUpdateService.register(() -> { + ui.access(() -> { + updateMessagesBadge(); + }); + }); + } + + @Override + protected void onDetach(DetachEvent detachEvent) { + if (badgeUpdateRegistration != null) { + badgeUpdateRegistration.remove(); + badgeUpdateRegistration = null; + } + super.onDetach(detachEvent); + } +} \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/pages/service/AppUserService.java b/src/main/java/de/assecutor/votianlt/pages/service/AppUserService.java index f5fee57..138a77c 100644 --- a/src/main/java/de/assecutor/votianlt/pages/service/AppUserService.java +++ b/src/main/java/de/assecutor/votianlt/pages/service/AppUserService.java @@ -59,6 +59,14 @@ public class AppUserService { return appUserRepository.findById(id).orElse(null); } + public AppUser findByEmail(String email) { + return appUserRepository.findByEmail(email); + } + + public AppUser findByBezeichnung(String bezeichnung) { + return appUserRepository.findByBezeichnung(bezeichnung); + } + public AppUser updateAppUser(AppUser appUser) { // Hash the password if it's being updated and not empty if (appUser.getPassword() != null && !appUser.getPassword().isEmpty()) { diff --git a/src/main/java/de/assecutor/votianlt/pages/service/UserService.java b/src/main/java/de/assecutor/votianlt/pages/service/UserService.java index 8120e2f..c4e8da3 100644 --- a/src/main/java/de/assecutor/votianlt/pages/service/UserService.java +++ b/src/main/java/de/assecutor/votianlt/pages/service/UserService.java @@ -2,6 +2,7 @@ package de.assecutor.votianlt.pages.service; import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.repository.UserRepository; +import org.bson.types.ObjectId; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -22,6 +23,10 @@ public class UserService { return userRepository.findByEmail(email).orElse(null); } + public User findById(ObjectId id) { + return userRepository.findById(id).orElse(null); + } + public User save(User user) { if (user.getPassword() != null && !user.getPassword().startsWith("$2a$")) { // Passwort verschlüsseln, falls noch nicht verschlüsselt diff --git a/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java b/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java index 68bc9a3..923d751 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java @@ -38,6 +38,8 @@ import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.service.MessageBroadcaster; import de.assecutor.votianlt.service.MessageService; import de.assecutor.votianlt.security.SecurityService; +import de.assecutor.votianlt.event.MessageReadStatusChangedEvent; +import org.springframework.context.ApplicationEventPublisher; import jakarta.annotation.security.RolesAllowed; import lombok.extern.slf4j.Slf4j; import org.bson.types.ObjectId; @@ -75,6 +77,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { private final MessageService messageService; private final SecurityService securityService; private final MessageBroadcaster messageBroadcaster; + private final ApplicationEventPublisher eventPublisher; private String participantKey; private String conversationId; @@ -95,15 +98,17 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { private static final float JPEG_COMPRESSION_QUALITY = 0.8f; public MessageDetailsView(AppUserService appUserService, MessageService messageService, - SecurityService securityService, MessageBroadcaster messageBroadcaster) { + SecurityService securityService, MessageBroadcaster messageBroadcaster, + ApplicationEventPublisher eventPublisher) { this.appUserService = appUserService; this.messageService = messageService; this.securityService = securityService; this.messageBroadcaster = messageBroadcaster; - + this.eventPublisher = eventPublisher; + // Set height to 100% to prevent page from growing beyond viewport setHeightFull(); - + // Create main layout with fixed positioning contentLayout = new VerticalLayout(); contentLayout.setPadding(true); @@ -119,7 +124,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { public void beforeEnter(BeforeEnterEvent event) { // Extract route parameters from URL RouteParameters parameters = event.getRouteParameters(); - + this.participantKey = parameters.get("clientId").orElse(null); this.conversationId = parameters.get("conversationId").orElse(null); @@ -164,10 +169,10 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { // Store current messages for rendering and future updates this.currentMessages = filteredMessages; - + // Reset scroll anchor for new container scrollAnchor = null; - + // Create messages container messagesContainer = new VerticalLayout(); messagesContainer.setPadding(false); @@ -190,10 +195,10 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { HorizontalLayout inputLayout = createMessageInputArea(); contentLayout.add(inputLayout); - + // Render messages using Vaadin components renderMessages(); - + // Ensure scroll anchor exists and scroll to bottom ensureScrollAnchor(); scrollToBottom(); @@ -210,22 +215,54 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { // Reset anchor so a fresh one can be attached after re-rendering scrollAnchor = null; messagesContainer.removeAll(); - + LocalDate currentDate = null; - + for (Message message : currentMessages) { LocalDateTime timestamp = resolveTimestamp(message); LocalDate messageDate = timestamp.toLocalDate(); - + // Add date separator if date changed if (currentDate == null || !currentDate.equals(messageDate)) { messagesContainer.add(createDateSeparator(messageDate)); currentDate = messageDate; } - + // Add message bubble messagesContainer.add(createMessageBubble(message, timestamp)); } + + // After rendering, mark any unread messages directed to the current user as read + markVisibleMessagesAsRead(); + } + + /** + * Marks all currently visible messages that are addressed to the logged-in user as read. + * This is triggered after (re)rendering the conversation and will also update the in-memory + * message objects to keep UI state consistent. + */ + private void markVisibleMessagesAsRead() { + try { + if (currentMessages == null || currentMessages.isEmpty()) { + return; + } + boolean anyMarked = false; + for (Message msg : currentMessages) { + if (!msg.isRead() && msg.getId() != null) { + // Update persistence + messageService.markAsRead(msg.getId()); + // Update in-memory object so UI reflects read state immediately + msg.markAsRead(); + anyMarked = true; + } + } + // Publish event to update badge in sidebar + if (anyMarked) { + eventPublisher.publishEvent(new MessageReadStatusChangedEvent(this)); + } + } catch (Exception e) { + log.warn("Failed to mark messages as read: {}", e.getMessage()); + } } private void openImageUploadDialog() { @@ -372,7 +409,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { .set("text-align", "center") .set("margin", "20px 0"); separator.setWidthFull(); - + Span dateLabel = new Span(date.format(DATE_FORMATTER)); dateLabel.getStyle() .set("background-color", "#d0d0d0") @@ -382,7 +419,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { .set("font-weight", "500") .set("color", "#333333") .set("display", "inline-block"); - + separator.add(dateLabel); return separator; } @@ -394,7 +431,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { // Determine alignment based on message origin // CLIENT origin = client messages (left), SERVER origin = server messages (right) boolean isServerMessage = message.getOrigin() == MessageOrigin.SERVER; - + // Container for the message (aligns left or right) Div messageWrapper = new Div(); String alignment = isServerMessage ? "right" : "left"; @@ -404,7 +441,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { .set("justify-content", isServerMessage ? "flex-end" : "flex-start") .set("margin", "5px 0") .set("width", "100%"); - + // Message bubble Div bubble = new Div(); bubble.getStyle() @@ -416,10 +453,10 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { .set("word-wrap", "break-word") .set("white-space", "pre-wrap") .set("text-align", alignment); - + // Message content component (text or media) Component contentComponent = createContentComponent(message, alignment); - + // Timestamp Span timeSpan = new Span(timestamp.format(TIME_FORMATTER)); timeSpan.getStyle() @@ -427,10 +464,10 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { .set("color", isServerMessage ? "#666666" : "#999999") .set("display", "block") .set("text-align", alignment); - + bubble.add(contentComponent, timeSpan); messageWrapper.add(bubble); - + return messageWrapper; } @@ -692,25 +729,25 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { private HorizontalLayout createHeaderLayout(String clientName, String conversationTitle) { Button backButton = new Button("Zurück", VaadinIcon.ARROW_LEFT.create()); backButton.addClickListener(e -> UI.getCurrent().navigate("user-messages/" + participantKey)); - + VerticalLayout titleLayout = new VerticalLayout(); titleLayout.setPadding(false); titleLayout.setSpacing(false); - + H2 title = new H2(clientName); title.getStyle().set("margin", "0"); - + Span subtitle = new Span(conversationTitle); subtitle.getStyle().set("color", "#666666"); subtitle.getStyle().set("font-size", "14px"); - + titleLayout.add(title, subtitle); - + HorizontalLayout layout = new HorizontalLayout(backButton, titleLayout); layout.setWidthFull(); layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER); layout.setSpacing(true); - + return layout; } @@ -778,6 +815,16 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { contentType); } + // Mark own outgoing message as read immediately + if (saved != null && saved.getId() != null) { + try { + messageService.markAsRead(saved.getId()); + saved.markAsRead(); // keep UI state consistent + } catch (Exception ignore) { + // non-fatal + } + } + Notification.show("Nachricht gesendet", 2000, Notification.Position.BOTTOM_END) .addThemeVariants(NotificationVariant.LUMO_SUCCESS); @@ -939,12 +986,12 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { protected void onAttach(AttachEvent attachEvent) { super.onAttach(attachEvent); UI ui = attachEvent.getUI(); - + // Register listener for incoming messages broadcasterRegistration = messageBroadcaster.register(message -> { handleIncomingMessage(ui, message); }); - + log.info("MessageDetailsView attached and listener registered for conversation: {}", conversationId); } @@ -972,9 +1019,9 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { } // Check if message involves the current participant - boolean involvesParticipant = participantKey.equals(message.getSender()) + boolean involvesParticipant = participantKey.equals(message.getSender()) || participantKey.equals(message.getReceiver()); - + if (!involvesParticipant) { log.debug("Message does not involve current participant, ignoring"); return; @@ -982,7 +1029,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { // Check if message belongs to the current conversation boolean belongsToConversation = false; - + if ("general".equalsIgnoreCase(conversationId)) { // General conversation: messages without job context belongsToConversation = message.getJobId() == null && message.getJobNumber() == null; @@ -998,7 +1045,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { } log.info("New message belongs to current conversation {}, updating UI", conversationId); - + // Update UI in a thread-safe manner using UI.access() ui.access(() -> { try { @@ -1010,11 +1057,11 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { // Re-render all messages with the new message included renderMessages(); - + // Ensure scroll anchor exists and scroll to show new message ensureScrollAnchor(); scrollToBottom(); - + log.info("Messages re-rendered with new message"); } } catch (Exception e) { diff --git a/src/main/java/de/assecutor/votianlt/repository/AppUserRepository.java b/src/main/java/de/assecutor/votianlt/repository/AppUserRepository.java index 270486e..6f96862 100644 --- a/src/main/java/de/assecutor/votianlt/repository/AppUserRepository.java +++ b/src/main/java/de/assecutor/votianlt/repository/AppUserRepository.java @@ -21,6 +21,6 @@ public interface AppUserRepository extends MongoRepository { // Find AppUser by appCode for task completion notifications java.util.Optional findByAppCode(String appCode); - // Custom query methods can be added here if needed - // List findByBezeichnung(String bezeichnung); + // Find AppUser by bezeichnung + AppUser findByBezeichnung(String bezeichnung); } diff --git a/src/main/java/de/assecutor/votianlt/service/MessageBadgeUpdateService.java b/src/main/java/de/assecutor/votianlt/service/MessageBadgeUpdateService.java new file mode 100644 index 0000000..8f69442 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/service/MessageBadgeUpdateService.java @@ -0,0 +1,77 @@ +package de.assecutor.votianlt.service; + +import com.vaadin.flow.shared.Registration; +import de.assecutor.votianlt.event.MessageReadStatusChangedEvent; +import de.assecutor.votianlt.event.MessageReceivedEvent; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashSet; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** + * Service that listens for message-related events and notifies registered UI components + * to update their message badges (e.g., in the sidebar navigation) + */ +@Service +@Slf4j +public class MessageBadgeUpdateService { + + private final Executor executor = Executors.newSingleThreadExecutor(); + private final LinkedHashSet listeners = new LinkedHashSet<>(); + + /** + * Register a listener that will be called when message badge should be updated + * + * @param listener Runnable that will be called when badge update is needed + * @return Registration object that can be used to unregister the listener + */ + public synchronized Registration register(Runnable listener) { + listeners.add(listener); + log.debug("Registered badge update listener. Total listeners: {}", listeners.size()); + + return () -> { + synchronized (MessageBadgeUpdateService.this) { + listeners.remove(listener); + log.debug("Unregistered badge update listener. Total listeners: {}", listeners.size()); + } + }; + } + + /** + * Notify all registered listeners that badge should be updated + */ + private synchronized void notifyListeners() { + log.debug("Notifying {} badge update listeners", listeners.size()); + for (Runnable listener : listeners) { + executor.execute(() -> { + try { + listener.run(); + } catch (Exception e) { + log.error("Error notifying badge update listener", e); + } + }); + } + } + + /** + * Spring event listener for message read status changes + */ + @EventListener + public void onMessageReadStatusChanged(MessageReadStatusChangedEvent event) { + log.debug("MessageBadgeUpdateService received MessageReadStatusChangedEvent"); + notifyListeners(); + } + + /** + * Spring event listener for new messages received + */ + @EventListener + public void onMessageReceived(MessageReceivedEvent event) { + log.debug("MessageBadgeUpdateService received MessageReceivedEvent"); + notifyListeners(); + } +} +