diff --git a/src/main/java/de/assecutor/votianlt/controller/MessageApiController.java b/src/main/java/de/assecutor/votianlt/controller/MessageApiController.java new file mode 100644 index 0000000..ffdb3d4 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/controller/MessageApiController.java @@ -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 sendGeneralMessage(@RequestBody Map 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 sendJobMessage(@RequestBody Map 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> getMessagesForReceiver(@PathVariable String username) { + try { + List 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> getUnreadMessagesForReceiver(@PathVariable String username) { + try { + List 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> 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> getMessagesForJob(@PathVariable String jobId) { + try { + ObjectId objectId = new ObjectId(jobId); + List 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> getAllMessages() { + try { + List 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> getMessagesByDirection(@PathVariable String direction) { + try { + MessageDirection messageDirection = MessageDirection.valueOf(direction.toUpperCase()); + List 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 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 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(); + } + } +} diff --git a/src/main/java/de/assecutor/votianlt/controller/MessageController.java b/src/main/java/de/assecutor/votianlt/controller/MessageController.java index 0fc799f..80928f8 100644 --- a/src/main/java/de/assecutor/votianlt/controller/MessageController.java +++ b/src/main/java/de/assecutor/votianlt/controller/MessageController.java @@ -22,6 +22,7 @@ import de.assecutor.votianlt.model.Signature; import de.assecutor.votianlt.model.Comment; import de.assecutor.votianlt.service.JobHistoryService; import de.assecutor.votianlt.service.EmailService; +import de.assecutor.votianlt.service.MessageService; import de.assecutor.votianlt.model.JobStatus; import lombok.extern.slf4j.Slf4j; import com.fasterxml.jackson.databind.ObjectMapper; @@ -64,11 +65,13 @@ public class MessageController { private final CommentRepository commentRepository; private final JobHistoryService jobHistoryService; private final EmailService emailService; + private final MessageService messageService; public MessageController(MqttPublisher mqttPublisher, AppUserRepository appUserRepository, AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository, TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository, - SignatureRepository signatureRepository, CommentRepository commentRepository, JobHistoryService jobHistoryService, EmailService emailService) { + SignatureRepository signatureRepository, CommentRepository commentRepository, JobHistoryService jobHistoryService, + EmailService emailService, MessageService messageService) { this.mqttPublisher = mqttPublisher; this.appUserRepository = appUserRepository; this.appUserService = appUserService; @@ -81,6 +84,7 @@ public class MessageController { this.commentRepository = commentRepository; this.jobHistoryService = jobHistoryService; this.emailService = emailService; + this.messageService = messageService; } /** @@ -589,4 +593,61 @@ public class MessageController { private String getClientIdForUserId(String 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 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); + } + } } \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/dto/ClientMessageSummary.java b/src/main/java/de/assecutor/votianlt/dto/ClientMessageSummary.java new file mode 100644 index 0000000..6990185 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/dto/ClientMessageSummary.java @@ -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; +} diff --git a/src/main/java/de/assecutor/votianlt/model/Message.java b/src/main/java/de/assecutor/votianlt/model/Message.java new file mode 100644 index 0000000..3445358 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/model/Message.java @@ -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; + } +} diff --git a/src/main/java/de/assecutor/votianlt/model/MessageDirection.java b/src/main/java/de/assecutor/votianlt/model/MessageDirection.java new file mode 100644 index 0000000..c7a6ed3 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/model/MessageDirection.java @@ -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 +} diff --git a/src/main/java/de/assecutor/votianlt/model/MessageType.java b/src/main/java/de/assecutor/votianlt/model/MessageType.java new file mode 100644 index 0000000..06863d0 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/model/MessageType.java @@ -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 +} diff --git a/src/main/java/de/assecutor/votianlt/mqtt/MqttV5ClientManager.java b/src/main/java/de/assecutor/votianlt/mqtt/MqttV5ClientManager.java index 103815e..34ac466 100644 --- a/src/main/java/de/assecutor/votianlt/mqtt/MqttV5ClientManager.java +++ b/src/main/java/de/assecutor/votianlt/mqtt/MqttV5ClientManager.java @@ -163,6 +163,13 @@ public class MqttV5ClientManager implements SmartLifecycle { de.assecutor.votianlt.dto.AppLoginRequest req = om.convertValue(payload, de.assecutor.votianlt.dto.AppLoginRequest.class); 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 { log.debug("No route for topic {}", topic); } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java b/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java new file mode 100644 index 0000000..e70469d --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java @@ -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);" + ); + }); + } + } +} diff --git a/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java b/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java new file mode 100644 index 0000000..e184306 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java @@ -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 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 createClientGrid() { + Grid 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 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 generateTestData() { + List summaries = new ArrayList<>(); + + // Test client 1 - Max Mustermann with unread messages + summaries.add(new ClientMessageSummary( + "client001", + "Max Mustermann", + "max.mustermann@example.com", + 15, + 3, + java.time.LocalDateTime.now().minusHours(2), + "Hallo, ich habe eine Frage zu meinem letzten Auftrag..." + )); + + // Test client 2 - Anna Schmidt with no unread messages + summaries.add(new ClientMessageSummary( + "client002", + "Anna Schmidt", + "anna.schmidt@example.com", + 8, + 0, + java.time.LocalDateTime.now().minusDays(1), + "Vielen Dank für die schnelle Bearbeitung!" + )); + + // Test client 3 - Peter Weber with many unread messages + summaries.add(new ClientMessageSummary( + "client003", + "Peter Weber", + "peter.weber@example.com", + 25, + 7, + java.time.LocalDateTime.now().minusMinutes(30), + "Können Sie mir bitte den aktuellen Status mitteilen?" + )); + + // Test client 4 - Lisa Müller with recent message + summaries.add(new ClientMessageSummary( + "client004", + "Lisa Müller", + "lisa.mueller@example.com", + 12, + 1, + java.time.LocalDateTime.now().minusMinutes(5), + "Wann wird die Lieferung ankommen?" + )); + + // Test client 5 - Thomas Becker with older messages + summaries.add(new ClientMessageSummary( + "client005", + "Thomas Becker", + "thomas.becker@example.com", + 20, + 0, + java.time.LocalDateTime.now().minusDays(5), + "Alles erledigt, danke für die Zusammenarbeit." + )); + + // Test client 6 - Sarah Wagner with unread messages + summaries.add(new ClientMessageSummary( + "client006", + "Sarah Wagner", + "sarah.wagner@example.com", + 6, + 2, + java.time.LocalDateTime.now().minusHours(8), + "Gibt es Updates zum Auftrag #12345?" + )); + + return summaries; + } + + private List aggregateMessagesByClient(String currentUsername) { + // Get all messages for current user (received and sent) + List receivedMessages = messageService.getMessagesForReceiver(currentUsername); + List sentMessages = messageService.getMessagesByDirection(MessageDirection.OUTGOING) + .stream() + .filter(m -> currentUsername.equals(m.getSender())) + .toList(); + + List allMessages = new ArrayList<>(); + allMessages.addAll(receivedMessages); + allMessages.addAll(sentMessages); + + // Group messages by client (sender or receiver that is not the current user) + Map> messagesByClient = allMessages.stream() + .collect(Collectors.groupingBy(message -> { + // Determine the "other party" (client) + if (currentUsername.equals(message.getSender())) { + return message.getReceiver(); + } else { + return message.getSender(); + } + })); + + // Create summaries for each client + List summaries = new ArrayList<>(); + for (Map.Entry> entry : messagesByClient.entrySet()) { + String clientEmail = entry.getKey(); + List clientMessages = entry.getValue(); + + // Find client info + AppUser appUser = appUserService.findAll().stream() + .filter(u -> clientEmail.equals(u.getEmail())) + .findFirst() + .orElse(null); + + String clientName = appUser != null ? + appUser.getVorname() + " " + appUser.getNachname() : clientEmail; + String clientId = appUser != null ? appUser.getIdAsString() : clientEmail; + + // Calculate statistics + int totalMessages = clientMessages.size(); + long unreadCount = clientMessages.stream() + .filter(m -> !m.isRead() && currentUsername.equals(m.getReceiver())) + .count(); + + // Get last message + Message lastMessage = clientMessages.stream() + .max(Comparator.comparing(Message::getCreatedAt)) + .orElse(null); + + java.time.LocalDateTime lastMessageDate = lastMessage != null ? lastMessage.getCreatedAt() : null; + String lastMessagePreview = lastMessage != null ? lastMessage.getContent() : null; + + summaries.add(new ClientMessageSummary( + clientId, + clientName, + clientEmail, + totalMessages, + (int) unreadCount, + lastMessageDate, + lastMessagePreview + )); + } + + // 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 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 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); + } + } +} diff --git a/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java b/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java new file mode 100644 index 0000000..ad70fac --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java @@ -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 { + + 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; + } +} diff --git a/src/main/java/de/assecutor/votianlt/repository/MessageRepository.java b/src/main/java/de/assecutor/votianlt/repository/MessageRepository.java new file mode 100644 index 0000000..bd7544c --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/repository/MessageRepository.java @@ -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 { + + /** + * Find all messages for a specific receiver, ordered by creation time descending (newest first) + */ + List findByReceiverOrderByCreatedAtDesc(String receiver); + + /** + * Find all messages sent by a specific sender, ordered by creation time descending + */ + List findBySenderOrderByCreatedAtDesc(String sender); + + /** + * Find all unread messages for a specific receiver + */ + List findByReceiverAndIsReadFalseOrderByCreatedAtDesc(String receiver); + + /** + * Find all messages related to a specific job + */ + List findByJobIdOrderByCreatedAtDesc(ObjectId jobId); + + /** + * Find all messages of a specific type for a receiver + */ + List findByReceiverAndMessageTypeOrderByCreatedAtDesc(String receiver, MessageType messageType); + + /** + * Find all messages by direction (incoming/outgoing) + */ + List findByDirectionOrderByCreatedAtDesc(MessageDirection direction); + + /** + * Find all messages between two users (in both directions) + */ + List 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 findByReceiverAndJobIdOrderByCreatedAtDesc(String receiver, ObjectId jobId); + + /** + * Find all messages (for admin/overview), ordered by creation time descending + */ + List findAllByOrderByCreatedAtDesc(); +} diff --git a/src/main/java/de/assecutor/votianlt/service/MessageService.java b/src/main/java/de/assecutor/votianlt/service/MessageService.java new file mode 100644 index 0000000..60b5f90 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/service/MessageService.java @@ -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 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 getMessagesForReceiver(String receiver) { + return messageRepository.findByReceiverOrderByCreatedAtDesc(receiver); + } + + /** + * Get all unread messages for a specific receiver + */ + public List getUnreadMessagesForReceiver(String receiver) { + return messageRepository.findByReceiverAndIsReadFalseOrderByCreatedAtDesc(receiver); + } + + /** + * Get all messages related to a specific job + */ + public List getMessagesForJob(ObjectId jobId) { + return messageRepository.findByJobIdOrderByCreatedAtDesc(jobId); + } + + /** + * Get all messages for a receiver and specific job + */ + public List getMessagesForReceiverAndJob(String receiver, ObjectId jobId) { + return messageRepository.findByReceiverAndJobIdOrderByCreatedAtDesc(receiver, jobId); + } + + /** + * Get all general messages for a receiver + */ + public List getGeneralMessagesForReceiver(String receiver) { + return messageRepository.findByReceiverAndMessageTypeOrderByCreatedAtDesc(receiver, MessageType.GENERAL); + } + + /** + * Get all job-related messages for a receiver + */ + public List getJobRelatedMessagesForReceiver(String receiver) { + return messageRepository.findByReceiverAndMessageTypeOrderByCreatedAtDesc(receiver, MessageType.JOB_RELATED); + } + + /** + * Get all messages (for admin/overview) + */ + public List getAllMessages() { + return messageRepository.findAllByOrderByCreatedAtDesc(); + } + + /** + * Get messages by direction + */ + public List getMessagesByDirection(MessageDirection direction) { + return messageRepository.findByDirectionOrderByCreatedAtDesc(direction); + } + + /** + * Mark a message as read + */ + public void markAsRead(ObjectId messageId) { + Optional 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 getMessageById(ObjectId messageId) { + return messageRepository.findById(messageId); + } + + /** + * Delete a message + */ + public void deleteMessage(ObjectId messageId) { + messageRepository.deleteById(messageId); + log.info("Deleted message {}", messageId); + } +}