diff --git a/MQTT_README.md b/MQTT_README.md index 091c6b2..05afe2c 100644 --- a/MQTT_README.md +++ b/MQTT_README.md @@ -133,11 +133,34 @@ Payload: - User notifications: v1/users//notifications Payload example: { - "type": "broadcast|notification", + "type": "broadcast|notification", "message": "...", "timestamp": "2025-09-13T22:10:00" } +## Chat Messaging (App ↔ Server) + +Mobile apps exchange chat messages with the backend through dedicated topics. JSON samples can be +found under `src/main/resources/mqtt/chat`. + +### App → Server +- **Topic:** `/server/{clientId}/message` +- **Payload example:** `src/main/resources/mqtt/chat/incoming-chat-message.json` +- **Required fields:** `sender`, `receiver`, `content` +- **Optional fields:** `jobId` (Mongo ObjectId), `jobNumber` +- Payloads missing required fields or containing invalid `jobId` values are rejected with a warning log. + +### Server → App +- **Topic:** `/client/{receiver}/message` +- **Payload example:** `src/main/resources/mqtt/chat/outgoing-chat-message.json` +- **Notes:** `direction` (INCOMING/OUTGOING) and `messageType` (GENERAL/JOB_RELATED) mirror the + persisted message entity. `read` remains `false` until the receiver acknowledges the message via the + REST API. + +### Quality of Service +- Chat topics inherit the global default QoS 2 (`app.mqtt.default-qos`). +- Messages are not retained; offline clients rely on QoS queueing on the broker. + Quality of Service & Retain - QoS 2 (exactly once) is used by default server side for both inbound subscriptions and outbound publications. - Retained messages are disabled by default to avoid stale updates. diff --git a/src/main/java/de/assecutor/votianlt/controller/MessageController.java b/src/main/java/de/assecutor/votianlt/controller/MessageController.java index 80928f8..ec0318f 100644 --- a/src/main/java/de/assecutor/votianlt/controller/MessageController.java +++ b/src/main/java/de/assecutor/votianlt/controller/MessageController.java @@ -2,6 +2,7 @@ package de.assecutor.votianlt.controller; import de.assecutor.votianlt.dto.AppLoginRequest; import de.assecutor.votianlt.dto.AppLoginResponse; +import de.assecutor.votianlt.dto.ChatMessageInboundPayload; import de.assecutor.votianlt.dto.JobWithRelatedDataDTO; import de.assecutor.votianlt.model.AppUser; import de.assecutor.votianlt.model.CargoItem; @@ -609,45 +610,14 @@ public class MessageController { log.info("MQTT Endpoint '/server/{clientId}/message' called with data: {}", payload); try { - // Extract required fields - String sender = payload.get("sender") != null ? payload.get("sender").toString() : null; - String receiver = payload.get("receiver") != null ? payload.get("receiver").toString() : null; - String content = payload.get("content") != null ? payload.get("content").toString() : null; - - // Validate required fields - if (sender == null || sender.isBlank() || - receiver == null || receiver.isBlank() || - content == null || content.isBlank()) { - log.warn("Incoming message missing required fields: sender={}, receiver={}, content={}", - sender, receiver, content != null ? "present" : "null"); - return; - } - - // Extract optional job-related fields - ObjectId jobId = null; - String jobNumber = null; - - if (payload.get("jobId") != null) { - try { - String jobIdStr = payload.get("jobId").toString(); - if (!jobIdStr.isBlank()) { - jobId = new ObjectId(jobIdStr); - } - } catch (IllegalArgumentException e) { - log.warn("Invalid jobId format in message: {}", payload.get("jobId")); - } - } - - if (payload.get("jobNumber") != null) { - jobNumber = payload.get("jobNumber").toString(); - } - - // Save the message using MessageService - messageService.receiveMessageFromClient(content, sender, receiver, jobId, jobNumber); - log.info("Successfully saved incoming message from {} to {}", sender, receiver); - + ChatMessageInboundPayload inboundPayload = ChatMessageInboundPayload.fromPayload(payload); + messageService.receiveMessageFromClient(inboundPayload); + log.info("Successfully saved incoming message from {} to {}", inboundPayload.sender(), + inboundPayload.receiver()); + } catch (IllegalArgumentException validationError) { + log.warn("Incoming chat message rejected: {}", validationError.getMessage()); } catch (Exception e) { log.error("Error handling incoming message: {}", e.getMessage(), e); } } -} \ No newline at end of file +} diff --git a/src/main/java/de/assecutor/votianlt/dto/ChatMessageInboundPayload.java b/src/main/java/de/assecutor/votianlt/dto/ChatMessageInboundPayload.java new file mode 100644 index 0000000..6a8e207 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/dto/ChatMessageInboundPayload.java @@ -0,0 +1,59 @@ +package de.assecutor.votianlt.dto; + +import java.util.Map; + +import org.bson.types.ObjectId; + +/** + * Normalized payload for chat messages sent by mobile clients via MQTT. + */ +public record ChatMessageInboundPayload(String sender, String receiver, String content, ObjectId jobId, String jobNumber) { + + public static ChatMessageInboundPayload fromPayload(Map payload) { + if (payload == null) { + throw new IllegalArgumentException("payload must not be null"); + } + + String sender = extractRequiredString(payload, "sender"); + String receiver = extractRequiredString(payload, "receiver"); + String content = extractRequiredString(payload, "content"); + ObjectId jobId = extractObjectId(payload.get("jobId"), "jobId"); + String jobNumber = extractOptionalString(payload.get("jobNumber")); + + return new ChatMessageInboundPayload(sender, receiver, content, jobId, jobNumber); + } + + public boolean hasJobContext() { + return jobId != null; + } + + private static String extractRequiredString(Map payload, String key) { + Object value = payload.get(key); + String asString = value != null ? value.toString().trim() : null; + if (asString == null || asString.isEmpty()) { + throw new IllegalArgumentException("Missing required field '%s'".formatted(key)); + } + return asString; + } + + private static String extractOptionalString(Object value) { + if (value == null) { + return null; + } + String asString = value.toString().trim(); + return asString.isEmpty() ? null : asString; + } + + private static ObjectId extractObjectId(Object value, String fieldName) { + String candidate = extractOptionalString(value); + if (candidate == null) { + return null; + } + try { + return new ObjectId(candidate); + } catch (IllegalArgumentException ex) { + throw new IllegalArgumentException( + "Field '%s' must be a valid MongoDB ObjectId".formatted(fieldName), ex); + } + } +} diff --git a/src/main/java/de/assecutor/votianlt/dto/ChatMessageOutboundPayload.java b/src/main/java/de/assecutor/votianlt/dto/ChatMessageOutboundPayload.java new file mode 100644 index 0000000..da344c6 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/dto/ChatMessageOutboundPayload.java @@ -0,0 +1,38 @@ +package de.assecutor.votianlt.dto; + +import de.assecutor.votianlt.model.Message; +import de.assecutor.votianlt.model.MessageDirection; +import de.assecutor.votianlt.model.MessageType; +import java.time.LocalDateTime; + +/** + * Outbound chat message payload published to MQTT subscribers. + */ +public record ChatMessageOutboundPayload( + String messageId, + String sender, + String receiver, + String content, + MessageDirection direction, + MessageType messageType, + LocalDateTime createdAt, + String jobId, + String jobNumber, + boolean read +) { + + public static ChatMessageOutboundPayload fromMessage(Message message) { + return new ChatMessageOutboundPayload( + message.getIdAsString(), + message.getSender(), + message.getReceiver(), + message.getContent(), + message.getDirection(), + message.getMessageType(), + message.getCreatedAt(), + message.getJobIdAsString(), + message.getJobNumber(), + message.isRead() + ); + } +} diff --git a/src/main/java/de/assecutor/votianlt/mqtt/MqttV5ClientManager.java b/src/main/java/de/assecutor/votianlt/mqtt/MqttV5ClientManager.java index 34ac466..8826cbd 100644 --- a/src/main/java/de/assecutor/votianlt/mqtt/MqttV5ClientManager.java +++ b/src/main/java/de/assecutor/votianlt/mqtt/MqttV5ClientManager.java @@ -87,7 +87,7 @@ public class MqttV5ClientManager implements SmartLifecycle { // Subscribe to topics with QoS String[] topics = new String[] { "/server/+/task/photo/completed", "/server/+/task/confirm", "/server/+/task/completed", "/server/+/task_completed", "/server/+/job/status", - "/server/+/jobs/assigned", "/server/login" }; + "/server/+/jobs/assigned", "/server/+/message", "/server/login" }; MqttQos qos = mapQos(props.getDefaultQos()); for (String topic : topics) { client.subscribeWith().topicFilter(topic).qos(qos).send().join(); 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 8552ee8..5dd1ca9 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 @@ -29,6 +29,10 @@ import de.assecutor.votianlt.service.MessageService; import static com.vaadin.flow.theme.lumo.LumoUtility.*; +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; + @AnonymousAllowed @Layout("main") @@ -157,14 +161,9 @@ public final class MainLayout extends AppLayout { } else { item = new SideNavItem(menuEntry.title(), menuEntry.path()); } - - // Add badge for "Nachrichten" menu item showing unread message count if ("Nachrichten".equals(menuEntry.title())) { - try { - // Test: Show badge with 10 unread messages - long unreadCount = 10; - - // Create blue badge with white text (same color as UserMessagesView) + 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)"); @@ -175,16 +174,52 @@ public final class MainLayout extends AppLayout { badge.getStyle().set("font-weight", "bold"); badge.getStyle().set("min-width", "20px"); badge.getStyle().set("text-align", "center"); - + item.setSuffixComponent(badge); - } catch (Exception e) { - // If there's an error, just don't show the badge } } return item; } + private long resolveUnreadMessageCount() { + if (!securityService.isUserLoggedIn()) { + return 0; + } + + Set candidateReceivers = new LinkedHashSet<>(); + + try { + User currentUser = securityService.getCurrentDatabaseUser(); + if (currentUser != null) { + String email = Optional.ofNullable(currentUser.getEmail()).map(String::trim).orElse(""); + if (!email.isBlank()) { + candidateReceivers.add(email); + } + String fullName = ((Optional.ofNullable(currentUser.getFirstname()).orElse("") + " " + + Optional.ofNullable(currentUser.getName()).orElse(""))).trim(); + if (!fullName.isBlank()) { + candidateReceivers.add(fullName); + } + } + } catch (RuntimeException ignored) { + // Fallback to username only + } + + Optional.ofNullable(securityService.getCurrentUsername()).map(String::trim).filter(name -> !name.isBlank()) + .ifPresent(candidateReceivers::add); + + if (candidateReceivers.isEmpty()) { + return 0; + } + + long unread = 0; + for (String receiver : candidateReceivers) { + unread += messageService.getUnreadMessageCount(receiver); + } + return unread; + } + private Component createUserMenu() { var userMenu = new MenuBar(); userMenu.addThemeVariants(MenuBarVariant.LUMO_TERTIARY_INLINE); 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 e70469d..d75c407 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java @@ -10,6 +10,8 @@ import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.Scroller; import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.notification.NotificationVariant; import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.router.BeforeEnterEvent; import com.vaadin.flow.router.BeforeEnterObserver; @@ -17,13 +19,23 @@ import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; import com.vaadin.flow.router.RouteParameters; import de.assecutor.votianlt.model.AppUser; +import de.assecutor.votianlt.model.Message; +import de.assecutor.votianlt.model.MessageDirection; +import de.assecutor.votianlt.model.MessageType; import de.assecutor.votianlt.pages.service.AppUserService; +import de.assecutor.votianlt.service.MessageService; +import de.assecutor.votianlt.security.SecurityService; import jakarta.annotation.security.RolesAllowed; import lombok.extern.slf4j.Slf4j; import org.bson.types.ObjectId; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.Objects; @Route(value = "message-details/:clientId/:conversationId", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @PageTitle("Nachrichtenverlauf") @@ -32,9 +44,14 @@ import java.time.format.DateTimeFormatter; public class MessageDetailsView extends Main implements BeforeEnterObserver { private final AppUserService appUserService; - - private String clientId; + private final MessageService messageService; + private final SecurityService securityService; + + private String participantKey; private String conversationId; + private boolean jobConversation; + private ObjectId jobIdContext; + private String jobNumberContext; private VerticalLayout contentLayout; private VerticalLayout messagesContainer; private Scroller messagesScroller; @@ -42,8 +59,11 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy"); - public MessageDetailsView(AppUserService appUserService) { + public MessageDetailsView(AppUserService appUserService, MessageService messageService, + SecurityService securityService) { this.appUserService = appUserService; + this.messageService = messageService; + this.securityService = securityService; // Set height to 100% to prevent page from growing beyond viewport setHeightFull(); @@ -64,46 +84,46 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { // Extract route parameters from URL RouteParameters parameters = event.getRouteParameters(); - this.clientId = parameters.get("clientId").orElse(null); + this.participantKey = parameters.get("clientId").orElse(null); this.conversationId = parameters.get("conversationId").orElse(null); - - log.info("MessageDetailsView - clientId: {}, conversationId: {}", clientId, conversationId); - - if (clientId == null || conversationId == null) { - log.warn("Missing required route parameters: clientId={}, conversationId={}", clientId, conversationId); + + log.info("MessageDetailsView - participant: {}, conversationId: {}", participantKey, conversationId); + + if (participantKey == null || conversationId == null) { + log.warn("Missing required route parameters: participantKey={}, conversationId={}", participantKey, conversationId); event.rerouteToError(IllegalArgumentException.class, "Missing required parameters"); return; } - + loadMessageDetails(); } private void loadMessageDetails() { contentLayout.removeAll(); - - // Get client info - AppUser client = null; - try { - ObjectId objectId = new ObjectId(clientId); - client = appUserService.findById(objectId); - } catch (Exception e) { - log.warn("Could not find client with id: {}", clientId); + + AppUser client = resolveParticipant(); + String clientName = client != null + ? (Optional.ofNullable(client.getVorname()).orElse("") + " " + + Optional.ofNullable(client.getNachname()).orElse("")).trim() + : Optional.ofNullable(participantKey).orElse("Unbekannter Teilnehmer"); + if (clientName.isBlank()) { + clientName = Optional.ofNullable(client).map(AppUser::getBezeichnung).orElse("Unbekannter Teilnehmer"); } - - String clientName = client != null ? - client.getVorname() + " " + client.getNachname() : "Unbekannter Client"; - - // Determine conversation title - String conversationTitle = "Allgemeine Unterhaltung"; - if (conversationId != null && conversationId.startsWith("job-")) { - conversationTitle = "Auftrag #" + conversationId.substring(4); - } - - // Create header + + List allMessages = messageService.getMessagesForParticipantAscending(participantKey); + List filteredMessages = filterMessagesForConversation(allMessages, conversationId); + + this.jobConversation = conversationId != null && conversationId.startsWith("job-"); + this.jobIdContext = filteredMessages.stream().map(Message::getJobId).filter(Objects::nonNull).findFirst() + .orElse(null); + this.jobNumberContext = filteredMessages.stream().map(Message::getJobNumber) + .filter(value -> value != null && !value.isBlank()).findFirst().orElse(null); + + String conversationTitle = resolveConversationTitle(filteredMessages, conversationId); + HorizontalLayout headerLayout = createHeaderLayout(clientName, conversationTitle); contentLayout.add(headerLayout); - - // Create messages container (content for scrollable area) + messagesContainer = new VerticalLayout(); messagesContainer.setPadding(true); messagesContainer.setSpacing(true); @@ -111,26 +131,34 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { messagesContainer.getStyle().set("background-color", "#f0f0f0"); messagesContainer.getStyle().set("border-radius", "8px"); messagesContainer.getStyle().set("padding", "20px"); - - // Add test messages - loadTestMessages(); - - // Wrap messages container in Scroller for proper scrolling behavior + messagesContainer.setHeightFull(); + messagesContainer.getStyle().set("min-height", "100%"); + messagesContainer.getStyle().set("display", "flex"); + messagesContainer.getStyle().set("flex-direction", "column"); + messagesContainer.getStyle().set("flex", "1 1 auto"); + + if (!filteredMessages.isEmpty()) { + renderMessages(filteredMessages); + } else { + ensureScrollAnchor(); + } + messagesScroller = new Scroller(messagesContainer); messagesScroller.setWidthFull(); messagesScroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL); - + messagesScroller.setHeightFull(); + messagesScroller.getStyle().set("flex", "1 1 auto"); + contentLayout.add(messagesScroller); contentLayout.setFlexGrow(1, messagesScroller); - - // Add message input area + HorizontalLayout inputLayout = createMessageInputArea(); contentLayout.add(inputLayout); } 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/" + clientId)); + backButton.addClickListener(e -> UI.getCurrent().navigate("user-messages/" + participantKey)); VerticalLayout titleLayout = new VerticalLayout(); titleLayout.setPadding(false); @@ -153,69 +181,6 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { return layout; } - private void loadTestMessages() { - // Test data - message thread - LocalDateTime now = LocalDateTime.now(); - - // Day 1 - older messages - messagesContainer.add(createDateSeparator(now.minusDays(2))); - messagesContainer.add(createIncomingMessage( - "Hallo, ich habe eine Frage zum Auftrag.", - now.minusDays(2).withHour(10).withMinute(30) - )); - messagesContainer.add(createOutgoingMessage( - "Hallo! Gerne, wie kann ich Ihnen helfen?", - now.minusDays(2).withHour(10).withMinute(35) - )); - messagesContainer.add(createIncomingMessage( - "Wann wird die Lieferung voraussichtlich ankommen?", - now.minusDays(2).withHour(10).withMinute(40) - )); - messagesContainer.add(createOutgoingMessage( - "Die Lieferung ist für morgen zwischen 14:00 und 16:00 Uhr geplant.", - now.minusDays(2).withHour(10).withMinute(45) - )); - - // Day 2 - yesterday - messagesContainer.add(createDateSeparator(now.minusDays(1))); - messagesContainer.add(createIncomingMessage( - "Vielen Dank für die Information!", - now.minusDays(1).withHour(9).withMinute(15) - )); - messagesContainer.add(createOutgoingMessage( - "Gern geschehen! Melden Sie sich bei weiteren Fragen.", - now.minusDays(1).withHour(9).withMinute(20) - )); - - // Today - messagesContainer.add(createDateSeparator(now)); - messagesContainer.add(createIncomingMessage( - "Die Lieferung ist angekommen. Alles perfekt!", - now.minusHours(2).withMinute(0) - )); - messagesContainer.add(createOutgoingMessage( - "Das freut mich zu hören! Vielen Dank für die Rückmeldung.", - now.minusHours(1).withMinute(30) - )); - messagesContainer.add(createIncomingMessage( - "Können wir für nächste Woche einen neuen Auftrag vereinbaren?", - now.minusMinutes(30) - )); - messagesContainer.add(createOutgoingMessage( - "Selbstverständlich! Ich erstelle Ihnen gleich ein Angebot.", - now.minusMinutes(15) - )); - - // Add scroll anchor at the end - this invisible element will be our scroll target - scrollAnchor = new Div(); - scrollAnchor.setId("scroll-anchor"); - scrollAnchor.getStyle().set("height", "1px"); - messagesContainer.add(scrollAnchor); - - // Auto-scroll to bottom after messages are loaded - scrollToBottom(); - } - private Div createDateSeparator(LocalDateTime date) { Div separator = new Div(); separator.setWidthFull(); @@ -309,22 +274,163 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { sendButton.addClickListener(e -> { String message = messageInput.getValue(); if (message != null && !message.trim().isEmpty()) { - // Add message to view (test data) - messagesContainer.add(createOutgoingMessage(message, LocalDateTime.now())); + sendMessageToParticipant(message.trim()); messageInput.clear(); - - // Scroll to bottom - scrollToBottom(); } }); - + HorizontalLayout layout = new HorizontalLayout(messageInput, sendButton); layout.setWidthFull(); layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.END); layout.expand(messageInput); - + return layout; } + + private void sendMessageToParticipant(String content) { + String sender = Optional.ofNullable(securityService.getCurrentUsername()).filter(name -> !name.isBlank()) + .orElse("System"); + + try { + Message saved; + if (jobConversation) { + saved = messageService.sendJobMessageToClient(content, sender, participantKey, jobIdContext, + jobNumberContext); + } else { + saved = messageService.sendGeneralMessageToClient(content, sender, participantKey); + } + + Notification.show("Nachricht gesendet", 2000, Notification.Position.BOTTOM_END) + .addThemeVariants(NotificationVariant.LUMO_SUCCESS); + + // Refresh conversation to include the new message and update counters + loadMessageDetails(); + + } catch (Exception ex) { + log.error("Failed to send message to {}: {}", participantKey, ex.getMessage(), ex); + Notification.show("Nachricht konnte nicht gesendet werden: " + ex.getMessage(), 4000, + Notification.Position.MIDDLE) + .addThemeVariants(NotificationVariant.LUMO_ERROR); + } + } + + private AppUser resolveParticipant() { + if (participantKey == null || participantKey.isBlank()) { + return null; + } + try { + ObjectId objectId = new ObjectId(participantKey); + return appUserService.findById(objectId); + } catch (IllegalArgumentException ex) { + return appUserService.findAll().stream() + .filter(user -> participantKey.equals(user.getEmail()) || participantKey.equals(user.getAppCode())) + .findFirst() + .orElse(null); + } + } + + private List filterMessagesForConversation(List messages, String conversationId) { + if (conversationId == null || messages == null) { + return List.of(); + } + if ("general".equalsIgnoreCase(conversationId)) { + return messages.stream() + .filter(msg -> Optional.ofNullable(msg.getMessageType()).orElse(MessageType.GENERAL) == MessageType.GENERAL) + .collect(Collectors.toList()); + } + if (conversationId.startsWith("job-")) { + String token = conversationId.substring(4); + return messages.stream() + .filter(msg -> Optional.ofNullable(msg.getMessageType()).orElse(MessageType.GENERAL) == MessageType.JOB_RELATED + && matchesJobConversation(msg, token)) + .collect(Collectors.toList()); + } + return messages; + } + + private boolean matchesJobConversation(Message message, String token) { + if (token == null || token.isBlank() || message == null) { + return false; + } + String normalizedToken = token.toLowerCase(); + String jobNumber = Optional.ofNullable(message.getJobNumber()).orElse(""); + String jobId = Optional.ofNullable(message.getJobIdAsString()).orElse(""); + + return sanitize(jobNumber).equalsIgnoreCase(normalizedToken) + || sanitize(jobId).equalsIgnoreCase(normalizedToken) + || jobNumber.equalsIgnoreCase(token) + || jobId.equalsIgnoreCase(token); + } + + private String sanitize(String value) { + if (value == null) { + return ""; + } + return value.replaceAll("[^a-zA-Z0-9_-]", "_").toLowerCase(); + } + + private String resolveConversationTitle(List messages, String conversationId) { + if (conversationId == null) { + return "Konversation"; + } + if ("general".equalsIgnoreCase(conversationId)) { + return "Allgemeine Unterhaltung"; + } + if (conversationId.startsWith("job-")) { + if (messages != null && !messages.isEmpty()) { + for (Message message : messages) { + String jobNumber = Optional.ofNullable(message.getJobNumber()).filter(s -> !s.isBlank()).orElse(null); + if (jobNumber != null) { + return "Auftrag " + jobNumber; + } + String jobId = Optional.ofNullable(message.getJobIdAsString()).filter(s -> !s.isBlank()).orElse(null); + if (jobId != null) { + return "Auftrag " + jobId; + } + } + } + return "Auftragsbasierte Unterhaltung"; + } + return "Konversation"; + } + + private void renderMessages(List messages) { + LocalDate currentDate = null; + for (Message message : messages) { + LocalDateTime timestamp = resolveTimestamp(message); + LocalDate messageDate = timestamp.toLocalDate(); + if (!messageDate.equals(currentDate)) { + messagesContainer.add(createDateSeparator(timestamp)); + currentDate = messageDate; + } + + String content = Optional.ofNullable(message.getContent()).orElse("(kein Inhalt)"); + if (message.getDirection() == MessageDirection.INCOMING) { + messagesContainer.add(createIncomingMessage(content, timestamp)); + } else { + messagesContainer.add(createOutgoingMessage(content, timestamp)); + } + } + + ensureScrollAnchor(); + + scrollToBottom(); + } + + private LocalDateTime resolveTimestamp(Message message) { + return Optional.ofNullable(message.getCreatedAt()).orElse(LocalDateTime.now()); + } + + private void ensureScrollAnchor() { + if (scrollAnchor == null) { + scrollAnchor = new Div(); + scrollAnchor.setId("scroll-anchor"); + scrollAnchor.getStyle().set("height", "1px"); + } + if (scrollAnchor.getParent().isEmpty()) { + messagesContainer.add(scrollAnchor); + } + } /** * Scroll the messages scroller to the bottom to show the latest message diff --git a/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java b/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java index 61ecf35..e915b5a 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java @@ -1,13 +1,12 @@ package de.assecutor.votianlt.pages.view; +import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.DetachEvent; import com.vaadin.flow.component.UI; -import com.vaadin.flow.component.button.Button; -import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.grid.Grid; import com.vaadin.flow.component.html.H2; import com.vaadin.flow.component.html.Main; import com.vaadin.flow.component.html.Span; -import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.NotificationVariant; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; @@ -21,15 +20,20 @@ import de.assecutor.votianlt.model.AppUser; import de.assecutor.votianlt.model.Message; import de.assecutor.votianlt.model.MessageDirection; import de.assecutor.votianlt.pages.service.AppUserService; -import de.assecutor.votianlt.repository.JobRepository; -import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.service.MessageService; import jakarta.annotation.security.RolesAllowed; import lombok.extern.slf4j.Slf4j; import java.time.format.DateTimeFormatter; -import java.util.*; -import java.util.stream.Collectors; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import com.vaadin.flow.shared.Registration; @Route(value = "messages", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @PageTitle("Nachrichten") @@ -38,20 +42,19 @@ import java.util.stream.Collectors; @Slf4j public class MessagesView extends Main { + private static final int POLL_INTERVAL_MS = 5000; + private final MessageService messageService; - private final SecurityService securityService; private final AppUserService appUserService; - private final JobRepository jobRepository; private Grid clientGrid; private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"); + private final AtomicBoolean loading = new AtomicBoolean(false); + private Registration pollRegistration; - public MessagesView(MessageService messageService, SecurityService securityService, - AppUserService appUserService, JobRepository jobRepository) { + public MessagesView(MessageService messageService, AppUserService appUserService) { this.messageService = messageService; - this.securityService = securityService; this.appUserService = appUserService; - this.jobRepository = jobRepository; // Create main layout VerticalLayout layout = new VerticalLayout(); @@ -77,15 +80,9 @@ public class MessagesView extends Main { private HorizontalLayout createHeaderLayout() { H2 title = new H2("Nachrichten"); - - Button refreshButton = new Button("Aktualisieren", VaadinIcon.REFRESH.create()); - refreshButton.addClickListener(e -> loadClientSummaries()); - - HorizontalLayout layout = new HorizontalLayout(title, refreshButton); + HorizontalLayout layout = new HorizontalLayout(title); layout.setWidthFull(); layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER); - layout.expand(title); - return layout; } @@ -144,162 +141,188 @@ public class MessagesView extends Main { } private void loadClientSummaries() { + if (!loading.compareAndSet(false, true)) { + return; + } try { - // Generate test data for display - List summaries = generateTestData(); + List appUsers = Optional.ofNullable(appUserService.findByCurrentUser()).orElseGet(ArrayList::new); + Map appUserLookup = buildAppUserLookup(appUsers); + + List allMessages = messageService.getAllMessages(); + Map> groupedByParticipant = groupMessagesByParticipant(allMessages); + List summaries = buildSummaries(groupedByParticipant, appUserLookup, appUsers); clientGrid.setItems(summaries); - + } catch (Exception e) { log.error("Error loading client summaries: {}", e.getMessage(), e); Notification.show("Fehler beim Laden der Nachrichten", 3000, Notification.Position.MIDDLE) .addThemeVariants(NotificationVariant.LUMO_ERROR); + } finally { + loading.set(false); } } - - private List generateTestData() { - List summaries = new ArrayList<>(); - - // Test client 1 - Max Mustermann with unread messages - summaries.add(new ClientMessageSummary( - "client001", - "Max Mustermann", - "max.mustermann@example.com", - 15, - 3, - java.time.LocalDateTime.now().minusHours(2), - "Hallo, ich habe eine Frage zu meinem letzten Auftrag..." - )); - - // Test client 2 - Anna Schmidt with no unread messages - summaries.add(new ClientMessageSummary( - "client002", - "Anna Schmidt", - "anna.schmidt@example.com", - 8, - 0, - java.time.LocalDateTime.now().minusDays(1), - "Vielen Dank für die schnelle Bearbeitung!" - )); - - // Test client 3 - Peter Weber with many unread messages - summaries.add(new ClientMessageSummary( - "client003", - "Peter Weber", - "peter.weber@example.com", - 25, - 7, - java.time.LocalDateTime.now().minusMinutes(30), - "Können Sie mir bitte den aktuellen Status mitteilen?" - )); - - // Test client 4 - Lisa Müller with recent message - summaries.add(new ClientMessageSummary( - "client004", - "Lisa Müller", - "lisa.mueller@example.com", - 12, - 1, - java.time.LocalDateTime.now().minusMinutes(5), - "Wann wird die Lieferung ankommen?" - )); - - // Test client 5 - Thomas Becker with older messages - summaries.add(new ClientMessageSummary( - "client005", - "Thomas Becker", - "thomas.becker@example.com", - 20, - 0, - java.time.LocalDateTime.now().minusDays(5), - "Alles erledigt, danke für die Zusammenarbeit." - )); - - // Test client 6 - Sarah Wagner with unread messages - summaries.add(new ClientMessageSummary( - "client006", - "Sarah Wagner", - "sarah.wagner@example.com", - 6, - 2, - java.time.LocalDateTime.now().minusHours(8), - "Gibt es Updates zum Auftrag #12345?" - )); - + + private Map> groupMessagesByParticipant(List messages) { + Map> grouped = new LinkedHashMap<>(); + for (Message message : messages) { + String participantKey = resolveParticipantKey(message); + if (participantKey == null || participantKey.isBlank()) { + continue; + } + grouped.computeIfAbsent(participantKey, key -> new ArrayList<>()).add(message); + } + return grouped; + } + + private List buildSummaries(Map> groupedMessages, + Map appUserLookup, List appUsers) { + Map summaryMap = new LinkedHashMap<>(); + + for (Map.Entry> entry : groupedMessages.entrySet()) { + String participantKey = entry.getKey(); + AppUser participant = resolveAppUser(participantKey, appUserLookup); + if (participant == null) { + continue; // Only display app users of the current account holder + } + + String participantId = participant.getIdAsString(); + if (participantId == null || participantId.isBlank()) { + continue; + } + + ClientMessageSummary summary = summaryMap.computeIfAbsent(participantId, + id -> createEmptySummary(participant)); + + List conversation = entry.getValue(); + if (conversation == null || conversation.isEmpty()) { + continue; + } + + conversation.sort(Comparator.comparing(Message::getCreatedAt, + Comparator.nullsLast(LocalDateTime::compareTo)).reversed()); + + Message latest = conversation.stream() + .filter(msg -> msg.getCreatedAt() != null) + .findFirst() + .orElse(conversation.get(0)); + + LocalDateTime lastDate = latest.getCreatedAt(); + String preview = Optional.ofNullable(latest.getContent()).filter(s -> !s.isBlank()).orElse("(kein Inhalt)"); + int totalMessages = conversation.size(); + int unreadCount = (int) conversation.stream() + .filter(msg -> msg.getDirection() == MessageDirection.INCOMING && !msg.isRead()) + .count(); + + summary.setTotalMessages(summary.getTotalMessages() + totalMessages); + summary.setUnreadCount(summary.getUnreadCount() + unreadCount); + + LocalDateTime currentLast = summary.getLastMessageDate(); + if (lastDate != null && (currentLast == null || lastDate.isAfter(currentLast))) { + summary.setLastMessageDate(lastDate); + summary.setLastMessagePreview(preview); + } + } + + for (AppUser appUser : appUsers) { + if (appUser == null) { + continue; + } + String appUserId = appUser.getIdAsString(); + if (appUserId != null && !summaryMap.containsKey(appUserId)) { + summaryMap.put(appUserId, createEmptySummary(appUser)); + } + } + + List summaries = new ArrayList<>(summaryMap.values()); + summaries.sort(Comparator.comparing(ClientMessageSummary::getLastMessageDate, + Comparator.nullsLast(LocalDateTime::compareTo)).reversed()); return summaries; } - private List aggregateMessagesByClient(String currentUsername) { - // Get all messages for current user (received and sent) - List receivedMessages = messageService.getMessagesForReceiver(currentUsername); - List sentMessages = messageService.getMessagesByDirection(MessageDirection.OUTGOING) - .stream() - .filter(m -> currentUsername.equals(m.getSender())) - .toList(); - - List allMessages = new ArrayList<>(); - allMessages.addAll(receivedMessages); - allMessages.addAll(sentMessages); - - // Group messages by client (sender or receiver that is not the current user) - Map> messagesByClient = allMessages.stream() - .collect(Collectors.groupingBy(message -> { - // Determine the "other party" (client) - if (currentUsername.equals(message.getSender())) { - return message.getReceiver(); - } else { - return message.getSender(); - } - })); - - // Create summaries for each client - List summaries = new ArrayList<>(); - for (Map.Entry> entry : messagesByClient.entrySet()) { - String clientEmail = entry.getKey(); - List clientMessages = entry.getValue(); - - // Find client info - AppUser appUser = appUserService.findAll().stream() - .filter(u -> clientEmail.equals(u.getEmail())) - .findFirst() - .orElse(null); - - String clientName = appUser != null ? - appUser.getVorname() + " " + appUser.getNachname() : clientEmail; - String clientId = appUser != null ? appUser.getIdAsString() : clientEmail; - - // Calculate statistics - int totalMessages = clientMessages.size(); - long unreadCount = clientMessages.stream() - .filter(m -> !m.isRead() && currentUsername.equals(m.getReceiver())) - .count(); - - // Get last message - Message lastMessage = clientMessages.stream() - .max(Comparator.comparing(Message::getCreatedAt)) - .orElse(null); - - java.time.LocalDateTime lastMessageDate = lastMessage != null ? lastMessage.getCreatedAt() : null; - String lastMessagePreview = lastMessage != null ? lastMessage.getContent() : null; - - summaries.add(new ClientMessageSummary( - clientId, - clientName, - clientEmail, - totalMessages, - (int) unreadCount, - lastMessageDate, - lastMessagePreview - )); + private String resolveParticipantKey(Message message) { + if (message == null) { + return null; } - - // Sort by last message date (most recent first) - summaries.sort((s1, s2) -> { - if (s1.getLastMessageDate() == null) return 1; - if (s2.getLastMessageDate() == null) return -1; - return s2.getLastMessageDate().compareTo(s1.getLastMessageDate()); - }); - - return summaries; + if (message.getDirection() == MessageDirection.INCOMING) { + return message.getSender(); + } + return message.getReceiver(); } + private AppUser resolveAppUser(String participantKey, Map appUserLookup) { + if (participantKey == null || participantKey.isBlank()) { + return null; + } + String trimmed = participantKey.trim(); + AppUser directMatch = appUserLookup.get(trimmed); + if (directMatch != null) { + return directMatch; + } + return appUserLookup.get(trimmed.toLowerCase()); + } + + private String buildClientName(AppUser participant) { + if (participant == null) { + return "-"; + } + String vorname = Optional.ofNullable(participant.getVorname()).orElse("").trim(); + String nachname = Optional.ofNullable(participant.getNachname()).orElse("").trim(); + + String fullName = (vorname + " " + nachname).trim(); + if (!fullName.isEmpty()) { + return fullName; + } + return Optional.ofNullable(participant.getBezeichnung()).orElse(participant.getIdAsString()); + } + + private ClientMessageSummary createEmptySummary(AppUser appUser) { + String clientId = Optional.ofNullable(appUser.getIdAsString()).orElse("-"); + String clientName = buildClientName(appUser); + String clientEmail = Optional.ofNullable(appUser.getEmail()).orElse("-"); + return new ClientMessageSummary(clientId, clientName, clientEmail, 0, 0, null, "-"); + } + + private Map buildAppUserLookup(List appUsers) { + Map lookup = new LinkedHashMap<>(); + for (AppUser appUser : appUsers) { + if (appUser == null) { + continue; + } + addLookupEntry(lookup, appUser.getIdAsString(), appUser); + addLookupEntry(lookup, appUser.getEmail(), appUser); + addLookupEntry(lookup, appUser.getAppCode(), appUser); + } + return lookup; + } + + private void addLookupEntry(Map lookup, String key, AppUser appUser) { + if (key == null) { + return; + } + String trimmed = key.trim(); + if (trimmed.isEmpty()) { + return; + } + lookup.putIfAbsent(trimmed, appUser); + lookup.putIfAbsent(trimmed.toLowerCase(), appUser); + } + + @Override + protected void onAttach(AttachEvent attachEvent) { + super.onAttach(attachEvent); + UI ui = attachEvent.getUI(); + ui.setPollInterval(POLL_INTERVAL_MS); + pollRegistration = ui.addPollListener(event -> loadClientSummaries()); + loadClientSummaries(); + } + + @Override + protected void onDetach(DetachEvent detachEvent) { + super.onDetach(detachEvent); + if (pollRegistration != null) { + pollRegistration.remove(); + pollRegistration = null; + } + detachEvent.getUI().setPollInterval(-1); + } } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java b/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java index ad70fac..d593bb1 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java @@ -16,13 +16,24 @@ import com.vaadin.flow.router.HasUrlParameter; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; import de.assecutor.votianlt.model.AppUser; +import de.assecutor.votianlt.model.Message; +import de.assecutor.votianlt.model.MessageDirection; +import de.assecutor.votianlt.model.MessageType; import de.assecutor.votianlt.pages.service.AppUserService; +import de.assecutor.votianlt.service.MessageService; import jakarta.annotation.security.RolesAllowed; import lombok.extern.slf4j.Slf4j; import org.bson.types.ObjectId; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; @Route(value = "user-messages", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @PageTitle("Nachrichten") @@ -31,13 +42,15 @@ import java.time.format.DateTimeFormatter; public class UserMessagesView extends Main implements HasUrlParameter { private final AppUserService appUserService; - - private String clientId; + private final MessageService messageService; + + private String participantKey; private VerticalLayout contentLayout; private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"); - public UserMessagesView(AppUserService appUserService) { + public UserMessagesView(AppUserService appUserService, MessageService messageService) { this.appUserService = appUserService; + this.messageService = messageService; // Create main layout contentLayout = new VerticalLayout(); @@ -50,35 +63,36 @@ public class UserMessagesView extends Main implements HasUrlParameter { @Override public void setParameter(BeforeEvent event, String parameter) { - this.clientId = parameter; + this.participantKey = parameter; loadClientMessages(); } private void loadClientMessages() { contentLayout.removeAll(); - + // Get client info AppUser client = null; try { - ObjectId objectId = new ObjectId(clientId); + ObjectId objectId = new ObjectId(participantKey); client = appUserService.findById(objectId); } catch (Exception e) { - log.warn("Could not find client with id: {}", clientId); + log.debug("Could not resolve AppUser for participant key {}: {}", participantKey, e.getMessage()); } - + String clientName = client != null ? - client.getVorname() + " " + client.getNachname() : "Unbekannter Client"; - - // Create header + client.getVorname() + " " + client.getNachname() : Optional.ofNullable(participantKey).orElse("Unbekannter Teilnehmer"); + HorizontalLayout headerLayout = createHeaderLayout(clientName); contentLayout.add(headerLayout); - - // Create section for general messages (only one chat for general conversation) - VerticalLayout generalSection = createGeneralMessagesSection(); - - // Create section for job-related messages - VerticalLayout jobSection = createJobMessagesSection(); - + + List conversation = messageService.getMessagesForParticipantAscending(participantKey); + Map> messagesByType = conversation.stream() + .collect(Collectors.groupingBy(message -> Optional.ofNullable(message.getMessageType()).orElse(MessageType.GENERAL))); + + VerticalLayout generalSection = createGeneralMessagesSection(messagesByType.get(MessageType.GENERAL)); + + VerticalLayout jobSection = createJobMessagesSection(messagesByType.get(MessageType.JOB_RELATED)); + contentLayout.add(generalSection, jobSection); } @@ -96,7 +110,7 @@ public class UserMessagesView extends Main implements HasUrlParameter { return layout; } - private VerticalLayout createGeneralMessagesSection() { + private VerticalLayout createGeneralMessagesSection(List generalMessages) { VerticalLayout section = new VerticalLayout(); section.setPadding(true); section.setSpacing(true); @@ -104,24 +118,38 @@ public class UserMessagesView extends Main implements HasUrlParameter { section.getStyle().set("border-radius", "8px"); section.setWidthFull(); section.getStyle().set("margin-right", "20px"); - + H3 title = new H3("Allgemeine Nachrichten"); section.add(title); - - // Test data - only one general chat conversation + + List sortedMessages = new ArrayList<>(); + if (generalMessages != null) { + sortedMessages.addAll(generalMessages); + } + + sortedMessages.sort(Comparator.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo))); + + Message latest = sortedMessages.isEmpty() ? null : sortedMessages.get(sortedMessages.size() - 1); + int unreadCount = (int) sortedMessages.stream() + .filter(message -> message.getDirection() == MessageDirection.INCOMING && !message.isRead()) + .count(); + int messageCount = sortedMessages.size(); + LocalDateTime lastMessageTime = latest != null ? latest.getCreatedAt() : null; + String preview = latest != null ? latest.getContent() : null; + section.add(createMessageCard( - "Allgemeine Unterhaltung", - "Hallo, wie geht es Ihnen?", - LocalDateTime.now().minusHours(2), - 5, - 2, - "general" + "Allgemeine Unterhaltung", + preview, + lastMessageTime, + messageCount, + unreadCount, + "general" )); - + return section; } - private VerticalLayout createJobMessagesSection() { + private VerticalLayout createJobMessagesSection(List jobMessages) { VerticalLayout section = new VerticalLayout(); section.setPadding(true); section.setSpacing(true); @@ -129,44 +157,42 @@ public class UserMessagesView extends Main implements HasUrlParameter { section.getStyle().set("border-radius", "8px"); section.setWidthFull(); section.getStyle().set("margin-right", "20px"); - + H3 title = new H3("Nachrichten zu Aufträgen"); section.add(title); - - // Test data - job-related messages - section.add(createMessageCard( - "Auftrag #12345", - "Die Lieferung ist angekommen.", - LocalDateTime.now().minusHours(5), - 8, - 1, - "job-12345" - )); - - section.add(createMessageCard( - "Auftrag #12344", - "Bitte um Rückruf bezüglich der Abholung.", - LocalDateTime.now().minusDays(2), - 12, - 3, - "job-12344" - )); - - section.add(createMessageCard( - "Auftrag #12343", - "Auftrag wurde erfolgreich abgeschlossen.", - LocalDateTime.now().minusDays(3), - 6, - 0, - "job-12343" - )); - + + if (jobMessages == null || jobMessages.isEmpty()) { + section.add(new Span("Keine auftragsbezogenen Nachrichten vorhanden.")); + return section; + } + + Map> messagesByJob = jobMessages.stream() + .collect(Collectors.groupingBy(this::resolveJobKey, LinkedHashMap::new, Collectors.toList())); + + messagesByJob.forEach((jobKey, messages) -> { + messages.sort(Comparator.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo))); + Message latest = messages.get(messages.size() - 1); + int unreadCount = (int) messages.stream() + .filter(message -> message.getDirection() == MessageDirection.INCOMING && !message.isRead()) + .count(); + + String conversationTitle = "Auftrag " + jobKey; + section.add(createMessageCard( + conversationTitle, + Optional.ofNullable(latest.getContent()).orElse(""), + latest.getCreatedAt(), + messages.size(), + unreadCount, + "job-" + sanitizeConversationId(jobKey) + )); + }); + return section; } - private Div createMessageCard(String conversationTitle, String lastMessagePreview, - LocalDateTime lastMessageTime, int messageCount, - int unreadCount, String conversationId) { + private Div createMessageCard(String conversationTitle, String lastMessagePreview, + LocalDateTime lastMessageTime, int messageCount, + int unreadCount, String conversationId) { Div card = new Div(); card.setWidthFull(); card.getStyle().set("padding", "15px"); @@ -211,7 +237,7 @@ public class UserMessagesView extends Main implements HasUrlParameter { titleRow.expand(titleSpan); // Preview text - Span preview = new Span(lastMessagePreview); + Span preview = new Span(Optional.ofNullable(lastMessagePreview).filter(s -> !s.isBlank()).orElse("(kein Inhalt)")); preview.getStyle().set("color", "#666666"); preview.getStyle().set("font-size", "14px"); @@ -219,7 +245,7 @@ public class UserMessagesView extends Main implements HasUrlParameter { HorizontalLayout metaRow = new HorizontalLayout(); metaRow.setWidthFull(); - Span timeSpan = new Span(lastMessageTime.format(DATE_FORMATTER)); + Span timeSpan = new Span(lastMessageTime != null ? lastMessageTime.format(DATE_FORMATTER) : "-"); timeSpan.getStyle().set("color", "#999999"); timeSpan.getStyle().set("font-size", "12px"); @@ -238,10 +264,30 @@ public class UserMessagesView extends Main implements HasUrlParameter { card.add(cardContent); // Click listener to navigate to message details - card.addClickListener(e -> { - UI.getCurrent().navigate("message-details/" + clientId + "/" + conversationId); - }); - + card.addClickListener(e -> UI.getCurrent().navigate("message-details/" + participantKey + "/" + conversationId)); + return card; } + + private String resolveJobKey(Message message) { + if (message == null) { + return "Unbekannt"; + } + String jobNumber = Optional.ofNullable(message.getJobNumber()).filter(s -> !s.isBlank()).orElse(null); + if (jobNumber != null) { + return jobNumber; + } + String jobId = message.getJobIdAsString(); + if (jobId != null && !jobId.isBlank()) { + return jobId; + } + return "Unbekannt"; + } + + private String sanitizeConversationId(String value) { + if (value == null || value.isBlank()) { + return "unknown"; + } + return value.replaceAll("[^a-zA-Z0-9_-]", "_"); + } } diff --git a/src/main/java/de/assecutor/votianlt/repository/MessageRepository.java b/src/main/java/de/assecutor/votianlt/repository/MessageRepository.java index bd7544c..e36696e 100644 --- a/src/main/java/de/assecutor/votianlt/repository/MessageRepository.java +++ b/src/main/java/de/assecutor/votianlt/repository/MessageRepository.java @@ -61,4 +61,16 @@ public interface MessageRepository extends MongoRepository { * Find all messages (for admin/overview), ordered by creation time descending */ List findAllByOrderByCreatedAtDesc(); + + /** + * Find all messages where the sender or receiver matches the provided value, + * ordered by creation time ascending. + */ + List findBySenderOrReceiverOrderByCreatedAtAsc(String sender, String receiver); + + /** + * Find all messages where the sender or receiver matches the provided value, + * ordered by creation time descending. + */ + List findBySenderOrReceiverOrderByCreatedAtDesc(String sender, String receiver); } diff --git a/src/main/java/de/assecutor/votianlt/service/MessageService.java b/src/main/java/de/assecutor/votianlt/service/MessageService.java index 60b5f90..7821e7d 100644 --- a/src/main/java/de/assecutor/votianlt/service/MessageService.java +++ b/src/main/java/de/assecutor/votianlt/service/MessageService.java @@ -1,19 +1,18 @@ package de.assecutor.votianlt.service; -import com.fasterxml.jackson.databind.ObjectMapper; import de.assecutor.votianlt.model.Message; import de.assecutor.votianlt.model.MessageDirection; import de.assecutor.votianlt.model.MessageType; +import de.assecutor.votianlt.dto.ChatMessageInboundPayload; +import de.assecutor.votianlt.dto.ChatMessageOutboundPayload; import de.assecutor.votianlt.mqtt.MqttPublisher; import de.assecutor.votianlt.repository.MessageRepository; import lombok.extern.slf4j.Slf4j; import org.bson.types.ObjectId; import org.springframework.stereotype.Service; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; +import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.Optional; @Service @@ -22,12 +21,9 @@ public class MessageService { private final MessageRepository messageRepository; private final MqttPublisher mqttPublisher; - private final ObjectMapper objectMapper; - public MessageService(MessageRepository messageRepository, MqttPublisher mqttPublisher) { this.messageRepository = messageRepository; this.mqttPublisher = mqttPublisher; - this.objectMapper = new ObjectMapper(); } /** @@ -62,13 +58,14 @@ public class MessageService { /** * Handle incoming message from a client */ - public Message receiveMessageFromClient(String content, String sender, String receiver, - ObjectId jobId, String jobNumber) { + public Message receiveMessageFromClient(ChatMessageInboundPayload payload) { Message message; - if (jobId != null) { - message = new Message(content, sender, receiver, MessageDirection.INCOMING, jobId, jobNumber); + if (payload.hasJobContext()) { + message = new Message(payload.content(), payload.sender(), payload.receiver(), + MessageDirection.INCOMING, payload.jobId(), payload.jobNumber()); } else { - message = new Message(content, sender, receiver, MessageDirection.INCOMING); + message = new Message(payload.content(), payload.sender(), payload.receiver(), + MessageDirection.INCOMING); } return saveMessage(message); } @@ -79,18 +76,7 @@ public class MessageService { private void publishMessageToMqtt(Message message, String receiver) { try { String topic = "/client/" + receiver + "/message"; - Map payload = new HashMap<>(); - payload.put("messageId", message.getIdAsString()); - payload.put("content", message.getContent()); - payload.put("sender", message.getSender()); - payload.put("messageType", message.getMessageType().toString()); - payload.put("createdAt", message.getCreatedAt().toString()); - - if (message.getJobId() != null) { - payload.put("jobId", message.getJobIdAsString()); - payload.put("jobNumber", message.getJobNumber()); - } - + ChatMessageOutboundPayload payload = ChatMessageOutboundPayload.fromMessage(message); mqttPublisher.publishAsJson(topic, payload, false); log.info("Published message to MQTT topic: {}", topic); } catch (Exception e) { @@ -126,6 +112,28 @@ public class MessageService { return messageRepository.findByReceiverAndJobIdOrderByCreatedAtDesc(receiver, jobId); } + /** + * Get all messages a participant sent or received (oldest first) to reconstruct + * the conversation timeline. + */ + public List getMessagesForParticipantAscending(String participant) { + if (participant == null || participant.isBlank()) { + return Collections.emptyList(); + } + return messageRepository.findBySenderOrReceiverOrderByCreatedAtAsc(participant, participant); + } + + /** + * Get all messages a participant sent or received (newest first) for + * quick summaries. + */ + public List getMessagesForParticipantDescending(String participant) { + if (participant == null || participant.isBlank()) { + return Collections.emptyList(); + } + return messageRepository.findBySenderOrReceiverOrderByCreatedAtDesc(participant, participant); + } + /** * Get all general messages for a receiver */ diff --git a/src/main/resources/mqtt/chat/incoming-chat-message.json b/src/main/resources/mqtt/chat/incoming-chat-message.json new file mode 100644 index 0000000..4f53d53 --- /dev/null +++ b/src/main/resources/mqtt/chat/incoming-chat-message.json @@ -0,0 +1,7 @@ +{ + "sender": "driver01", + "receiver": "dispatcher01", + "content": "Ankunft in 10 Minuten.", + "jobId": "665f1c601971c8390b29f1f5", + "jobNumber": "JOB-2024-00042" +} diff --git a/src/main/resources/mqtt/chat/outgoing-chat-message.json b/src/main/resources/mqtt/chat/outgoing-chat-message.json new file mode 100644 index 0000000..7f9bba7 --- /dev/null +++ b/src/main/resources/mqtt/chat/outgoing-chat-message.json @@ -0,0 +1,12 @@ +{ + "messageId": "6660d4c1fcb75a64f1b3c812", + "sender": "dispatcher01", + "receiver": "driver01", + "content": "Bitte bestätige die Lieferung.", + "direction": "OUTGOING", + "messageType": "JOB_RELATED", + "createdAt": "2024-05-25T10:45:00", + "jobId": "665f1c601971c8390b29f1f5", + "jobNumber": "JOB-2024-00042", + "read": false +}