Erweiterungen

This commit is contained in:
2025-10-14 19:28:20 +02:00
parent 4440825024
commit b391dbec8d
9 changed files with 354 additions and 62 deletions

View File

@@ -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<String, String> userClientIdMapping = new ConcurrentHashMap<>();
// Map to store clientId -> userId mapping for active sessions (reverse lookup)
private final Map<String, String> 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) {
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<String, Object> 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);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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()) {

View File

@@ -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

View File

@@ -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,11 +98,13 @@ 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();
@@ -226,6 +231,38 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
// 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() {
@@ -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);

View File

@@ -21,6 +21,6 @@ public interface AppUserRepository extends MongoRepository<AppUser, ObjectId> {
// Find AppUser by appCode for task completion notifications
java.util.Optional<AppUser> findByAppCode(String appCode);
// Custom query methods can be added here if needed
// List<AppUser> findByBezeichnung(String bezeichnung);
// Find AppUser by bezeichnung
AppUser findByBezeichnung(String bezeichnung);
}

View File

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