Erweiterungen
This commit is contained in:
@@ -133,11 +133,34 @@ Payload:
|
|||||||
- User notifications: v1/users/<username>/notifications
|
- User notifications: v1/users/<username>/notifications
|
||||||
Payload example:
|
Payload example:
|
||||||
{
|
{
|
||||||
"type": "broadcast|notification",
|
"type": "broadcast|notification",
|
||||||
"message": "...",
|
"message": "...",
|
||||||
"timestamp": "2025-09-13T22:10:00"
|
"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
|
Quality of Service & Retain
|
||||||
- QoS 2 (exactly once) is used by default server side for both inbound subscriptions and outbound publications.
|
- 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.
|
- Retained messages are disabled by default to avoid stale updates.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.assecutor.votianlt.controller;
|
|||||||
|
|
||||||
import de.assecutor.votianlt.dto.AppLoginRequest;
|
import de.assecutor.votianlt.dto.AppLoginRequest;
|
||||||
import de.assecutor.votianlt.dto.AppLoginResponse;
|
import de.assecutor.votianlt.dto.AppLoginResponse;
|
||||||
|
import de.assecutor.votianlt.dto.ChatMessageInboundPayload;
|
||||||
import de.assecutor.votianlt.dto.JobWithRelatedDataDTO;
|
import de.assecutor.votianlt.dto.JobWithRelatedDataDTO;
|
||||||
import de.assecutor.votianlt.model.AppUser;
|
import de.assecutor.votianlt.model.AppUser;
|
||||||
import de.assecutor.votianlt.model.CargoItem;
|
import de.assecutor.votianlt.model.CargoItem;
|
||||||
@@ -609,43 +610,12 @@ public class MessageController {
|
|||||||
log.info("MQTT Endpoint '/server/{clientId}/message' called with data: {}", payload);
|
log.info("MQTT Endpoint '/server/{clientId}/message' called with data: {}", payload);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Extract required fields
|
ChatMessageInboundPayload inboundPayload = ChatMessageInboundPayload.fromPayload(payload);
|
||||||
String sender = payload.get("sender") != null ? payload.get("sender").toString() : null;
|
messageService.receiveMessageFromClient(inboundPayload);
|
||||||
String receiver = payload.get("receiver") != null ? payload.get("receiver").toString() : null;
|
log.info("Successfully saved incoming message from {} to {}", inboundPayload.sender(),
|
||||||
String content = payload.get("content") != null ? payload.get("content").toString() : null;
|
inboundPayload.receiver());
|
||||||
|
} catch (IllegalArgumentException validationError) {
|
||||||
// Validate required fields
|
log.warn("Incoming chat message rejected: {}", validationError.getMessage());
|
||||||
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);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error handling incoming message: {}", e.getMessage(), e);
|
log.error("Error handling incoming message: {}", e.getMessage(), e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, Object> 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<String, Object> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,7 +87,7 @@ public class MqttV5ClientManager implements SmartLifecycle {
|
|||||||
// Subscribe to topics with QoS
|
// Subscribe to topics with QoS
|
||||||
String[] topics = new String[] { "/server/+/task/photo/completed", "/server/+/task/confirm",
|
String[] topics = new String[] { "/server/+/task/photo/completed", "/server/+/task/confirm",
|
||||||
"/server/+/task/completed", "/server/+/task_completed", "/server/+/job/status",
|
"/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());
|
MqttQos qos = mapQos(props.getDefaultQos());
|
||||||
for (String topic : topics) {
|
for (String topic : topics) {
|
||||||
client.subscribeWith().topicFilter(topic).qos(qos).send().join();
|
client.subscribeWith().topicFilter(topic).qos(qos).send().join();
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ import de.assecutor.votianlt.service.MessageService;
|
|||||||
|
|
||||||
import static com.vaadin.flow.theme.lumo.LumoUtility.*;
|
import static com.vaadin.flow.theme.lumo.LumoUtility.*;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@AnonymousAllowed
|
@AnonymousAllowed
|
||||||
|
|
||||||
@Layout("main")
|
@Layout("main")
|
||||||
@@ -157,14 +161,9 @@ public final class MainLayout extends AppLayout {
|
|||||||
} else {
|
} else {
|
||||||
item = new SideNavItem(menuEntry.title(), menuEntry.path());
|
item = new SideNavItem(menuEntry.title(), menuEntry.path());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add badge for "Nachrichten" menu item showing unread message count
|
|
||||||
if ("Nachrichten".equals(menuEntry.title())) {
|
if ("Nachrichten".equals(menuEntry.title())) {
|
||||||
try {
|
long unreadCount = resolveUnreadMessageCount();
|
||||||
// Test: Show badge with 10 unread messages
|
if (unreadCount > 0) {
|
||||||
long unreadCount = 10;
|
|
||||||
|
|
||||||
// Create blue badge with white text (same color as UserMessagesView)
|
|
||||||
Span badge = new Span(String.valueOf(unreadCount));
|
Span badge = new Span(String.valueOf(unreadCount));
|
||||||
badge.getElement().getThemeList().add("badge");
|
badge.getElement().getThemeList().add("badge");
|
||||||
badge.getStyle().set("background-color", "var(--lumo-primary-color)");
|
badge.getStyle().set("background-color", "var(--lumo-primary-color)");
|
||||||
@@ -177,14 +176,50 @@ public final class MainLayout extends AppLayout {
|
|||||||
badge.getStyle().set("text-align", "center");
|
badge.getStyle().set("text-align", "center");
|
||||||
|
|
||||||
item.setSuffixComponent(badge);
|
item.setSuffixComponent(badge);
|
||||||
} catch (Exception e) {
|
|
||||||
// If there's an error, just don't show the badge
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private long resolveUnreadMessageCount() {
|
||||||
|
if (!securityService.isUserLoggedIn()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> 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() {
|
private Component createUserMenu() {
|
||||||
var userMenu = new MenuBar();
|
var userMenu = new MenuBar();
|
||||||
userMenu.addThemeVariants(MenuBarVariant.LUMO_TERTIARY_INLINE);
|
userMenu.addThemeVariants(MenuBarVariant.LUMO_TERTIARY_INLINE);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import com.vaadin.flow.component.icon.VaadinIcon;
|
|||||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||||
import com.vaadin.flow.component.orderedlayout.Scroller;
|
import com.vaadin.flow.component.orderedlayout.Scroller;
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
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.component.textfield.TextArea;
|
||||||
import com.vaadin.flow.router.BeforeEnterEvent;
|
import com.vaadin.flow.router.BeforeEnterEvent;
|
||||||
import com.vaadin.flow.router.BeforeEnterObserver;
|
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.Route;
|
||||||
import com.vaadin.flow.router.RouteParameters;
|
import com.vaadin.flow.router.RouteParameters;
|
||||||
import de.assecutor.votianlt.model.AppUser;
|
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.pages.service.AppUserService;
|
||||||
|
import de.assecutor.votianlt.service.MessageService;
|
||||||
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
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)
|
@Route(value = "message-details/:clientId/:conversationId", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
||||||
@PageTitle("Nachrichtenverlauf")
|
@PageTitle("Nachrichtenverlauf")
|
||||||
@@ -32,9 +44,14 @@ import java.time.format.DateTimeFormatter;
|
|||||||
public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||||
|
|
||||||
private final AppUserService appUserService;
|
private final AppUserService appUserService;
|
||||||
|
private final MessageService messageService;
|
||||||
|
private final SecurityService securityService;
|
||||||
|
|
||||||
private String clientId;
|
private String participantKey;
|
||||||
private String conversationId;
|
private String conversationId;
|
||||||
|
private boolean jobConversation;
|
||||||
|
private ObjectId jobIdContext;
|
||||||
|
private String jobNumberContext;
|
||||||
private VerticalLayout contentLayout;
|
private VerticalLayout contentLayout;
|
||||||
private VerticalLayout messagesContainer;
|
private VerticalLayout messagesContainer;
|
||||||
private Scroller messagesScroller;
|
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 TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
|
||||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
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.appUserService = appUserService;
|
||||||
|
this.messageService = messageService;
|
||||||
|
this.securityService = securityService;
|
||||||
|
|
||||||
// Set height to 100% to prevent page from growing beyond viewport
|
// Set height to 100% to prevent page from growing beyond viewport
|
||||||
setHeightFull();
|
setHeightFull();
|
||||||
@@ -64,13 +84,13 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
// Extract route parameters from URL
|
// Extract route parameters from URL
|
||||||
RouteParameters parameters = event.getRouteParameters();
|
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);
|
this.conversationId = parameters.get("conversationId").orElse(null);
|
||||||
|
|
||||||
log.info("MessageDetailsView - clientId: {}, conversationId: {}", clientId, conversationId);
|
log.info("MessageDetailsView - participant: {}, conversationId: {}", participantKey, conversationId);
|
||||||
|
|
||||||
if (clientId == null || conversationId == null) {
|
if (participantKey == null || conversationId == null) {
|
||||||
log.warn("Missing required route parameters: clientId={}, conversationId={}", clientId, conversationId);
|
log.warn("Missing required route parameters: participantKey={}, conversationId={}", participantKey, conversationId);
|
||||||
event.rerouteToError(IllegalArgumentException.class, "Missing required parameters");
|
event.rerouteToError(IllegalArgumentException.class, "Missing required parameters");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -81,29 +101,29 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
private void loadMessageDetails() {
|
private void loadMessageDetails() {
|
||||||
contentLayout.removeAll();
|
contentLayout.removeAll();
|
||||||
|
|
||||||
// Get client info
|
AppUser client = resolveParticipant();
|
||||||
AppUser client = null;
|
String clientName = client != null
|
||||||
try {
|
? (Optional.ofNullable(client.getVorname()).orElse("") + " "
|
||||||
ObjectId objectId = new ObjectId(clientId);
|
+ Optional.ofNullable(client.getNachname()).orElse("")).trim()
|
||||||
client = appUserService.findById(objectId);
|
: Optional.ofNullable(participantKey).orElse("Unbekannter Teilnehmer");
|
||||||
} catch (Exception e) {
|
if (clientName.isBlank()) {
|
||||||
log.warn("Could not find client with id: {}", clientId);
|
clientName = Optional.ofNullable(client).map(AppUser::getBezeichnung).orElse("Unbekannter Teilnehmer");
|
||||||
}
|
}
|
||||||
|
|
||||||
String clientName = client != null ?
|
List<Message> allMessages = messageService.getMessagesForParticipantAscending(participantKey);
|
||||||
client.getVorname() + " " + client.getNachname() : "Unbekannter Client";
|
List<Message> filteredMessages = filterMessagesForConversation(allMessages, conversationId);
|
||||||
|
|
||||||
// Determine conversation title
|
this.jobConversation = conversationId != null && conversationId.startsWith("job-");
|
||||||
String conversationTitle = "Allgemeine Unterhaltung";
|
this.jobIdContext = filteredMessages.stream().map(Message::getJobId).filter(Objects::nonNull).findFirst()
|
||||||
if (conversationId != null && conversationId.startsWith("job-")) {
|
.orElse(null);
|
||||||
conversationTitle = "Auftrag #" + conversationId.substring(4);
|
this.jobNumberContext = filteredMessages.stream().map(Message::getJobNumber)
|
||||||
}
|
.filter(value -> value != null && !value.isBlank()).findFirst().orElse(null);
|
||||||
|
|
||||||
|
String conversationTitle = resolveConversationTitle(filteredMessages, conversationId);
|
||||||
|
|
||||||
// Create header
|
|
||||||
HorizontalLayout headerLayout = createHeaderLayout(clientName, conversationTitle);
|
HorizontalLayout headerLayout = createHeaderLayout(clientName, conversationTitle);
|
||||||
contentLayout.add(headerLayout);
|
contentLayout.add(headerLayout);
|
||||||
|
|
||||||
// Create messages container (content for scrollable area)
|
|
||||||
messagesContainer = new VerticalLayout();
|
messagesContainer = new VerticalLayout();
|
||||||
messagesContainer.setPadding(true);
|
messagesContainer.setPadding(true);
|
||||||
messagesContainer.setSpacing(true);
|
messagesContainer.setSpacing(true);
|
||||||
@@ -111,26 +131,34 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
messagesContainer.getStyle().set("background-color", "#f0f0f0");
|
messagesContainer.getStyle().set("background-color", "#f0f0f0");
|
||||||
messagesContainer.getStyle().set("border-radius", "8px");
|
messagesContainer.getStyle().set("border-radius", "8px");
|
||||||
messagesContainer.getStyle().set("padding", "20px");
|
messagesContainer.getStyle().set("padding", "20px");
|
||||||
|
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");
|
||||||
|
|
||||||
// Add test messages
|
if (!filteredMessages.isEmpty()) {
|
||||||
loadTestMessages();
|
renderMessages(filteredMessages);
|
||||||
|
} else {
|
||||||
|
ensureScrollAnchor();
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap messages container in Scroller for proper scrolling behavior
|
|
||||||
messagesScroller = new Scroller(messagesContainer);
|
messagesScroller = new Scroller(messagesContainer);
|
||||||
messagesScroller.setWidthFull();
|
messagesScroller.setWidthFull();
|
||||||
messagesScroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
|
messagesScroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
|
||||||
|
messagesScroller.setHeightFull();
|
||||||
|
messagesScroller.getStyle().set("flex", "1 1 auto");
|
||||||
|
|
||||||
contentLayout.add(messagesScroller);
|
contentLayout.add(messagesScroller);
|
||||||
contentLayout.setFlexGrow(1, messagesScroller);
|
contentLayout.setFlexGrow(1, messagesScroller);
|
||||||
|
|
||||||
// Add message input area
|
|
||||||
HorizontalLayout inputLayout = createMessageInputArea();
|
HorizontalLayout inputLayout = createMessageInputArea();
|
||||||
contentLayout.add(inputLayout);
|
contentLayout.add(inputLayout);
|
||||||
}
|
}
|
||||||
|
|
||||||
private HorizontalLayout createHeaderLayout(String clientName, String conversationTitle) {
|
private HorizontalLayout createHeaderLayout(String clientName, String conversationTitle) {
|
||||||
Button backButton = new Button("Zurück", VaadinIcon.ARROW_LEFT.create());
|
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();
|
VerticalLayout titleLayout = new VerticalLayout();
|
||||||
titleLayout.setPadding(false);
|
titleLayout.setPadding(false);
|
||||||
@@ -153,69 +181,6 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
return layout;
|
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) {
|
private Div createDateSeparator(LocalDateTime date) {
|
||||||
Div separator = new Div();
|
Div separator = new Div();
|
||||||
separator.setWidthFull();
|
separator.setWidthFull();
|
||||||
@@ -309,12 +274,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
sendButton.addClickListener(e -> {
|
sendButton.addClickListener(e -> {
|
||||||
String message = messageInput.getValue();
|
String message = messageInput.getValue();
|
||||||
if (message != null && !message.trim().isEmpty()) {
|
if (message != null && !message.trim().isEmpty()) {
|
||||||
// Add message to view (test data)
|
sendMessageToParticipant(message.trim());
|
||||||
messagesContainer.add(createOutgoingMessage(message, LocalDateTime.now()));
|
|
||||||
messageInput.clear();
|
messageInput.clear();
|
||||||
|
|
||||||
// Scroll to bottom
|
|
||||||
scrollToBottom();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -326,6 +287,151 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
return layout;
|
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<Message> filterMessagesForConversation(List<Message> 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<Message> 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<Message> 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
|
* Scroll the messages scroller to the bottom to show the latest message
|
||||||
* Uses scrollIntoView on the anchor element at the end of messages
|
* Uses scrollIntoView on the anchor element at the end of messages
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
package de.assecutor.votianlt.pages.view;
|
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.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.grid.Grid;
|
||||||
import com.vaadin.flow.component.html.H2;
|
import com.vaadin.flow.component.html.H2;
|
||||||
import com.vaadin.flow.component.html.Main;
|
import com.vaadin.flow.component.html.Main;
|
||||||
import com.vaadin.flow.component.html.Span;
|
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.Notification;
|
||||||
import com.vaadin.flow.component.notification.NotificationVariant;
|
import com.vaadin.flow.component.notification.NotificationVariant;
|
||||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
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.Message;
|
||||||
import de.assecutor.votianlt.model.MessageDirection;
|
import de.assecutor.votianlt.model.MessageDirection;
|
||||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
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 de.assecutor.votianlt.service.MessageService;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.*;
|
import java.time.LocalDateTime;
|
||||||
import java.util.stream.Collectors;
|
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)
|
@Route(value = "messages", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
||||||
@PageTitle("Nachrichten")
|
@PageTitle("Nachrichten")
|
||||||
@@ -38,20 +42,19 @@ import java.util.stream.Collectors;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class MessagesView extends Main {
|
public class MessagesView extends Main {
|
||||||
|
|
||||||
|
private static final int POLL_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
private final MessageService messageService;
|
private final MessageService messageService;
|
||||||
private final SecurityService securityService;
|
|
||||||
private final AppUserService appUserService;
|
private final AppUserService appUserService;
|
||||||
private final JobRepository jobRepository;
|
|
||||||
|
|
||||||
private Grid<ClientMessageSummary> clientGrid;
|
private Grid<ClientMessageSummary> clientGrid;
|
||||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
|
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,
|
public MessagesView(MessageService messageService, AppUserService appUserService) {
|
||||||
AppUserService appUserService, JobRepository jobRepository) {
|
|
||||||
this.messageService = messageService;
|
this.messageService = messageService;
|
||||||
this.securityService = securityService;
|
|
||||||
this.appUserService = appUserService;
|
this.appUserService = appUserService;
|
||||||
this.jobRepository = jobRepository;
|
|
||||||
|
|
||||||
// Create main layout
|
// Create main layout
|
||||||
VerticalLayout layout = new VerticalLayout();
|
VerticalLayout layout = new VerticalLayout();
|
||||||
@@ -77,15 +80,9 @@ public class MessagesView extends Main {
|
|||||||
|
|
||||||
private HorizontalLayout createHeaderLayout() {
|
private HorizontalLayout createHeaderLayout() {
|
||||||
H2 title = new H2("Nachrichten");
|
H2 title = new H2("Nachrichten");
|
||||||
|
HorizontalLayout layout = new HorizontalLayout(title);
|
||||||
Button refreshButton = new Button("Aktualisieren", VaadinIcon.REFRESH.create());
|
|
||||||
refreshButton.addClickListener(e -> loadClientSummaries());
|
|
||||||
|
|
||||||
HorizontalLayout layout = new HorizontalLayout(title, refreshButton);
|
|
||||||
layout.setWidthFull();
|
layout.setWidthFull();
|
||||||
layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER);
|
layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER);
|
||||||
layout.expand(title);
|
|
||||||
|
|
||||||
return layout;
|
return layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,162 +141,188 @@ public class MessagesView extends Main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void loadClientSummaries() {
|
private void loadClientSummaries() {
|
||||||
|
if (!loading.compareAndSet(false, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Generate test data for display
|
List<AppUser> appUsers = Optional.ofNullable(appUserService.findByCurrentUser()).orElseGet(ArrayList::new);
|
||||||
List<ClientMessageSummary> summaries = generateTestData();
|
Map<String, AppUser> appUserLookup = buildAppUserLookup(appUsers);
|
||||||
|
|
||||||
|
List<Message> allMessages = messageService.getAllMessages();
|
||||||
|
Map<String, List<Message>> groupedByParticipant = groupMessagesByParticipant(allMessages);
|
||||||
|
List<ClientMessageSummary> summaries = buildSummaries(groupedByParticipant, appUserLookup, appUsers);
|
||||||
clientGrid.setItems(summaries);
|
clientGrid.setItems(summaries);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error loading client summaries: {}", e.getMessage(), e);
|
log.error("Error loading client summaries: {}", e.getMessage(), e);
|
||||||
Notification.show("Fehler beim Laden der Nachrichten", 3000, Notification.Position.MIDDLE)
|
Notification.show("Fehler beim Laden der Nachrichten", 3000, Notification.Position.MIDDLE)
|
||||||
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
|
} finally {
|
||||||
|
loading.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ClientMessageSummary> generateTestData() {
|
private Map<String, List<Message>> groupMessagesByParticipant(List<Message> messages) {
|
||||||
List<ClientMessageSummary> summaries = new ArrayList<>();
|
Map<String, List<Message>> grouped = new LinkedHashMap<>();
|
||||||
|
for (Message message : messages) {
|
||||||
// Test client 1 - Max Mustermann with unread messages
|
String participantKey = resolveParticipantKey(message);
|
||||||
summaries.add(new ClientMessageSummary(
|
if (participantKey == null || participantKey.isBlank()) {
|
||||||
"client001",
|
continue;
|
||||||
"Max Mustermann",
|
}
|
||||||
"max.mustermann@example.com",
|
grouped.computeIfAbsent(participantKey, key -> new ArrayList<>()).add(message);
|
||||||
15,
|
}
|
||||||
3,
|
return grouped;
|
||||||
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?"
|
|
||||||
));
|
|
||||||
|
|
||||||
return summaries;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ClientMessageSummary> aggregateMessagesByClient(String currentUsername) {
|
private List<ClientMessageSummary> buildSummaries(Map<String, List<Message>> groupedMessages,
|
||||||
// Get all messages for current user (received and sent)
|
Map<String, AppUser> appUserLookup, List<AppUser> appUsers) {
|
||||||
List<Message> receivedMessages = messageService.getMessagesForReceiver(currentUsername);
|
Map<String, ClientMessageSummary> summaryMap = new LinkedHashMap<>();
|
||||||
List<Message> sentMessages = messageService.getMessagesByDirection(MessageDirection.OUTGOING)
|
|
||||||
.stream()
|
|
||||||
.filter(m -> currentUsername.equals(m.getSender()))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
List<Message> allMessages = new ArrayList<>();
|
for (Map.Entry<String, List<Message>> entry : groupedMessages.entrySet()) {
|
||||||
allMessages.addAll(receivedMessages);
|
String participantKey = entry.getKey();
|
||||||
allMessages.addAll(sentMessages);
|
AppUser participant = resolveAppUser(participantKey, appUserLookup);
|
||||||
|
if (participant == null) {
|
||||||
|
continue; // Only display app users of the current account holder
|
||||||
|
}
|
||||||
|
|
||||||
// Group messages by client (sender or receiver that is not the current user)
|
String participantId = participant.getIdAsString();
|
||||||
Map<String, List<Message>> messagesByClient = allMessages.stream()
|
if (participantId == null || participantId.isBlank()) {
|
||||||
.collect(Collectors.groupingBy(message -> {
|
continue;
|
||||||
// Determine the "other party" (client)
|
}
|
||||||
if (currentUsername.equals(message.getSender())) {
|
|
||||||
return message.getReceiver();
|
|
||||||
} else {
|
|
||||||
return message.getSender();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Create summaries for each client
|
ClientMessageSummary summary = summaryMap.computeIfAbsent(participantId,
|
||||||
List<ClientMessageSummary> summaries = new ArrayList<>();
|
id -> createEmptySummary(participant));
|
||||||
for (Map.Entry<String, List<Message>> entry : messagesByClient.entrySet()) {
|
|
||||||
String clientEmail = entry.getKey();
|
|
||||||
List<Message> clientMessages = entry.getValue();
|
|
||||||
|
|
||||||
// Find client info
|
List<Message> conversation = entry.getValue();
|
||||||
AppUser appUser = appUserService.findAll().stream()
|
if (conversation == null || conversation.isEmpty()) {
|
||||||
.filter(u -> clientEmail.equals(u.getEmail()))
|
continue;
|
||||||
.findFirst()
|
}
|
||||||
.orElse(null);
|
|
||||||
|
|
||||||
String clientName = appUser != null ?
|
conversation.sort(Comparator.comparing(Message::getCreatedAt,
|
||||||
appUser.getVorname() + " " + appUser.getNachname() : clientEmail;
|
Comparator.nullsLast(LocalDateTime::compareTo)).reversed());
|
||||||
String clientId = appUser != null ? appUser.getIdAsString() : clientEmail;
|
|
||||||
|
|
||||||
// Calculate statistics
|
Message latest = conversation.stream()
|
||||||
int totalMessages = clientMessages.size();
|
.filter(msg -> msg.getCreatedAt() != null)
|
||||||
long unreadCount = clientMessages.stream()
|
.findFirst()
|
||||||
.filter(m -> !m.isRead() && currentUsername.equals(m.getReceiver()))
|
.orElse(conversation.get(0));
|
||||||
.count();
|
|
||||||
|
|
||||||
// Get last message
|
LocalDateTime lastDate = latest.getCreatedAt();
|
||||||
Message lastMessage = clientMessages.stream()
|
String preview = Optional.ofNullable(latest.getContent()).filter(s -> !s.isBlank()).orElse("(kein Inhalt)");
|
||||||
.max(Comparator.comparing(Message::getCreatedAt))
|
int totalMessages = conversation.size();
|
||||||
.orElse(null);
|
int unreadCount = (int) conversation.stream()
|
||||||
|
.filter(msg -> msg.getDirection() == MessageDirection.INCOMING && !msg.isRead())
|
||||||
|
.count();
|
||||||
|
|
||||||
java.time.LocalDateTime lastMessageDate = lastMessage != null ? lastMessage.getCreatedAt() : null;
|
summary.setTotalMessages(summary.getTotalMessages() + totalMessages);
|
||||||
String lastMessagePreview = lastMessage != null ? lastMessage.getContent() : null;
|
summary.setUnreadCount(summary.getUnreadCount() + unreadCount);
|
||||||
|
|
||||||
summaries.add(new ClientMessageSummary(
|
LocalDateTime currentLast = summary.getLastMessageDate();
|
||||||
clientId,
|
if (lastDate != null && (currentLast == null || lastDate.isAfter(currentLast))) {
|
||||||
clientName,
|
summary.setLastMessageDate(lastDate);
|
||||||
clientEmail,
|
summary.setLastMessagePreview(preview);
|
||||||
totalMessages,
|
}
|
||||||
(int) unreadCount,
|
|
||||||
lastMessageDate,
|
|
||||||
lastMessagePreview
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by last message date (most recent first)
|
for (AppUser appUser : appUsers) {
|
||||||
summaries.sort((s1, s2) -> {
|
if (appUser == null) {
|
||||||
if (s1.getLastMessageDate() == null) return 1;
|
continue;
|
||||||
if (s2.getLastMessageDate() == null) return -1;
|
}
|
||||||
return s2.getLastMessageDate().compareTo(s1.getLastMessageDate());
|
String appUserId = appUser.getIdAsString();
|
||||||
});
|
if (appUserId != null && !summaryMap.containsKey(appUserId)) {
|
||||||
|
summaryMap.put(appUserId, createEmptySummary(appUser));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ClientMessageSummary> summaries = new ArrayList<>(summaryMap.values());
|
||||||
|
summaries.sort(Comparator.comparing(ClientMessageSummary::getLastMessageDate,
|
||||||
|
Comparator.nullsLast(LocalDateTime::compareTo)).reversed());
|
||||||
return summaries;
|
return summaries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String resolveParticipantKey(Message message) {
|
||||||
|
if (message == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (message.getDirection() == MessageDirection.INCOMING) {
|
||||||
|
return message.getSender();
|
||||||
|
}
|
||||||
|
return message.getReceiver();
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppUser resolveAppUser(String participantKey, Map<String, AppUser> 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<String, AppUser> buildAppUserLookup(List<AppUser> appUsers) {
|
||||||
|
Map<String, AppUser> 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<String, AppUser> 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,24 @@ import com.vaadin.flow.router.HasUrlParameter;
|
|||||||
import com.vaadin.flow.router.PageTitle;
|
import com.vaadin.flow.router.PageTitle;
|
||||||
import com.vaadin.flow.router.Route;
|
import com.vaadin.flow.router.Route;
|
||||||
import de.assecutor.votianlt.model.AppUser;
|
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.pages.service.AppUserService;
|
||||||
|
import de.assecutor.votianlt.service.MessageService;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
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)
|
@Route(value = "user-messages", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
||||||
@PageTitle("Nachrichten")
|
@PageTitle("Nachrichten")
|
||||||
@@ -31,13 +42,15 @@ import java.time.format.DateTimeFormatter;
|
|||||||
public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
||||||
|
|
||||||
private final AppUserService appUserService;
|
private final AppUserService appUserService;
|
||||||
|
private final MessageService messageService;
|
||||||
|
|
||||||
private String clientId;
|
private String participantKey;
|
||||||
private VerticalLayout contentLayout;
|
private VerticalLayout contentLayout;
|
||||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
|
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.appUserService = appUserService;
|
||||||
|
this.messageService = messageService;
|
||||||
|
|
||||||
// Create main layout
|
// Create main layout
|
||||||
contentLayout = new VerticalLayout();
|
contentLayout = new VerticalLayout();
|
||||||
@@ -50,7 +63,7 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setParameter(BeforeEvent event, String parameter) {
|
public void setParameter(BeforeEvent event, String parameter) {
|
||||||
this.clientId = parameter;
|
this.participantKey = parameter;
|
||||||
loadClientMessages();
|
loadClientMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,24 +73,25 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
// Get client info
|
// Get client info
|
||||||
AppUser client = null;
|
AppUser client = null;
|
||||||
try {
|
try {
|
||||||
ObjectId objectId = new ObjectId(clientId);
|
ObjectId objectId = new ObjectId(participantKey);
|
||||||
client = appUserService.findById(objectId);
|
client = appUserService.findById(objectId);
|
||||||
} catch (Exception e) {
|
} 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 ?
|
String clientName = client != null ?
|
||||||
client.getVorname() + " " + client.getNachname() : "Unbekannter Client";
|
client.getVorname() + " " + client.getNachname() : Optional.ofNullable(participantKey).orElse("Unbekannter Teilnehmer");
|
||||||
|
|
||||||
// Create header
|
|
||||||
HorizontalLayout headerLayout = createHeaderLayout(clientName);
|
HorizontalLayout headerLayout = createHeaderLayout(clientName);
|
||||||
contentLayout.add(headerLayout);
|
contentLayout.add(headerLayout);
|
||||||
|
|
||||||
// Create section for general messages (only one chat for general conversation)
|
List<Message> conversation = messageService.getMessagesForParticipantAscending(participantKey);
|
||||||
VerticalLayout generalSection = createGeneralMessagesSection();
|
Map<MessageType, List<Message>> messagesByType = conversation.stream()
|
||||||
|
.collect(Collectors.groupingBy(message -> Optional.ofNullable(message.getMessageType()).orElse(MessageType.GENERAL)));
|
||||||
|
|
||||||
// Create section for job-related messages
|
VerticalLayout generalSection = createGeneralMessagesSection(messagesByType.get(MessageType.GENERAL));
|
||||||
VerticalLayout jobSection = createJobMessagesSection();
|
|
||||||
|
VerticalLayout jobSection = createJobMessagesSection(messagesByType.get(MessageType.JOB_RELATED));
|
||||||
|
|
||||||
contentLayout.add(generalSection, jobSection);
|
contentLayout.add(generalSection, jobSection);
|
||||||
}
|
}
|
||||||
@@ -96,7 +110,7 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
return layout;
|
return layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
private VerticalLayout createGeneralMessagesSection() {
|
private VerticalLayout createGeneralMessagesSection(List<Message> generalMessages) {
|
||||||
VerticalLayout section = new VerticalLayout();
|
VerticalLayout section = new VerticalLayout();
|
||||||
section.setPadding(true);
|
section.setPadding(true);
|
||||||
section.setSpacing(true);
|
section.setSpacing(true);
|
||||||
@@ -108,20 +122,34 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
H3 title = new H3("Allgemeine Nachrichten");
|
H3 title = new H3("Allgemeine Nachrichten");
|
||||||
section.add(title);
|
section.add(title);
|
||||||
|
|
||||||
// Test data - only one general chat conversation
|
List<Message> 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(
|
section.add(createMessageCard(
|
||||||
"Allgemeine Unterhaltung",
|
"Allgemeine Unterhaltung",
|
||||||
"Hallo, wie geht es Ihnen?",
|
preview,
|
||||||
LocalDateTime.now().minusHours(2),
|
lastMessageTime,
|
||||||
5,
|
messageCount,
|
||||||
2,
|
unreadCount,
|
||||||
"general"
|
"general"
|
||||||
));
|
));
|
||||||
|
|
||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
|
|
||||||
private VerticalLayout createJobMessagesSection() {
|
private VerticalLayout createJobMessagesSection(List<Message> jobMessages) {
|
||||||
VerticalLayout section = new VerticalLayout();
|
VerticalLayout section = new VerticalLayout();
|
||||||
section.setPadding(true);
|
section.setPadding(true);
|
||||||
section.setSpacing(true);
|
section.setSpacing(true);
|
||||||
@@ -133,40 +161,38 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
H3 title = new H3("Nachrichten zu Aufträgen");
|
H3 title = new H3("Nachrichten zu Aufträgen");
|
||||||
section.add(title);
|
section.add(title);
|
||||||
|
|
||||||
// Test data - job-related messages
|
if (jobMessages == null || jobMessages.isEmpty()) {
|
||||||
section.add(createMessageCard(
|
section.add(new Span("Keine auftragsbezogenen Nachrichten vorhanden."));
|
||||||
"Auftrag #12345",
|
return section;
|
||||||
"Die Lieferung ist angekommen.",
|
}
|
||||||
LocalDateTime.now().minusHours(5),
|
|
||||||
8,
|
|
||||||
1,
|
|
||||||
"job-12345"
|
|
||||||
));
|
|
||||||
|
|
||||||
section.add(createMessageCard(
|
Map<String, List<Message>> messagesByJob = jobMessages.stream()
|
||||||
"Auftrag #12344",
|
.collect(Collectors.groupingBy(this::resolveJobKey, LinkedHashMap::new, Collectors.toList()));
|
||||||
"Bitte um Rückruf bezüglich der Abholung.",
|
|
||||||
LocalDateTime.now().minusDays(2),
|
|
||||||
12,
|
|
||||||
3,
|
|
||||||
"job-12344"
|
|
||||||
));
|
|
||||||
|
|
||||||
section.add(createMessageCard(
|
messagesByJob.forEach((jobKey, messages) -> {
|
||||||
"Auftrag #12343",
|
messages.sort(Comparator.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo)));
|
||||||
"Auftrag wurde erfolgreich abgeschlossen.",
|
Message latest = messages.get(messages.size() - 1);
|
||||||
LocalDateTime.now().minusDays(3),
|
int unreadCount = (int) messages.stream()
|
||||||
6,
|
.filter(message -> message.getDirection() == MessageDirection.INCOMING && !message.isRead())
|
||||||
0,
|
.count();
|
||||||
"job-12343"
|
|
||||||
));
|
String conversationTitle = "Auftrag " + jobKey;
|
||||||
|
section.add(createMessageCard(
|
||||||
|
conversationTitle,
|
||||||
|
Optional.ofNullable(latest.getContent()).orElse(""),
|
||||||
|
latest.getCreatedAt(),
|
||||||
|
messages.size(),
|
||||||
|
unreadCount,
|
||||||
|
"job-" + sanitizeConversationId(jobKey)
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Div createMessageCard(String conversationTitle, String lastMessagePreview,
|
private Div createMessageCard(String conversationTitle, String lastMessagePreview,
|
||||||
LocalDateTime lastMessageTime, int messageCount,
|
LocalDateTime lastMessageTime, int messageCount,
|
||||||
int unreadCount, String conversationId) {
|
int unreadCount, String conversationId) {
|
||||||
Div card = new Div();
|
Div card = new Div();
|
||||||
card.setWidthFull();
|
card.setWidthFull();
|
||||||
card.getStyle().set("padding", "15px");
|
card.getStyle().set("padding", "15px");
|
||||||
@@ -211,7 +237,7 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
titleRow.expand(titleSpan);
|
titleRow.expand(titleSpan);
|
||||||
|
|
||||||
// Preview text
|
// 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("color", "#666666");
|
||||||
preview.getStyle().set("font-size", "14px");
|
preview.getStyle().set("font-size", "14px");
|
||||||
|
|
||||||
@@ -219,7 +245,7 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
HorizontalLayout metaRow = new HorizontalLayout();
|
HorizontalLayout metaRow = new HorizontalLayout();
|
||||||
metaRow.setWidthFull();
|
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("color", "#999999");
|
||||||
timeSpan.getStyle().set("font-size", "12px");
|
timeSpan.getStyle().set("font-size", "12px");
|
||||||
|
|
||||||
@@ -238,10 +264,30 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
card.add(cardContent);
|
card.add(cardContent);
|
||||||
|
|
||||||
// Click listener to navigate to message details
|
// Click listener to navigate to message details
|
||||||
card.addClickListener(e -> {
|
card.addClickListener(e -> UI.getCurrent().navigate("message-details/" + participantKey + "/" + conversationId));
|
||||||
UI.getCurrent().navigate("message-details/" + clientId + "/" + conversationId);
|
|
||||||
});
|
|
||||||
|
|
||||||
return card;
|
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_-]", "_");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,16 @@ public interface MessageRepository extends MongoRepository<Message, ObjectId> {
|
|||||||
* Find all messages (for admin/overview), ordered by creation time descending
|
* Find all messages (for admin/overview), ordered by creation time descending
|
||||||
*/
|
*/
|
||||||
List<Message> findAllByOrderByCreatedAtDesc();
|
List<Message> findAllByOrderByCreatedAtDesc();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all messages where the sender or receiver matches the provided value,
|
||||||
|
* ordered by creation time ascending.
|
||||||
|
*/
|
||||||
|
List<Message> findBySenderOrReceiverOrderByCreatedAtAsc(String sender, String receiver);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all messages where the sender or receiver matches the provided value,
|
||||||
|
* ordered by creation time descending.
|
||||||
|
*/
|
||||||
|
List<Message> findBySenderOrReceiverOrderByCreatedAtDesc(String sender, String receiver);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
package de.assecutor.votianlt.service;
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import de.assecutor.votianlt.model.Message;
|
import de.assecutor.votianlt.model.Message;
|
||||||
import de.assecutor.votianlt.model.MessageDirection;
|
import de.assecutor.votianlt.model.MessageDirection;
|
||||||
import de.assecutor.votianlt.model.MessageType;
|
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.mqtt.MqttPublisher;
|
||||||
import de.assecutor.votianlt.repository.MessageRepository;
|
import de.assecutor.votianlt.repository.MessageRepository;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -22,12 +21,9 @@ public class MessageService {
|
|||||||
|
|
||||||
private final MessageRepository messageRepository;
|
private final MessageRepository messageRepository;
|
||||||
private final MqttPublisher mqttPublisher;
|
private final MqttPublisher mqttPublisher;
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
public MessageService(MessageRepository messageRepository, MqttPublisher mqttPublisher) {
|
public MessageService(MessageRepository messageRepository, MqttPublisher mqttPublisher) {
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
this.mqttPublisher = mqttPublisher;
|
this.mqttPublisher = mqttPublisher;
|
||||||
this.objectMapper = new ObjectMapper();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,13 +58,14 @@ public class MessageService {
|
|||||||
/**
|
/**
|
||||||
* Handle incoming message from a client
|
* Handle incoming message from a client
|
||||||
*/
|
*/
|
||||||
public Message receiveMessageFromClient(String content, String sender, String receiver,
|
public Message receiveMessageFromClient(ChatMessageInboundPayload payload) {
|
||||||
ObjectId jobId, String jobNumber) {
|
|
||||||
Message message;
|
Message message;
|
||||||
if (jobId != null) {
|
if (payload.hasJobContext()) {
|
||||||
message = new Message(content, sender, receiver, MessageDirection.INCOMING, jobId, jobNumber);
|
message = new Message(payload.content(), payload.sender(), payload.receiver(),
|
||||||
|
MessageDirection.INCOMING, payload.jobId(), payload.jobNumber());
|
||||||
} else {
|
} else {
|
||||||
message = new Message(content, sender, receiver, MessageDirection.INCOMING);
|
message = new Message(payload.content(), payload.sender(), payload.receiver(),
|
||||||
|
MessageDirection.INCOMING);
|
||||||
}
|
}
|
||||||
return saveMessage(message);
|
return saveMessage(message);
|
||||||
}
|
}
|
||||||
@@ -79,18 +76,7 @@ public class MessageService {
|
|||||||
private void publishMessageToMqtt(Message message, String receiver) {
|
private void publishMessageToMqtt(Message message, String receiver) {
|
||||||
try {
|
try {
|
||||||
String topic = "/client/" + receiver + "/message";
|
String topic = "/client/" + receiver + "/message";
|
||||||
Map<String, Object> payload = new HashMap<>();
|
ChatMessageOutboundPayload payload = ChatMessageOutboundPayload.fromMessage(message);
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
mqttPublisher.publishAsJson(topic, payload, false);
|
mqttPublisher.publishAsJson(topic, payload, false);
|
||||||
log.info("Published message to MQTT topic: {}", topic);
|
log.info("Published message to MQTT topic: {}", topic);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -126,6 +112,28 @@ public class MessageService {
|
|||||||
return messageRepository.findByReceiverAndJobIdOrderByCreatedAtDesc(receiver, jobId);
|
return messageRepository.findByReceiverAndJobIdOrderByCreatedAtDesc(receiver, jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all messages a participant sent or received (oldest first) to reconstruct
|
||||||
|
* the conversation timeline.
|
||||||
|
*/
|
||||||
|
public List<Message> 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<Message> getMessagesForParticipantDescending(String participant) {
|
||||||
|
if (participant == null || participant.isBlank()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
return messageRepository.findBySenderOrReceiverOrderByCreatedAtDesc(participant, participant);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all general messages for a receiver
|
* Get all general messages for a receiver
|
||||||
*/
|
*/
|
||||||
|
|||||||
7
src/main/resources/mqtt/chat/incoming-chat-message.json
Normal file
7
src/main/resources/mqtt/chat/incoming-chat-message.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"sender": "driver01",
|
||||||
|
"receiver": "dispatcher01",
|
||||||
|
"content": "Ankunft in 10 Minuten.",
|
||||||
|
"jobId": "665f1c601971c8390b29f1f5",
|
||||||
|
"jobNumber": "JOB-2024-00042"
|
||||||
|
}
|
||||||
12
src/main/resources/mqtt/chat/outgoing-chat-message.json
Normal file
12
src/main/resources/mqtt/chat/outgoing-chat-message.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user