Erweiterungen

This commit is contained in:
2025-10-02 11:26:15 +02:00
parent 8d3f8208e1
commit 4e380f52a3
12 changed files with 1758 additions and 1 deletions

View File

@@ -0,0 +1,231 @@
package de.assecutor.votianlt.controller;
import de.assecutor.votianlt.model.Message;
import de.assecutor.votianlt.model.MessageDirection;
import de.assecutor.votianlt.service.MessageService;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* REST API controller for message operations.
* Provides endpoints for sending messages, retrieving messages, and marking messages as read.
*/
@RestController
@RequestMapping("/api/messages")
@Slf4j
public class MessageApiController {
private final MessageService messageService;
public MessageApiController(MessageService messageService) {
this.messageService = messageService;
}
/**
* Send a general message to a client
* POST /api/messages/send
* Body: { "content": "message text", "sender": "username", "receiver": "username" }
*/
@PostMapping("/send")
public ResponseEntity<Message> sendGeneralMessage(@RequestBody Map<String, String> request) {
try {
String content = request.get("content");
String sender = request.get("sender");
String receiver = request.get("receiver");
if (content == null || content.isBlank() ||
sender == null || sender.isBlank() ||
receiver == null || receiver.isBlank()) {
log.warn("Invalid message request: missing required fields");
return ResponseEntity.badRequest().build();
}
Message message = messageService.sendGeneralMessageToClient(content, sender, receiver);
log.info("General message sent from {} to {}", sender, receiver);
return ResponseEntity.ok(message);
} catch (Exception e) {
log.error("Error sending general message: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Send a job-related message to a client
* POST /api/messages/send-job-message
* Body: { "content": "message text", "sender": "username", "receiver": "username",
* "jobId": "job id", "jobNumber": "job number" }
*/
@PostMapping("/send-job-message")
public ResponseEntity<Message> sendJobMessage(@RequestBody Map<String, String> request) {
try {
String content = request.get("content");
String sender = request.get("sender");
String receiver = request.get("receiver");
String jobIdStr = request.get("jobId");
String jobNumber = request.get("jobNumber");
if (content == null || content.isBlank() ||
sender == null || sender.isBlank() ||
receiver == null || receiver.isBlank() ||
jobIdStr == null || jobIdStr.isBlank()) {
log.warn("Invalid job message request: missing required fields");
return ResponseEntity.badRequest().build();
}
ObjectId jobId = new ObjectId(jobIdStr);
Message message = messageService.sendJobMessageToClient(content, sender, receiver, jobId, jobNumber);
log.info("Job-related message sent from {} to {} for job {}", sender, receiver, jobNumber);
return ResponseEntity.ok(message);
} catch (IllegalArgumentException e) {
log.error("Invalid jobId format: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Error sending job message: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Get all messages for a specific receiver
* GET /api/messages/receiver/{username}
*/
@GetMapping("/receiver/{username}")
public ResponseEntity<List<Message>> getMessagesForReceiver(@PathVariable String username) {
try {
List<Message> messages = messageService.getMessagesForReceiver(username);
return ResponseEntity.ok(messages);
} catch (Exception e) {
log.error("Error retrieving messages for receiver {}: {}", username, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Get all unread messages for a specific receiver
* GET /api/messages/receiver/{username}/unread
*/
@GetMapping("/receiver/{username}/unread")
public ResponseEntity<List<Message>> getUnreadMessagesForReceiver(@PathVariable String username) {
try {
List<Message> messages = messageService.getUnreadMessagesForReceiver(username);
return ResponseEntity.ok(messages);
} catch (Exception e) {
log.error("Error retrieving unread messages for receiver {}: {}", username, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Get unread message count for a specific receiver
* GET /api/messages/receiver/{username}/unread-count
*/
@GetMapping("/receiver/{username}/unread-count")
public ResponseEntity<Map<String, Long>> getUnreadMessageCount(@PathVariable String username) {
try {
long count = messageService.getUnreadMessageCount(username);
return ResponseEntity.ok(Map.of("count", count));
} catch (Exception e) {
log.error("Error retrieving unread message count for receiver {}: {}", username, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Get all messages related to a specific job
* GET /api/messages/job/{jobId}
*/
@GetMapping("/job/{jobId}")
public ResponseEntity<List<Message>> getMessagesForJob(@PathVariable String jobId) {
try {
ObjectId objectId = new ObjectId(jobId);
List<Message> messages = messageService.getMessagesForJob(objectId);
return ResponseEntity.ok(messages);
} catch (IllegalArgumentException e) {
log.error("Invalid jobId format: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Error retrieving messages for job {}: {}", jobId, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Get all messages (for admin/overview)
* GET /api/messages/all
*/
@GetMapping("/all")
public ResponseEntity<List<Message>> getAllMessages() {
try {
List<Message> messages = messageService.getAllMessages();
return ResponseEntity.ok(messages);
} catch (Exception e) {
log.error("Error retrieving all messages: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Get messages by direction (incoming/outgoing)
* GET /api/messages/direction/{direction}
*/
@GetMapping("/direction/{direction}")
public ResponseEntity<List<Message>> getMessagesByDirection(@PathVariable String direction) {
try {
MessageDirection messageDirection = MessageDirection.valueOf(direction.toUpperCase());
List<Message> messages = messageService.getMessagesByDirection(messageDirection);
return ResponseEntity.ok(messages);
} catch (IllegalArgumentException e) {
log.error("Invalid direction: {}", direction);
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Error retrieving messages by direction {}: {}", direction, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Mark a message as read
* PUT /api/messages/{messageId}/mark-read
*/
@PutMapping("/{messageId}/mark-read")
public ResponseEntity<Void> markMessageAsRead(@PathVariable String messageId) {
try {
ObjectId objectId = new ObjectId(messageId);
messageService.markAsRead(objectId);
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
log.error("Invalid messageId format: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Error marking message as read {}: {}", messageId, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Delete a message
* DELETE /api/messages/{messageId}
*/
@DeleteMapping("/{messageId}")
public ResponseEntity<Void> deleteMessage(@PathVariable String messageId) {
try {
ObjectId objectId = new ObjectId(messageId);
messageService.deleteMessage(objectId);
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
log.error("Invalid messageId format: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Error deleting message {}: {}", messageId, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}

View File

@@ -22,6 +22,7 @@ import de.assecutor.votianlt.model.Signature;
import de.assecutor.votianlt.model.Comment; import de.assecutor.votianlt.model.Comment;
import de.assecutor.votianlt.service.JobHistoryService; import de.assecutor.votianlt.service.JobHistoryService;
import de.assecutor.votianlt.service.EmailService; import de.assecutor.votianlt.service.EmailService;
import de.assecutor.votianlt.service.MessageService;
import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.JobStatus;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -64,11 +65,13 @@ public class MessageController {
private final CommentRepository commentRepository; private final CommentRepository commentRepository;
private final JobHistoryService jobHistoryService; private final JobHistoryService jobHistoryService;
private final EmailService emailService; private final EmailService emailService;
private final MessageService messageService;
public MessageController(MqttPublisher mqttPublisher, AppUserRepository appUserRepository, public MessageController(MqttPublisher mqttPublisher, AppUserRepository appUserRepository,
AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository, AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository,
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository, TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
SignatureRepository signatureRepository, CommentRepository commentRepository, JobHistoryService jobHistoryService, EmailService emailService) { SignatureRepository signatureRepository, CommentRepository commentRepository, JobHistoryService jobHistoryService,
EmailService emailService, MessageService messageService) {
this.mqttPublisher = mqttPublisher; this.mqttPublisher = mqttPublisher;
this.appUserRepository = appUserRepository; this.appUserRepository = appUserRepository;
this.appUserService = appUserService; this.appUserService = appUserService;
@@ -81,6 +84,7 @@ public class MessageController {
this.commentRepository = commentRepository; this.commentRepository = commentRepository;
this.jobHistoryService = jobHistoryService; this.jobHistoryService = jobHistoryService;
this.emailService = emailService; this.emailService = emailService;
this.messageService = messageService;
} }
/** /**
@@ -589,4 +593,61 @@ public class MessageController {
private String getClientIdForUserId(String userId) { private String getClientIdForUserId(String userId) {
return userClientIdMapping.get(userId); return userClientIdMapping.get(userId);
} }
/**
* Handle incoming message from a client via MQTT.
* Client sends to /server/{clientId}/message with payload:
* {
* "sender": "appUserUsername",
* "receiver": "systemUserUsername",
* "content": "message text",
* "jobId": "optional job id",
* "jobNumber": "optional job number"
* }
*/
public void handleIncomingMessage(Map<String, Object> payload) {
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);
} catch (Exception e) {
log.error("Error handling incoming message: {}", e.getMessage(), e);
}
}
} }

View File

@@ -0,0 +1,24 @@
package de.assecutor.votianlt.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* DTO for summarizing message conversations by client
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ClientMessageSummary {
private String clientId;
private String clientName;
private String clientEmail;
private int totalMessages;
private int unreadCount;
private LocalDateTime lastMessageDate;
private String lastMessagePreview;
}

View File

@@ -0,0 +1,139 @@
package de.assecutor.votianlt.model;
import com.fasterxml.jackson.annotation.JsonGetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import java.time.LocalDateTime;
/**
* Represents a message that can be sent between the server and clients.
* Messages can be either job-related or general messages.
*/
@Data
@NoArgsConstructor
@Document(collection = "messages")
public class Message {
@Id
@JsonIgnore
private ObjectId id;
/**
* Content of the message
*/
@Field("content")
private String content;
/**
* Username of the sender (app user or system user)
*/
@Field("sender")
private String sender;
/**
* Username of the receiver (app user or system user)
*/
@Field("receiver")
private String receiver;
/**
* Timestamp when the message was created
*/
@Field("created_at")
private LocalDateTime createdAt;
/**
* Direction of the message: INCOMING (from client) or OUTGOING (to client)
*/
@Field("direction")
private MessageDirection direction;
/**
* Type of message: JOB_RELATED or GENERAL
*/
@Field("message_type")
private MessageType messageType;
/**
* Optional reference to a job (only for job-related messages)
*/
@Field("job_id")
private ObjectId jobId;
/**
* Optional job number for easier reference (denormalized)
*/
@Field("job_number")
private String jobNumber;
/**
* Whether the message has been read by the receiver
*/
@Field("is_read")
private boolean isRead;
/**
* Timestamp when the message was read
*/
@Field("read_at")
private LocalDateTime readAt;
/**
* Constructor for general messages
*/
public Message(String content, String sender, String receiver, MessageDirection direction) {
this.content = content;
this.sender = sender;
this.receiver = receiver;
this.direction = direction;
this.messageType = MessageType.GENERAL;
this.createdAt = LocalDateTime.now();
this.isRead = false;
}
/**
* Constructor for job-related messages
*/
public Message(String content, String sender, String receiver, MessageDirection direction,
ObjectId jobId, String jobNumber) {
this.content = content;
this.sender = sender;
this.receiver = receiver;
this.direction = direction;
this.messageType = MessageType.JOB_RELATED;
this.jobId = jobId;
this.jobNumber = jobNumber;
this.createdAt = LocalDateTime.now();
this.isRead = false;
}
/**
* Mark the message as read
*/
public void markAsRead() {
this.isRead = true;
this.readAt = LocalDateTime.now();
}
/**
* Returns the ObjectId as string for JSON serialization
*/
@JsonGetter("id")
public String getIdAsString() {
return id != null ? id.toString() : null;
}
/**
* Returns the job ObjectId as string for JSON serialization
*/
@JsonGetter("jobId")
public String getJobIdAsString() {
return jobId != null ? jobId.toString() : null;
}
}

View File

@@ -0,0 +1,16 @@
package de.assecutor.votianlt.model;
/**
* Enum representing the direction of a message
*/
public enum MessageDirection {
/**
* Message received from a client (app user)
*/
INCOMING,
/**
* Message sent to a client (app user)
*/
OUTGOING
}

View File

@@ -0,0 +1,16 @@
package de.assecutor.votianlt.model;
/**
* Enum representing the type of message
*/
public enum MessageType {
/**
* General message not related to a specific job
*/
GENERAL,
/**
* Message related to a specific job
*/
JOB_RELATED
}

View File

@@ -163,6 +163,13 @@ public class MqttV5ClientManager implements SmartLifecycle {
de.assecutor.votianlt.dto.AppLoginRequest req = om.convertValue(payload, de.assecutor.votianlt.dto.AppLoginRequest req = om.convertValue(payload,
de.assecutor.votianlt.dto.AppLoginRequest.class); de.assecutor.votianlt.dto.AppLoginRequest.class);
messageController.handleAppLogin(req); messageController.handleAppLogin(req);
} else if (topic.matches("/server/.+/message")) {
try {
// Handle incoming message from client
messageController.handleIncomingMessage(payload);
} catch (Exception e) {
log.error("Error handling incoming message on {}: {}", topic, e.getMessage(), e);
}
} else { } else {
log.debug("No route for topic {}", topic); log.debug("No route for topic {}", topic);
} }

View File

@@ -0,0 +1,362 @@
package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.html.Div;
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.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.Scroller;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver;
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.pages.service.AppUserService;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Route(value = "message-details/:clientId/:conversationId", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@PageTitle("Nachrichtenverlauf")
@RolesAllowed("USER")
@Slf4j
public class MessageDetailsView extends Main implements BeforeEnterObserver {
private final AppUserService appUserService;
private String clientId;
private String conversationId;
private VerticalLayout contentLayout;
private VerticalLayout messagesContainer;
private Scroller messagesScroller;
private Div scrollAnchor; // Marker element at the end of messages for scrolling
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) {
this.appUserService = appUserService;
// Set height to 100% to prevent page from growing beyond viewport
setHeightFull();
// Create main layout with fixed positioning
contentLayout = new VerticalLayout();
contentLayout.setPadding(true);
contentLayout.setSpacing(true);
contentLayout.setWidthFull();
contentLayout.setHeightFull();
contentLayout.getStyle().set("overflow", "hidden");
add(contentLayout);
}
@Override
public void beforeEnter(BeforeEnterEvent event) {
// Extract route parameters from URL
RouteParameters parameters = event.getRouteParameters();
this.clientId = 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);
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);
}
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
HorizontalLayout headerLayout = createHeaderLayout(clientName, conversationTitle);
contentLayout.add(headerLayout);
// Create messages container (content for scrollable area)
messagesContainer = new VerticalLayout();
messagesContainer.setPadding(true);
messagesContainer.setSpacing(true);
messagesContainer.setWidthFull();
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
messagesScroller = new Scroller(messagesContainer);
messagesScroller.setWidthFull();
messagesScroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
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));
VerticalLayout titleLayout = new VerticalLayout();
titleLayout.setPadding(false);
titleLayout.setSpacing(false);
H2 title = new H2(clientName);
title.getStyle().set("margin", "0");
Span subtitle = new Span(conversationTitle);
subtitle.getStyle().set("color", "#666666");
subtitle.getStyle().set("font-size", "14px");
titleLayout.add(title, subtitle);
HorizontalLayout layout = new HorizontalLayout(backButton, titleLayout);
layout.setWidthFull();
layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER);
layout.setSpacing(true);
return layout;
}
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();
separator.getStyle().set("text-align", "center");
separator.getStyle().set("margin", "20px 0");
Span dateSpan = new Span(date.format(DATE_FORMATTER));
dateSpan.getStyle().set("background-color", "#e0e0e0");
dateSpan.getStyle().set("padding", "5px 15px");
dateSpan.getStyle().set("border-radius", "15px");
dateSpan.getStyle().set("font-size", "12px");
dateSpan.getStyle().set("color", "#666666");
separator.add(dateSpan);
return separator;
}
private Div createIncomingMessage(String content, LocalDateTime timestamp) {
Div messageWrapper = new Div();
messageWrapper.setWidthFull();
messageWrapper.getStyle().set("display", "flex");
messageWrapper.getStyle().set("justify-content", "flex-start");
messageWrapper.getStyle().set("margin-bottom", "10px");
Div messageBubble = new Div();
messageBubble.getStyle().set("max-width", "60%");
messageBubble.getStyle().set("background-color", "#ffffff");
messageBubble.getStyle().set("padding", "10px 15px");
messageBubble.getStyle().set("border-radius", "18px");
messageBubble.getStyle().set("box-shadow", "0 1px 2px rgba(0,0,0,0.1)");
Span contentSpan = new Span(content);
contentSpan.getStyle().set("display", "block");
contentSpan.getStyle().set("word-wrap", "break-word");
Span timeSpan = new Span(timestamp.format(TIME_FORMATTER));
timeSpan.getStyle().set("font-size", "11px");
timeSpan.getStyle().set("color", "#999999");
timeSpan.getStyle().set("display", "block");
timeSpan.getStyle().set("text-align", "right");
timeSpan.getStyle().set("margin-top", "5px");
messageBubble.add(contentSpan, timeSpan);
messageWrapper.add(messageBubble);
return messageWrapper;
}
private Div createOutgoingMessage(String content, LocalDateTime timestamp) {
Div messageWrapper = new Div();
messageWrapper.setWidthFull();
messageWrapper.getStyle().set("display", "flex");
messageWrapper.getStyle().set("justify-content", "flex-end");
messageWrapper.getStyle().set("margin-bottom", "10px");
Div messageBubble = new Div();
messageBubble.getStyle().set("max-width", "60%");
messageBubble.getStyle().set("background-color", "#dcf8c6");
messageBubble.getStyle().set("padding", "10px 15px");
messageBubble.getStyle().set("border-radius", "18px");
messageBubble.getStyle().set("box-shadow", "0 1px 2px rgba(0,0,0,0.1)");
Span contentSpan = new Span(content);
contentSpan.getStyle().set("display", "block");
contentSpan.getStyle().set("word-wrap", "break-word");
Span timeSpan = new Span(timestamp.format(TIME_FORMATTER));
timeSpan.getStyle().set("font-size", "11px");
timeSpan.getStyle().set("color", "#666666");
timeSpan.getStyle().set("display", "block");
timeSpan.getStyle().set("text-align", "right");
timeSpan.getStyle().set("margin-top", "5px");
messageBubble.add(contentSpan, timeSpan);
messageWrapper.add(messageBubble);
return messageWrapper;
}
private HorizontalLayout createMessageInputArea() {
TextArea messageInput = new TextArea();
messageInput.setPlaceholder("Nachricht eingeben...");
messageInput.setWidthFull();
messageInput.getStyle().set("min-height", "60px");
messageInput.getStyle().set("max-height", "120px");
Button sendButton = new Button("Senden", VaadinIcon.PAPERPLANE.create());
sendButton.addThemeVariants(com.vaadin.flow.component.button.ButtonVariant.LUMO_PRIMARY);
sendButton.getStyle().set("height", "60px");
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()));
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;
}
/**
* Scroll the messages scroller to the bottom to show the latest message
* Uses scrollIntoView on the anchor element at the end of messages
*/
private void scrollToBottom() {
if (scrollAnchor != null) {
// Use beforeClientResponse to ensure all components are rendered and DOM is ready
UI.getCurrent().beforeClientResponse(scrollAnchor, context -> {
// Use scrollIntoView on the anchor element - this is more reliable than
// trying to manipulate scrollTop/scrollHeight
// Multiple delayed attempts ensure content is fully rendered and laid out
scrollAnchor.getElement().executeJs(
"const anchor = this;" +
"console.log('Scroll anchor found:', anchor);" +
// First attempt after 50ms - instant scroll
"setTimeout(() => {" +
" anchor.scrollIntoView({ behavior: 'instant', block: 'end' });" +
" console.log('Scroll attempt 1: scrollIntoView called (instant)');" +
"}, 50);" +
// Second attempt after 200ms - instant scroll
"setTimeout(() => {" +
" anchor.scrollIntoView({ behavior: 'instant', block: 'end' });" +
" console.log('Scroll attempt 2: scrollIntoView called (instant)');" +
"}, 200);" +
// Third attempt after 500ms - instant scroll
"setTimeout(() => {" +
" anchor.scrollIntoView({ behavior: 'instant', block: 'end' });" +
" console.log('Scroll attempt 3: scrollIntoView called (instant)');" +
"}, 500);"
);
});
}
}
}

View File

@@ -0,0 +1,397 @@
package de.assecutor.votianlt.pages.view;
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.combobox.ComboBox;
import com.vaadin.flow.component.dialog.Dialog;
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;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.router.Menu;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.dto.ClientMessageSummary;
import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.Message;
import de.assecutor.votianlt.model.MessageDirection;
import de.assecutor.votianlt.model.User;
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 org.bson.types.ObjectId;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
@Route(value = "messages", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@PageTitle("Nachrichten")
@Menu(order = 1, icon = "vaadin:envelope", title = "Nachrichten")
@RolesAllowed("USER")
@Slf4j
public class MessagesView extends Main {
private final MessageService messageService;
private final SecurityService securityService;
private final AppUserService appUserService;
private final JobRepository jobRepository;
private Grid<ClientMessageSummary> clientGrid;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
public MessagesView(MessageService messageService, SecurityService securityService,
AppUserService appUserService, JobRepository jobRepository) {
this.messageService = messageService;
this.securityService = securityService;
this.appUserService = appUserService;
this.jobRepository = jobRepository;
// Create main layout
VerticalLayout layout = new VerticalLayout();
layout.setPadding(true);
layout.setSpacing(true);
layout.setWidthFull();
// Add title and action buttons
HorizontalLayout headerLayout = createHeaderLayout();
// Create client grid
clientGrid = createClientGrid();
// Add components to layout
layout.add(headerLayout, clientGrid);
// Add layout to main view
add(layout);
// Load client summaries
loadClientSummaries();
}
private HorizontalLayout createHeaderLayout() {
H2 title = new H2("Nachrichten nach Client");
Button sendMessageButton = new Button("Nachricht senden", VaadinIcon.ENVELOPE_O.create());
sendMessageButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
sendMessageButton.addClickListener(e -> openSendMessageDialog());
Button refreshButton = new Button("Aktualisieren", VaadinIcon.REFRESH.create());
refreshButton.addClickListener(e -> loadClientSummaries());
HorizontalLayout layout = new HorizontalLayout(title, sendMessageButton, refreshButton);
layout.setWidthFull();
layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER);
layout.expand(title);
return layout;
}
private Grid<ClientMessageSummary> createClientGrid() {
Grid<ClientMessageSummary> grid = new Grid<>(ClientMessageSummary.class, false);
grid.setWidthFull();
grid.setHeight("600px");
// Add columns
grid.addColumn(new ComponentRenderer<>(summary -> {
Span span = new Span(summary.getUnreadCount() > 0 ? "" : "");
span.getStyle().set("font-size", "20px");
if (summary.getUnreadCount() > 0) {
span.getStyle().set("color", "var(--lumo-primary-color)");
}
return span;
})).setHeader("Status").setWidth("80px").setFlexGrow(0);
grid.addColumn(ClientMessageSummary::getClientName).setHeader("Client").setAutoWidth(true);
grid.addColumn(ClientMessageSummary::getClientEmail).setHeader("E-Mail").setAutoWidth(true);
grid.addColumn(new ComponentRenderer<>(summary -> {
Span span = new Span(String.valueOf(summary.getTotalMessages()));
return span;
})).setHeader("Nachrichten").setWidth("120px").setFlexGrow(0);
grid.addColumn(new ComponentRenderer<>(summary -> {
if (summary.getUnreadCount() > 0) {
Span span = new Span(String.valueOf(summary.getUnreadCount()));
span.getStyle().set("color", "var(--lumo-primary-color)");
span.getStyle().set("font-weight", "bold");
return span;
}
return new Span("0");
})).setHeader("Ungelesen").setWidth("100px").setFlexGrow(0);
grid.addColumn(summary ->
summary.getLastMessageDate() != null ? summary.getLastMessageDate().format(DATE_FORMATTER) : "-"
).setHeader("Letzte Nachricht").setAutoWidth(true);
grid.addColumn(new ComponentRenderer<>(summary -> {
String preview = summary.getLastMessagePreview();
if (preview != null && preview.length() > 50) {
preview = preview.substring(0, 47) + "...";
}
return new Span(preview != null ? preview : "-");
})).setHeader("Vorschau").setAutoWidth(true);
// Add click listener to navigate to UserMessagesView
grid.addItemClickListener(event -> {
ClientMessageSummary summary = event.getItem();
UI.getCurrent().navigate("user-messages/" + summary.getClientId());
});
return grid;
}
private void loadClientSummaries() {
try {
// Generate test data for display
List<ClientMessageSummary> summaries = generateTestData();
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);
}
}
private List<ClientMessageSummary> generateTestData() {
List<ClientMessageSummary> 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?"
));
return summaries;
}
private List<ClientMessageSummary> aggregateMessagesByClient(String currentUsername) {
// Get all messages for current user (received and sent)
List<Message> receivedMessages = messageService.getMessagesForReceiver(currentUsername);
List<Message> sentMessages = messageService.getMessagesByDirection(MessageDirection.OUTGOING)
.stream()
.filter(m -> currentUsername.equals(m.getSender()))
.toList();
List<Message> allMessages = new ArrayList<>();
allMessages.addAll(receivedMessages);
allMessages.addAll(sentMessages);
// Group messages by client (sender or receiver that is not the current user)
Map<String, List<Message>> 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<ClientMessageSummary> summaries = new ArrayList<>();
for (Map.Entry<String, List<Message>> entry : messagesByClient.entrySet()) {
String clientEmail = entry.getKey();
List<Message> 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
));
}
// 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;
}
private void openSendMessageDialog() {
Dialog dialog = new Dialog();
dialog.setHeaderTitle("Neue Nachricht senden");
dialog.setWidth("600px");
VerticalLayout layout = new VerticalLayout();
layout.setPadding(false);
layout.setSpacing(true);
// Receiver selection
ComboBox<AppUser> receiverCombo = new ComboBox<>("Empfänger (App-Benutzer)");
receiverCombo.setWidthFull();
receiverCombo.setItems(appUserService.findAll());
receiverCombo.setItemLabelGenerator(appUser ->
appUser.getVorname() + " " + appUser.getNachname() + " (" + appUser.getEmail() + ")"
);
// Job selection (optional)
ComboBox<Job> jobCombo = new ComboBox<>("Auftrag (optional)");
jobCombo.setWidthFull();
jobCombo.setItems(jobRepository.findAll());
jobCombo.setItemLabelGenerator(job ->
job.getJobNumber() + " - " + (job.getPickupCity() != null ? job.getPickupCity() : "")
);
// Message content
TextArea contentArea = new TextArea("Nachricht");
contentArea.setWidthFull();
contentArea.setHeight("200px");
layout.add(receiverCombo, jobCombo, contentArea);
// Buttons
Button sendButton = new Button("Senden", e -> {
if (receiverCombo.getValue() == null || contentArea.getValue().isBlank()) {
Notification.show("Bitte Empfänger und Nachricht eingeben", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
sendMessage(receiverCombo.getValue(), jobCombo.getValue(), contentArea.getValue());
dialog.close();
});
sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button cancelButton = new Button("Abbrechen", e -> dialog.close());
dialog.getFooter().add(cancelButton, sendButton);
dialog.add(layout);
dialog.open();
}
private void sendMessage(AppUser receiver, Job job, String content) {
try {
User currentUser = securityService.getCurrentDatabaseUser();
if (currentUser == null) {
Notification.show("Benutzer nicht gefunden", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
String sender = currentUser.getEmail();
String receiverUsername = receiver.getEmail(); // Using email as username for app users
if (job != null) {
messageService.sendJobMessageToClient(content, sender, receiverUsername,
job.getId(), job.getJobNumber());
} else {
messageService.sendGeneralMessageToClient(content, sender, receiverUsername);
}
Notification.show("Nachricht erfolgreich gesendet", 2000, Notification.Position.BOTTOM_START)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
loadClientSummaries();
} catch (Exception e) {
log.error("Error sending message: {}", e.getMessage(), e);
Notification.show("Fehler beim Senden der Nachricht", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
}

View File

@@ -0,0 +1,247 @@
package de.assecutor.votianlt.pages.view;
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.html.Div;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.H3;
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.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.BeforeEvent;
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.pages.service.AppUserService;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Route(value = "user-messages", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@PageTitle("Nachrichten")
@RolesAllowed("USER")
@Slf4j
public class UserMessagesView extends Main implements HasUrlParameter<String> {
private final AppUserService appUserService;
private String clientId;
private VerticalLayout contentLayout;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
public UserMessagesView(AppUserService appUserService) {
this.appUserService = appUserService;
// Create main layout
contentLayout = new VerticalLayout();
contentLayout.setPadding(true);
contentLayout.setSpacing(true);
contentLayout.setWidthFull();
add(contentLayout);
}
@Override
public void setParameter(BeforeEvent event, String parameter) {
this.clientId = parameter;
loadClientMessages();
}
private void loadClientMessages() {
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);
}
String clientName = client != null ?
client.getVorname() + " " + client.getNachname() : "Unbekannter Client";
// Create header
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();
contentLayout.add(generalSection, jobSection);
}
private HorizontalLayout createHeaderLayout(String clientName) {
Button backButton = new Button("Zurück", VaadinIcon.ARROW_LEFT.create());
backButton.addClickListener(e -> UI.getCurrent().navigate("messages"));
H2 title = new H2("Nachrichten mit " + clientName);
HorizontalLayout layout = new HorizontalLayout(backButton, title);
layout.setWidthFull();
layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER);
layout.setSpacing(true);
return layout;
}
private VerticalLayout createGeneralMessagesSection() {
VerticalLayout section = new VerticalLayout();
section.setPadding(true);
section.setSpacing(true);
section.getStyle().set("border", "1px solid #e0e0e0");
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
section.add(createMessageCard(
"Allgemeine Unterhaltung",
"Hallo, wie geht es Ihnen?",
LocalDateTime.now().minusHours(2),
5,
2,
"general"
));
return section;
}
private VerticalLayout createJobMessagesSection() {
VerticalLayout section = new VerticalLayout();
section.setPadding(true);
section.setSpacing(true);
section.getStyle().set("border", "1px solid #e0e0e0");
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"
));
return section;
}
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");
card.getStyle().set("border", "1px solid #e0e0e0");
card.getStyle().set("border-radius", "8px");
card.getStyle().set("cursor", "pointer");
card.getStyle().set("background-color", "#ffffff");
card.getStyle().set("margin-bottom", "10px");
card.getStyle().set("max-width", "97.5%");
card.addClassName("message-card");
// Hover effect
card.getElement().addEventListener("mouseenter", e -> {
card.getStyle().set("background-color", "#f5f5f5");
});
card.getElement().addEventListener("mouseleave", e -> {
card.getStyle().set("background-color", "#ffffff");
});
// Title row with unread indicator
HorizontalLayout titleRow = new HorizontalLayout();
titleRow.setWidthFull();
titleRow.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER);
Span titleSpan = new Span(conversationTitle);
titleSpan.getStyle().set("font-weight", "bold");
titleSpan.getStyle().set("font-size", "16px");
if (unreadCount > 0) {
Span unreadBadge = new Span(String.valueOf(unreadCount));
unreadBadge.getStyle().set("background-color", "var(--lumo-primary-color)");
unreadBadge.getStyle().set("color", "white");
unreadBadge.getStyle().set("border-radius", "50%");
unreadBadge.getStyle().set("padding", "2px 8px");
unreadBadge.getStyle().set("font-size", "12px");
unreadBadge.getStyle().set("font-weight", "bold");
titleRow.add(titleSpan, unreadBadge);
} else {
titleRow.add(titleSpan);
}
titleRow.expand(titleSpan);
// Preview text
Span preview = new Span(lastMessagePreview);
preview.getStyle().set("color", "#666666");
preview.getStyle().set("font-size", "14px");
// Metadata row
HorizontalLayout metaRow = new HorizontalLayout();
metaRow.setWidthFull();
Span timeSpan = new Span(lastMessageTime.format(DATE_FORMATTER));
timeSpan.getStyle().set("color", "#999999");
timeSpan.getStyle().set("font-size", "12px");
Span countSpan = new Span(messageCount + " Nachrichten");
countSpan.getStyle().set("color", "#999999");
countSpan.getStyle().set("font-size", "12px");
metaRow.add(timeSpan, countSpan);
metaRow.expand(timeSpan);
// Add all elements to card
VerticalLayout cardContent = new VerticalLayout(titleRow, preview, metaRow);
cardContent.setWidthFull();
cardContent.setPadding(false);
cardContent.setSpacing(false);
card.add(cardContent);
// Click listener to navigate to message details
card.addClickListener(e -> {
UI.getCurrent().navigate("message-details/" + clientId + "/" + conversationId);
});
return card;
}
}

View File

@@ -0,0 +1,64 @@
package de.assecutor.votianlt.repository;
import de.assecutor.votianlt.model.Message;
import de.assecutor.votianlt.model.MessageDirection;
import de.assecutor.votianlt.model.MessageType;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface MessageRepository extends MongoRepository<Message, ObjectId> {
/**
* Find all messages for a specific receiver, ordered by creation time descending (newest first)
*/
List<Message> findByReceiverOrderByCreatedAtDesc(String receiver);
/**
* Find all messages sent by a specific sender, ordered by creation time descending
*/
List<Message> findBySenderOrderByCreatedAtDesc(String sender);
/**
* Find all unread messages for a specific receiver
*/
List<Message> findByReceiverAndIsReadFalseOrderByCreatedAtDesc(String receiver);
/**
* Find all messages related to a specific job
*/
List<Message> findByJobIdOrderByCreatedAtDesc(ObjectId jobId);
/**
* Find all messages of a specific type for a receiver
*/
List<Message> findByReceiverAndMessageTypeOrderByCreatedAtDesc(String receiver, MessageType messageType);
/**
* Find all messages by direction (incoming/outgoing)
*/
List<Message> findByDirectionOrderByCreatedAtDesc(MessageDirection direction);
/**
* Find all messages between two users (in both directions)
*/
List<Message> findBySenderAndReceiverOrderByCreatedAtAsc(String sender, String receiver);
/**
* Find all unread messages count for a specific receiver
*/
long countByReceiverAndIsReadFalse(String receiver);
/**
* Find all messages for a specific receiver and job
*/
List<Message> findByReceiverAndJobIdOrderByCreatedAtDesc(String receiver, ObjectId jobId);
/**
* Find all messages (for admin/overview), ordered by creation time descending
*/
List<Message> findAllByOrderByCreatedAtDesc();
}

View File

@@ -0,0 +1,193 @@
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.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.List;
import java.util.Map;
import java.util.Optional;
@Service
@Slf4j
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();
}
/**
* Save a message to the database
*/
public Message saveMessage(Message message) {
log.info("Saving message from {} to {}", message.getSender(), message.getReceiver());
return messageRepository.save(message);
}
/**
* Send a general message to a client via MQTT
*/
public Message sendGeneralMessageToClient(String content, String sender, String receiver) {
Message message = new Message(content, sender, receiver, MessageDirection.OUTGOING);
message = saveMessage(message);
publishMessageToMqtt(message, receiver);
return message;
}
/**
* Send a job-related message to a client via MQTT
*/
public Message sendJobMessageToClient(String content, String sender, String receiver,
ObjectId jobId, String jobNumber) {
Message message = new Message(content, sender, receiver, MessageDirection.OUTGOING, jobId, jobNumber);
message = saveMessage(message);
publishMessageToMqtt(message, receiver);
return message;
}
/**
* Handle incoming message from a client
*/
public Message receiveMessageFromClient(String content, String sender, String receiver,
ObjectId jobId, String jobNumber) {
Message message;
if (jobId != null) {
message = new Message(content, sender, receiver, MessageDirection.INCOMING, jobId, jobNumber);
} else {
message = new Message(content, sender, receiver, MessageDirection.INCOMING);
}
return saveMessage(message);
}
/**
* Publish message to MQTT topic for the receiver
*/
private void publishMessageToMqtt(Message message, String receiver) {
try {
String topic = "/client/" + receiver + "/message";
Map<String, Object> 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());
}
mqttPublisher.publishAsJson(topic, payload, false);
log.info("Published message to MQTT topic: {}", topic);
} catch (Exception e) {
log.error("Error publishing message to MQTT: {}", e.getMessage(), e);
}
}
/**
* Get all messages for a specific receiver
*/
public List<Message> getMessagesForReceiver(String receiver) {
return messageRepository.findByReceiverOrderByCreatedAtDesc(receiver);
}
/**
* Get all unread messages for a specific receiver
*/
public List<Message> getUnreadMessagesForReceiver(String receiver) {
return messageRepository.findByReceiverAndIsReadFalseOrderByCreatedAtDesc(receiver);
}
/**
* Get all messages related to a specific job
*/
public List<Message> getMessagesForJob(ObjectId jobId) {
return messageRepository.findByJobIdOrderByCreatedAtDesc(jobId);
}
/**
* Get all messages for a receiver and specific job
*/
public List<Message> getMessagesForReceiverAndJob(String receiver, ObjectId jobId) {
return messageRepository.findByReceiverAndJobIdOrderByCreatedAtDesc(receiver, jobId);
}
/**
* Get all general messages for a receiver
*/
public List<Message> getGeneralMessagesForReceiver(String receiver) {
return messageRepository.findByReceiverAndMessageTypeOrderByCreatedAtDesc(receiver, MessageType.GENERAL);
}
/**
* Get all job-related messages for a receiver
*/
public List<Message> getJobRelatedMessagesForReceiver(String receiver) {
return messageRepository.findByReceiverAndMessageTypeOrderByCreatedAtDesc(receiver, MessageType.JOB_RELATED);
}
/**
* Get all messages (for admin/overview)
*/
public List<Message> getAllMessages() {
return messageRepository.findAllByOrderByCreatedAtDesc();
}
/**
* Get messages by direction
*/
public List<Message> getMessagesByDirection(MessageDirection direction) {
return messageRepository.findByDirectionOrderByCreatedAtDesc(direction);
}
/**
* Mark a message as read
*/
public void markAsRead(ObjectId messageId) {
Optional<Message> messageOpt = messageRepository.findById(messageId);
if (messageOpt.isPresent()) {
Message message = messageOpt.get();
message.markAsRead();
messageRepository.save(message);
log.info("Marked message {} as read", messageId);
} else {
log.warn("Message {} not found", messageId);
}
}
/**
* Get unread message count for a receiver
*/
public long getUnreadMessageCount(String receiver) {
return messageRepository.countByReceiverAndIsReadFalse(receiver);
}
/**
* Get a message by ID
*/
public Optional<Message> getMessageById(ObjectId messageId) {
return messageRepository.findById(messageId);
}
/**
* Delete a message
*/
public void deleteMessage(ObjectId messageId) {
messageRepository.deleteById(messageId);
log.info("Deleted message {}", messageId);
}
}