From 5adfb9c2db977ecbc15b8bb695346c3670a740e8 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Sat, 13 Sep 2025 22:07:01 +0200 Subject: [PATCH] Erweiterungen --- STOMP_README.md | 421 ++++++------------ .../votianlt/config/WebSocketConfig.java | 55 ++- .../config/WebSocketMessageSizeConfig.java | 47 ++ .../controller/MessageController.java | 178 +++++--- .../controller/PhotoUploadController.java | 258 +++++++++++ .../de/assecutor/votianlt/model/Photo.java | 123 +++++ .../votianlt/repository/PhotoRepository.java | 48 ++ src/main/resources/application.properties | 18 +- 8 files changed, 801 insertions(+), 347 deletions(-) create mode 100644 src/main/java/de/assecutor/votianlt/config/WebSocketMessageSizeConfig.java create mode 100644 src/main/java/de/assecutor/votianlt/controller/PhotoUploadController.java create mode 100644 src/main/java/de/assecutor/votianlt/model/Photo.java create mode 100644 src/main/java/de/assecutor/votianlt/repository/PhotoRepository.java diff --git a/STOMP_README.md b/STOMP_README.md index 191137a..60671bd 100644 --- a/STOMP_README.md +++ b/STOMP_README.md @@ -1,302 +1,177 @@ -# STOMP Messaging Integration +# VOTIANLT Realtime & Photo Upload API -Die Anwendung unterstützt jetzt STOMP (Simple Text Oriented Messaging Protocol) für die Kommunikation mit externen Apps über WebSocket-Verbindungen. +This document describes how mobile/Flutter apps can interact with the backend via STOMP (WebSocket) and how to upload photos via HTTP POST (new flow). -## Übersicht +## 1) STOMP Overview -Das System bietet folgende STOMP-Funktionalitäten: +- Connect to one of the WebSocket endpoints: + - `/ws` (SockJS fallback supported) + - `/websocket` (native WebSocket) + - `/stomp` (native WebSocket, alternate path) +- Application destination prefix: `/app` +- Broker destinations: + - Broadcast: `/topic/...` + - User specific: `/user/queue/...` -### WebSocket-Endpunkte +Examples: +- Send a generic message: send to `/app/message`, receive on `/topic/messages` +- Job updates: send to `/app/job/status`, receive on `/topic/job-updates` +- Device locations: send to `/app/device/location`, receive on `/topic/device-locations` +- Task updates (broadcast): `/topic/task-updates` +- Task specific topic: `/topic/tasks/{taskId}` -- **`/ws`** - STOMP-Endpunkt mit SockJS-Fallback-Unterstützung -- **`/websocket`** - Reiner WebSocket-Endpunkt ohne SockJS +## 2) Photo Task – New HTTP Upload Flow -### Nachrichtendestinationen +In the future, photos for a PHOTO task must NOT be embedded in the STOMP message anymore. Instead, upload the photos via HTTP POST and only send the task-completion event via STOMP without large payloads. -#### Eingehende Nachrichten (Client → Server) -- **`/app/message`** - Allgemeine Nachrichten -- **`/app/job/status`** - Job-Status-Updates -- **`/app/device/location`** - Gerätestandort-Updates -- **`/app/auth/login`** - Anmeldung eines App-Users (Payload: { email, password }) +### Endpoint -#### Ausgehende Nachrichten (Server → Client) -- **`/topic/messages`** - Broadcast aller allgemeinen Nachrichten -- **`/topic/job-updates`** - Job-Status-Updates für alle Abonnenten -- **`/topic/device-locations`** - Gerätestandort-Updates -- **`/topic/broadcasts`** - System-weite Broadcast-Nachrichten -- **`/queue/notifications`** - Benutzerspezifische Benachrichtigungen +- URL: `POST /api/tasks/{taskId}/photos` +- Purpose: Upload one or many photos for a task of type `PHOTO`. +- Path params: + - `taskId` (required): The MongoDB ObjectId of the task. +- Auth: (same as your app uses today; if you have a session/cookie or token, include it accordingly) +- CORS: Enabled for `*` by default on this controller. -## Verwendung für Apps +### Content Types (choose one) -### 1. Verbindung aufbauen +1) Multipart form data (recommended for binary images) -```javascript -// Mit SockJS -const socket = new SockJS('http://192.168.180.196:8080/ws'); -const stompClient = Stomp.over(socket); +- `Content-Type: multipart/form-data` +- Fields: + - `files`: one or multiple image files (repeat the field for multiple images) + - `completedBy` (optional): username/id of the completing user + - `note` (optional): any note (currently stored on the task when you mark it as completed via STOMP) -// Oder mit nativem WebSocket (WICHTIG: ws:// verwenden, nicht http://) -const socket = new WebSocket('ws://192.168.180.196:8080/websocket'); -const stompClient = Stomp.over(socket); +Example (curl): + +```bash +curl -X POST "http://localhost:8080/api/tasks/66f5c2f1a3b6e27a1a234567/photos" \ + -H "Content-Type: multipart/form-data" \ + -F "files=@/path/to/photo1.jpg" \ + -F "files=@/path/to/photo2.jpg" \ + -F "completedBy=driver01" \ + -F "note=Anlieferung dokumentiert" ``` -### Flutter/Dart STOMP Client +2) JSON with base64 strings (kept for compatibility) -**Für Flutter-Apps verwenden Sie diese Konfiguration:** - -```dart -import 'package:stomp_dart_client/stomp.dart'; -import 'package:stomp_dart_client/stomp_config.dart'; -import 'package:stomp_dart_client/stomp_frame.dart'; -import 'dart:convert'; - -// WICHTIG: Verwenden Sie ws:// für WebSocket-Verbindungen, NICHT http:// -final stompClient = StompClient( - config: StompConfig( - url: 'ws://192.168.180.196:8080/websocket', // Beachten Sie ws:// statt http:// - onConnect: onConnectCallback, - onWebSocketError: (dynamic error) => print('WebSocket Error: $error'), - onStompError: (StompFrame frame) => print('Stomp Error: ${frame.body}'), - onDisconnect: (StompFrame frame) => print('Disconnected'), - // Heartbeat-Konfiguration - heartbeatIncoming: Duration(seconds: 20), - heartbeatOutgoing: Duration(seconds: 20), - ), -); - -void onConnectCallback(StompFrame frame) { - print('Connected to STOMP server'); - - // Nachrichten abonnieren - stompClient.subscribe( - destination: '/topic/messages', - callback: (StompFrame frame) { - print('Received message: ${frame.body}'); - }, - ); -} - -// Verbindung herstellen -stompClient.activate(); - -// Nachricht senden -void sendMessage(String content) { - stompClient.send( - destination: '/app/message', - body: jsonEncode({ - 'content': content, - 'sender': 'FlutterApp', - }), - ); -} -``` - -**Alternative Endpunkte für Flutter:** -Falls Probleme mit `/websocket` auftreten, versuchen Sie: -- `ws://192.168.180.196:8080/stomp` (zusätzlicher Endpunkt) -- `ws://192.168.180.196:8080/ws` (SockJS-Endpunkt ohne SockJS-Protokoll) - -### 2. Verbindung herstellen - -```javascript -stompClient.connect({}, function(frame) { - console.log('Verbunden: ' + frame); - - // Nachrichten abonnieren - stompClient.subscribe('/topic/messages', function(message) { - console.log('Nachricht erhalten:', JSON.parse(message.body)); - }); -}); -``` - -### 3. Nachrichten senden - -```javascript -// Allgemeine Nachricht senden -stompClient.send('/app/message', {}, JSON.stringify({ - content: 'Hallo vom App', - sender: 'MobileApp' -})); - -// Job-Status-Update senden -stompClient.send('/app/job/status', {}, JSON.stringify({ - jobId: '12345', - status: 'IN_PROGRESS', - progress: 75 -})); - -// Gerätestandort senden -stompClient.send('/app/device/location', {}, JSON.stringify({ - deviceId: 'device-001', - latitude: 52.5200, - longitude: 13.4050, - accuracy: 10 -})); - -// Anmeldung eines App-Users -// Zuerst die Antwort-Warteschlange abonnieren (user-spezifisch) -const authSubscription = stompClient.subscribe('/user/queue/auth', function(message) { - const resp = JSON.parse(message.body); - console.log('Login-Antwort:', resp); -}); - -// Login-Request senden -stompClient.send('/app/auth/login', {}, JSON.stringify({ - email: 'user@example.com', - password: 'geheimesPasswort' -})); -``` - -## Backend-Integration - -### Programmatische Nachrichten senden - -```java -@Autowired -private MessageController messageController; - -// Benachrichtigung an spezifischen Benutzer -messageController.sendNotificationToUser("username", "Neue Aufgabe verfügbar"); - -// Broadcast-Nachricht an alle -messageController.sendBroadcastMessage("Systemwartung in 10 Minuten"); -``` - -## Zeroconf (mDNS) Veröffentlichung - -Die Anwendung veröffentlicht die STOMP-Schnittstelle via Zeroconf (DNS-SD/mDNS), sofern verfügbar. Es wird der Service-Typ `_stomp._tcp.local.` mit folgenden TXT-Records publiziert: -- path = Pfad für SockJS-Endpoint (Standard: /ws) -- websocket = Pfad für nativen WebSocket (Standard: /websocket) -- protocol = "stomp" - -Clients können per Bonjour/mDNS nach `_stomp._tcp` suchen und erhalten Port und Metadaten. - -Hinweise: -- Die Implementierung nutzt JmDNS, falls die Bibliothek auf dem Klassenpfad vorhanden ist. In Umgebungen ohne JmDNS bleibt Zeroconf stillschweigend deaktiviert (es wird ein Hinweis im Log ausgegeben). -- Konfigurierbare Properties: - - app.zeroconf.enabled (default: true) - - app.zeroconf.serviceName (default: votianlt-stomp) - - app.stomp.wsPath (default: /ws) - - app.stomp.websocketPath (default: /websocket) - -## Konfiguration - -Die STOMP-Konfiguration befindet sich in: -- **`WebSocketConfig.java`** - WebSocket und STOMP-Konfiguration -- **`MessageController.java`** - Nachrichtenbehandlung -- **`application.properties`** - Zusätzliche WebSocket-Einstellungen - -### Wichtige Konfigurationsparameter - -```properties -# Nachrichtenpuffergröße -spring.websocket.servlet.max-text-message-buffer-size=8192 -spring.websocket.servlet.max-binary-message-buffer-size=8192 - -# STOMP aktivieren -spring.websocket.stomp.enabled=true - -# Heartbeat-Einstellungen -spring.websocket.stomp.heartbeat.outgoing=10000 -spring.websocket.stomp.heartbeat.incoming=10000 -``` - -## Sicherheitshinweise - -- WebSocket-Verbindungen verwenden die gleiche Authentifizierung wie die Web-Anwendung -- Nachrichten werden automatisch mit Zeitstempel versehen -- Alle Nachrichten werden in JSON-Format verarbeitet - -## Testing - -Zum Testen der STOMP-Funktionalität können Sie: -1. Eine WebSocket-Client-Bibliothek verwenden -2. Browser-Entwicklertools für WebSocket-Verbindungen nutzen -3. Spezialisierte STOMP-Testing-Tools einsetzen - -Die Implementierung ist vollständig und bereit für die Integration mit externen Apps. - -## Neue STOMP-Schnittstelle: Task-Erledigung melden - -Mit dieser Schnittstelle kann ein Client die Erledigung eines Tasks melden. - -- Senden (Client → Server): `/app/task/completed` -- Broadcasts (Server → Client): - - Global: `/topic/task-updates` - - Task-spezifisch: `/topic/tasks/{taskId}` - -Payload (JSON): +- `Content-Type: application/json` +- Body: ```json { - "taskId": "", - "completedBy": "", - "note": "" -} -``` - -Antwort (Beispiel): - -```json -{ - "timestamp": "2025-09-05T09:25:00", - "type": "taskCompletedAck", - "success": true, - "taskId": "...", - "jobId": "...", - "completed": true, - "completedAt": "2025-09-05T09:25:00", "completedBy": "driver01", - "note": "Übergabe erfolgreich", - "event": "taskCompleted" + "note": "Anlieferung dokumentiert", + "photos": [ + "", + "" + ] } ``` -JavaScript Beispiel: +Example (curl): -```javascript -// Abonnieren der globalen Updates -stompClient.subscribe('/topic/task-updates', (frame) => { - console.log('Task update:', JSON.parse(frame.body)); -}); - -// Abonnieren eines spezifischen Tasks -stompClient.subscribe('/topic/tasks/' + taskId, (frame) => { - console.log('Task-specific update:', JSON.parse(frame.body)); -}); - -// Task als erledigt melden -stompClient.send('/app/task/completed', {}, JSON.stringify({ - taskId: taskId, - completedBy: 'driver01', - note: 'Übergabe erfolgreich' -})); +```bash +curl -X POST "http://localhost:8080/api/tasks/66f5c2f1a3b6e27a1a234567/photos" \ + -H "Content-Type: application/json" \ + -d '{ + "completedBy":"driver01", + "note":"Anlieferung dokumentiert", + "photos":["iVBORw0KGgo...","iVBORw0KGgo..."] + }' ``` -Flutter/Dart Beispiel: +### Simple JSON endpoint (single photo per call) -```dart -stompClient.subscribe( - destination: '/topic/task-updates', - callback: (frame) => print('Task update: ${frame.body}'), -); +- URL: `POST /api/photos` +- Content-Type: `application/json` +- Body: -stompClient.subscribe( - destination: '/topic/tasks/$taskId', - callback: (frame) => print('Task-specific update: ${frame.body}'), -); - -stompClient.send( - destination: '/app/task/completed', - body: jsonEncode({ - 'taskId': taskId, - 'completedBy': 'driver01', - 'note': 'Übergabe erfolgreich', - }), -); +```json +{ + "taskId": "66f5c2f1a3b6e27a1a234567", + "photo": "", + "completedBy": "driver01", + "note": "optional" +} ``` -Hinweise: -- `taskId` ist Pflicht. Bei ungültiger oder unbekannter `taskId` wird `success=false` zurückgegeben. -- Der Server setzt `completed=true` und `completedAt` automatisch. -- Zusätzlich zum globalen Broadcast wird ein task-spezifisches Event auf `/topic/tasks/{taskId}` versendet. +- Multiple photos: call this endpoint multiple times (one image per request). + +Example (curl): + +```bash +curl -X POST "http://localhost:8080/api/photos" \ + -H "Content-Type: application/json" \ + -d '{ + "taskId":"66f5c2f1a3b6e27a1a234567", + "photo":"iVBORw0KGgo...", + "completedBy":"driver01", + "note":"Anlieferung dokumentiert" + }' +``` + +### Response + +Both content types return the same JSON structure on success: + +```json +{ + "timestamp": "2025-09-13T21:27:00", + "type": "photoUploadAck", + "success": true, + "photoId": "66f5c400b1a1b05b1c123abc", + "photosCount": 2, + "taskId": "66f5c2f1a3b6e27a1a234567", + "jobId": "66f5c2f1a3b6e27a1a200000" +} +``` + +Error responses use HTTP status codes with: + +```json +{ "timestamp": "...", "type": "photoUploadAck", "success": false, "message": "..." } +``` + +### Server-side side-effect (notification) + +After a successful upload, a small event is broadcast to the specific task topic: + +- Destination: `/topic/tasks/{taskId}` +- Payload: + +```json +{ "event": "photoUploaded", "taskId": "...", "jobId": "...", "photosCount": 2, "timestamp": "..." } +``` + +Use this to update UI (e.g., show that photos have arrived) without transferring large images via STOMP. + +### Limits + +- Multipart limits (configurable in `application.properties`): + - `spring.servlet.multipart.max-file-size=32MB` + - `spring.servlet.multipart.max-request-size=64MB` +- Tomcat limits adjusted accordingly. + +If you need higher limits, we can raise these values. + +## 3) Marking Task as Completed (STOMP) + +Continue to mark a task as completed via STOMP without embedding images: + +- Send to: `/app/task/photo/completed` +- Payload (no photos inside): + +```json +{ "taskId": "66f5c2f1a3b6e27a1a234567", "completedBy": "driver01", "note": "Anlieferung dokumentiert" } +``` + +- Broadcasts an update to `/topic/task-updates` and `/topic/tasks/{taskId}`. + +## 4) Retrieval of Photos (optional) + +Currently, the backend stores uploaded photos as base64 in the `photos` collection referencing the `taskId` and `jobId`. If you need a download/list endpoint, let us know—we can expose `/api/tasks/{taskId}/photos` (GET) to return metadata and/or images. + +--- +If anything is unclear or you need different formats (e.g., presigned URLs, chunked uploads), please reach out. \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/config/WebSocketConfig.java b/src/main/java/de/assecutor/votianlt/config/WebSocketConfig.java index 2a60a3d..0b5574a 100644 --- a/src/main/java/de/assecutor/votianlt/config/WebSocketConfig.java +++ b/src/main/java/de/assecutor/votianlt/config/WebSocketConfig.java @@ -6,6 +6,12 @@ import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBr import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; +import org.springframework.messaging.converter.MessageConverter; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; /** * WebSocket configuration for STOMP messaging. @@ -13,43 +19,74 @@ import org.springframework.web.socket.server.support.HttpSessionHandshakeInterce */ @Configuration @EnableWebSocketMessageBroker +@Slf4j public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + @Override public void configureMessageBroker(MessageBrokerRegistry config) { // Enable a simple memory-based message broker to carry messages back to client // on destinations prefixed with "/topic" and "/queue" config.enableSimpleBroker("/topic", "/queue"); - + // Designate the "/app" prefix for messages that are bound to methods // annotated with @MessageMapping config.setApplicationDestinationPrefixes("/app"); - + // Set user destination prefix for user-specific messages config.setUserDestinationPrefix("/user"); } + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + // Increase message size limits for large payloads (like base64 photos) + registration.taskExecutor().corePoolSize(4); + registration.taskExecutor().maxPoolSize(8); + registration.taskExecutor().keepAliveSeconds(60); + } + + @Override + public void configureClientOutboundChannel(ChannelRegistration registration) { + // Configure outbound channel for better performance with large messages + registration.taskExecutor().corePoolSize(4); + registration.taskExecutor().maxPoolSize(8); + registration.taskExecutor().keepAliveSeconds(60); + } + + @Override + public void configureWebSocketTransport(WebSocketTransportRegistration registration) { + // Use framework defaults (no custom large-message settings) + } + + @Override + public boolean configureMessageConverters(List messageConverters) { + // Use default message converters (no custom large-payload converter) + return false; // keep default converters + } + @Override public void registerStompEndpoints(StompEndpointRegistry registry) { + log.info("=== REGISTERING WEBSOCKET ENDPOINTS ==="); + // Register the "/ws" endpoint for WebSocket connections with SockJS fallback registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") .addInterceptors(new HttpSessionHandshakeInterceptor()) .withSockJS() - .setHeartbeatTime(25000) // Set heartbeat interval - .setDisconnectDelay(5000) // Set disconnect delay - .setStreamBytesLimit(128 * 1024) // Set stream bytes limit - .setHttpMessageCacheSize(1000) // Set HTTP message cache size - .setSessionCookieNeeded(false); // Disable session cookie requirement - + .setHeartbeatTime(25000) + .setDisconnectDelay(5000) + .setSessionCookieNeeded(false); + // Plain WebSocket endpoint without SockJS for native WebSocket clients (Flutter, mobile apps) registry.addEndpoint("/websocket") .setAllowedOriginPatterns("*") .addInterceptors(new HttpSessionHandshakeInterceptor()); - + // Additional endpoint specifically for mobile/Flutter clients that might have URL issues registry.addEndpoint("/stomp") .setAllowedOriginPatterns("*") .addInterceptors(new HttpSessionHandshakeInterceptor()); + + log.info("WebSocket endpoints registered: /ws (with SockJS), /websocket, /stomp"); } } \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/config/WebSocketMessageSizeConfig.java b/src/main/java/de/assecutor/votianlt/config/WebSocketMessageSizeConfig.java new file mode 100644 index 0000000..08cd4c4 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/config/WebSocketMessageSizeConfig.java @@ -0,0 +1,47 @@ +package de.assecutor.votianlt.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.converter.DefaultContentTypeResolver; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.util.MimeTypeUtils; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.databind.SerializationFeature; + +import java.util.List; + +/** + * Additional configuration for handling large WebSocket messages. + * This configuration specifically addresses JSON parsing limits for large payloads. + */ +@Configuration +public class WebSocketMessageSizeConfig { + + /** + * Configure Jackson ObjectMapper to handle large JSON strings (like base64 photos). + */ + @Bean("webSocketObjectMapper") + public ObjectMapper webSocketObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper; + } + + /** + * Configure message converter to use our custom ObjectMapper. + */ + @Bean + public MappingJackson2MessageConverter messageConverter() { + MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(); + converter.setObjectMapper(webSocketObjectMapper()); + + DefaultContentTypeResolver resolver = new DefaultContentTypeResolver(); + resolver.setDefaultMimeType(MimeTypeUtils.APPLICATION_JSON); + converter.setContentTypeResolver(resolver); + + return converter; + } +} \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/controller/MessageController.java b/src/main/java/de/assecutor/votianlt/controller/MessageController.java index 4240bcf..cd601d6 100644 --- a/src/main/java/de/assecutor/votianlt/controller/MessageController.java +++ b/src/main/java/de/assecutor/votianlt/controller/MessageController.java @@ -6,11 +6,13 @@ import de.assecutor.votianlt.dto.JobWithRelatedDataDTO; import de.assecutor.votianlt.model.AppUser; import de.assecutor.votianlt.model.CargoItem; import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.Photo; import de.assecutor.votianlt.model.task.BaseTask; import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.repository.AppUserRepository; import de.assecutor.votianlt.repository.CargoItemRepository; import de.assecutor.votianlt.repository.JobRepository; +import de.assecutor.votianlt.repository.PhotoRepository; import de.assecutor.votianlt.repository.TaskRepository; import lombok.extern.slf4j.Slf4j; import com.fasterxml.jackson.databind.ObjectMapper; @@ -53,18 +55,22 @@ public class MessageController { @Autowired private TaskRepository taskRepository; + @Autowired + private PhotoRepository photoRepository; + /** * Handles messages sent to /app/message and broadcasts them to all subscribers of /topic/messages */ @MessageMapping("/message") @SendTo("/topic/messages") public Map handleMessage(Map message) { + log.error("=== ANY MESSAGE RECEIVED === STOMP Endpoint '/app/message' called"); log.info("STOMP Endpoint '/app/message' called with data: {}", message); - + // Add timestamp to the message message.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); message.put("processed", true); - + log.info("STOMP Response for '/app/message' sent to '/topic/messages': {}", message); return message; } @@ -234,70 +240,16 @@ public class MessageController { } /** - * Report task completion from apps. + * Report generic task completion from apps. * Client sends to /app/task/completed with payload { taskId, completedBy?, note? }. * Broadcasts to /topic/task-updates and /topic/tasks/{taskId}. + * This endpoint accepts any task type (fallback for GENERIC or unknown types). */ @MessageMapping("/task/completed") @SendTo("/topic/task-updates") public Map handleTaskCompleted(Map payload) { log.info("STOMP Endpoint '/app/task/completed' called with data: {}", payload); - Map response = new java.util.HashMap<>(); - response.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); - response.put("type", "taskCompletedAck"); - - if (payload == null || !payload.containsKey("taskId") || payload.get("taskId") == null || payload.get("taskId").toString().isBlank()) { - response.put("success", false); - response.put("message", "taskId ist erforderlich"); - log.info("Task completion failed: {}", response); - return response; - } - - String taskIdStr = payload.get("taskId").toString(); - String completedBy = payload.get("completedBy") != null ? payload.get("completedBy").toString() : null; - String note = payload.get("note") != null ? payload.get("note").toString() : null; - - try { - org.bson.types.ObjectId taskId = new org.bson.types.ObjectId(taskIdStr); - java.util.Optional opt = taskRepository.findById(taskId); - if (opt.isEmpty()) { - response.put("success", false); - response.put("message", "Task nicht gefunden"); - return response; - } - BaseTask task = opt.get(); - task.setCompleted(true); - task.setCompletedAt(LocalDateTime.now()); - if (completedBy != null) task.setCompletedBy(completedBy); - if (note != null) task.setCompletionNote(note); - taskRepository.save(task); - - java.util.Map event = new java.util.HashMap<>(); - event.put("taskId", task.getIdAsString()); - event.put("jobId", task.getJobIdAsString()); - event.put("completed", task.isCompleted()); - event.put("completedAt", task.getCompletedAt() != null ? task.getCompletedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) : null); - event.put("completedBy", task.getCompletedBy()); - event.put("note", task.getCompletionNote()); - event.put("event", "taskCompleted"); - - // Send specific task topic - messagingTemplate.convertAndSend("/topic/tasks/" + task.getIdAsString(), event); - - response.put("success", true); - response.putAll(event); - log.info("Task completion processed successfully for taskId={}", taskIdStr); - return response; - } catch (IllegalArgumentException e) { - response.put("success", false); - response.put("message", "Ungültige taskId"); - return response; - } catch (Exception e) { - log.error("Error processing task completion", e); - response.put("success", false); - response.put("message", "Fehler bei der Verarbeitung"); - return response; - } + return processTaskCompletion(payload, null); // null means accept any task type } /** @@ -314,14 +266,15 @@ public class MessageController { /** * Report photo task completion from apps. - * Client sends to /app/task/photo/completed with payload { taskId, completedBy?, note? }. + * Client sends to /app/task/photo/completed with payload { taskId, completedBy?, note?, extraData? }. + * The extraData contains: { photos: base64List, count: base64List.length } * Broadcasts to /topic/task-updates and /topic/tasks/{taskId}. */ @MessageMapping("/task/photo/completed") @SendTo("/topic/task-updates") public Map handlePhotoTaskCompleted(Map payload) { - log.info("STOMP Endpoint '/app/task/photo/completed' called with data: {}", payload); - return processTaskCompletion(payload, "PHOTO"); + log.info("STOMP Endpoint '/app/task/photo/completed' called"); + return processPhotoTaskCompletion(payload); } /** @@ -360,6 +313,107 @@ public class MessageController { return processTaskCompletion(payload, "TODOLIST"); } + /** + * Specialized method to process photo task completion with extraData handling. + * Saves photo data to the photos collection and processes task completion. + */ + private Map processPhotoTaskCompletion(Map payload) { + Map response = new java.util.HashMap<>(); + response.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + response.put("type", "taskCompletedAck"); + + if (payload == null || !payload.containsKey("taskId") || payload.get("taskId") == null || payload.get("taskId").toString().isBlank()) { + response.put("success", false); + response.put("message", "taskId ist erforderlich"); + log.info("Photo task completion failed: {}", response); + return response; + } + + String taskIdStr = payload.get("taskId").toString(); + String completedBy = payload.get("completedBy") != null ? payload.get("completedBy").toString() : null; + String note = payload.get("note") != null ? payload.get("note").toString() : null; + + try { + org.bson.types.ObjectId taskId = new org.bson.types.ObjectId(taskIdStr); + java.util.Optional opt = taskRepository.findById(taskId); + if (opt.isEmpty()) { + response.put("success", false); + response.put("message", "Task nicht gefunden"); + return response; + } + + BaseTask task = opt.get(); + + // Validate task type is PHOTO + if (!"PHOTO".equals(task.getTaskType())) { + response.put("success", false); + response.put("message", "Task-Typ stimmt nicht mit dem Endpunkt überein. Erwartet: PHOTO, Gefunden: " + task.getTaskType()); + log.warn("Task type mismatch for taskId={}: expected=PHOTO, actual={}", taskIdStr, task.getTaskType()); + return response; + } + + // Process extraData if present + if (payload.containsKey("extraData") && payload.get("extraData") != null) { + try { + @SuppressWarnings("unchecked") + Map extraData = (Map) payload.get("extraData"); + + if (extraData.containsKey("photos") && extraData.get("photos") != null) { + @SuppressWarnings("unchecked") + List base64Photos = (List) extraData.get("photos"); + + // Create and save Photo entity + Photo photo = new Photo(task.getJobId(), task.getId(), base64Photos, completedBy); + photoRepository.save(photo); + + log.info("Saved {} photos for taskId={}, jobId={}, photoId={}", + base64Photos.size(), taskIdStr, task.getJobIdAsString(), photo.getIdAsString()); + + response.put("photoId", photo.getIdAsString()); + response.put("photosCount", base64Photos.size()); + } + } catch (Exception e) { + log.error("Error processing photo extraData for taskId={}: {}", taskIdStr, e.getMessage(), e); + response.put("photoError", "Fehler beim Speichern der Fotos: " + e.getMessage()); + } + } + + // Complete the task + task.setCompleted(true); + task.setCompletedAt(LocalDateTime.now()); + if (completedBy != null) task.setCompletedBy(completedBy); + if (note != null) task.setCompletionNote(note); + taskRepository.save(task); + + java.util.Map event = new java.util.HashMap<>(); + event.put("taskId", task.getIdAsString()); + event.put("jobId", task.getJobIdAsString()); + event.put("completed", task.isCompleted()); + event.put("completedAt", task.getCompletedAt() != null ? task.getCompletedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) : null); + event.put("completedBy", task.getCompletedBy()); + event.put("note", task.getCompletionNote()); + event.put("event", "taskCompleted"); + event.put("taskType", task.getTaskType()); + + // Send specific task topic + messagingTemplate.convertAndSend("/topic/tasks/" + task.getIdAsString(), event); + + response.put("success", true); + response.putAll(event); + log.info("Photo task completion processed successfully for taskId={}", taskIdStr); + return response; + } catch (IllegalArgumentException e) { + response.put("success", false); + response.put("message", "Ungültige taskId"); + return response; + } catch (Exception e) { + log.error("Error processing photo task completion", e); + response.put("success", false); + response.put("message", "Fehler bei der Verarbeitung"); + return response; + } + } + /** * Common method to process task completion for different task types. * This method contains the shared logic for all task completion endpoints. diff --git a/src/main/java/de/assecutor/votianlt/controller/PhotoUploadController.java b/src/main/java/de/assecutor/votianlt/controller/PhotoUploadController.java new file mode 100644 index 0000000..8184201 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/controller/PhotoUploadController.java @@ -0,0 +1,258 @@ +package de.assecutor.votianlt.controller; + +import de.assecutor.votianlt.model.Photo; +import de.assecutor.votianlt.model.task.BaseTask; +import de.assecutor.votianlt.repository.PhotoRepository; +import de.assecutor.votianlt.repository.TaskRepository; +import lombok.extern.slf4j.Slf4j; +import org.bson.types.ObjectId; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.Base64; + +/** + * REST endpoint for uploading photos for PHOTO tasks via HTTP POST instead of STOMP payload. + * + * Provides two content types on the same path: + * - multipart/form-data: files[] (one or many images), optional completedBy, note + * - application/json: { photos: [base64], completedBy?, note? } + */ +@RestController +@RequestMapping("/api") +@CrossOrigin(origins = "*") +@Slf4j +public class PhotoUploadController { + + @Autowired + private TaskRepository taskRepository; + + @Autowired + private PhotoRepository photoRepository; + + @Autowired + private SimpMessagingTemplate messagingTemplate; + + @PostMapping(path = "/tasks/{taskId}/photos", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> uploadPhotosMultipart( + @PathVariable("taskId") String taskId, + @RequestParam(value = "files") List files, + @RequestParam(value = "completedBy", required = false) String completedBy, + @RequestParam(value = "note", required = false) String note + ) { + Map response = initResponse("photoUploadAck"); + if (!StringUtils.hasText(taskId)) { + return badRequest(response, "taskId ist erforderlich"); + } + if (files == null || files.isEmpty()) { + return badRequest(response, "Mindestens eine Bilddatei (files) ist erforderlich"); + } + try { + ObjectId taskObjectId = new ObjectId(taskId); + Optional opt = taskRepository.findById(taskObjectId); + if (opt.isEmpty()) { + return notFound(response, "Task nicht gefunden"); + } + BaseTask task = opt.get(); + if (!"PHOTO".equals(task.getTaskType())) { + return badRequest(response, "Task-Typ stimmt nicht. Erwartet: PHOTO, Gefunden: " + task.getTaskType()); + } + + // Convert files to base64 strings to keep storage compatible with existing Photo entity + List base64Photos = new ArrayList<>(); + for (MultipartFile file : files) { + if (file == null || file.isEmpty()) continue; + String base64 = Base64.getEncoder().encodeToString(file.getBytes()); + base64Photos.add(base64); + } + if (base64Photos.isEmpty()) { + return badRequest(response, "Die übermittelten Dateien sind leer"); + } + + Photo photo = new Photo(task.getJobId(), task.getId(), base64Photos, completedBy); + photoRepository.save(photo); + + // Build success response + response.put("success", true); + response.put("photoId", photo.getIdAsString()); + response.put("photosCount", base64Photos.size()); + response.put("taskId", task.getIdAsString()); + response.put("jobId", task.getJobIdAsString()); + + // Optionally broadcast a small event to task topic + Map event = new HashMap<>(); + event.put("event", "photoUploaded"); + event.put("taskId", task.getIdAsString()); + event.put("jobId", task.getJobIdAsString()); + event.put("photosCount", base64Photos.size()); + event.put("timestamp", now()); + messagingTemplate.convertAndSend("/topic/tasks/" + task.getIdAsString(), event); + + log.info("Photo upload (multipart) successful: taskId={}, jobId={}, photoId={}, count={}", + taskId, task.getJobIdAsString(), photo.getIdAsString(), base64Photos.size()); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + return badRequest(response, "Ungültige taskId"); + } catch (IOException e) { + log.error("Fehler beim Lesen der Dateien: {}", e.getMessage(), e); + return serverError(response, "Fehler beim Lesen der Dateien: " + e.getMessage()); + } catch (Exception e) { + log.error("Fehler beim Speichern der Fotos: {}", e.getMessage(), e); + return serverError(response, "Fehler beim Speichern der Fotos"); + } + } + + @PostMapping(path = "/tasks/{taskId}/photos", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> uploadPhotosJson( + @PathVariable("taskId") String taskId, + @RequestBody Map body + ) { + Map response = initResponse("photoUploadAck"); + if (!StringUtils.hasText(taskId)) { + return badRequest(response, "taskId ist erforderlich"); + } + try { + ObjectId taskObjectId = new ObjectId(taskId); + Optional opt = taskRepository.findById(taskObjectId); + if (opt.isEmpty()) { + return notFound(response, "Task nicht gefunden"); + } + BaseTask task = opt.get(); + if (!"PHOTO".equals(task.getTaskType())) { + return badRequest(response, "Task-Typ stimmt nicht. Erwartet: PHOTO, Gefunden: " + task.getTaskType()); + } + + String completedBy = body.get("completedBy") != null ? body.get("completedBy").toString() : null; + @SuppressWarnings("unchecked") + List photos = body.get("photos") instanceof List ? (List) body.get("photos") : null; + if (photos == null || photos.isEmpty()) { + return badRequest(response, "Feld 'photos' (Liste von Base64-Strings) ist erforderlich"); + } + + Photo photo = new Photo(task.getJobId(), task.getId(), photos, completedBy); + photoRepository.save(photo); + + response.put("success", true); + response.put("photoId", photo.getIdAsString()); + response.put("photosCount", photo.getCount()); + response.put("taskId", task.getIdAsString()); + response.put("jobId", task.getJobIdAsString()); + + Map event = new HashMap<>(); + event.put("event", "photoUploaded"); + event.put("taskId", task.getIdAsString()); + event.put("jobId", task.getJobIdAsString()); + event.put("photosCount", photo.getCount()); + event.put("timestamp", now()); + messagingTemplate.convertAndSend("/topic/tasks/" + task.getIdAsString(), event); + + log.info("Photo upload (json) successful: taskId={}, jobId={}, photoId={}, count={}", + taskId, task.getJobIdAsString(), photo.getIdAsString(), photo.getCount()); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + return badRequest(response, "Ungültige taskId"); + } catch (Exception e) { + log.error("Fehler beim Speichern der Fotos: {}", e.getMessage(), e); + return serverError(response, "Fehler beim Speichern der Fotos"); + } + } + + /** + * New simple JSON endpoint: accept a single base64 photo with taskId in the body. + * If there are multiple photos, call this endpoint multiple times. + * Body: { "taskId": "", "photo": "", "completedBy"?: "...", "note"?: "..." } + */ + @PostMapping(path = "/photos", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> uploadSinglePhotoJson( + @RequestBody Map body + ) { + Map response = initResponse("photoUploadAck"); + String taskId = body != null && body.get("taskId") != null ? body.get("taskId").toString() : null; + String completedBy = body != null && body.get("completedBy") != null ? body.get("completedBy").toString() : null; + String base64Photo = body != null && body.get("photo") != null ? body.get("photo").toString() : null; + + if (!StringUtils.hasText(taskId)) { + return badRequest(response, "taskId ist erforderlich"); + } + if (!StringUtils.hasText(base64Photo)) { + return badRequest(response, "Feld 'photo' (Base64-String) ist erforderlich"); + } + try { + ObjectId taskObjectId = new ObjectId(taskId); + Optional opt = taskRepository.findById(taskObjectId); + if (opt.isEmpty()) { + return notFound(response, "Task nicht gefunden"); + } + BaseTask task = opt.get(); + if (!"PHOTO".equals(task.getTaskType())) { + return badRequest(response, "Task-Typ stimmt nicht. Erwartet: PHOTO, Gefunden: " + task.getTaskType()); + } + + Photo photo = new Photo(task.getJobId(), task.getId(), java.util.List.of(base64Photo), completedBy); + photoRepository.save(photo); + + response.put("success", true); + response.put("photoId", photo.getIdAsString()); + response.put("photosCount", 1); + response.put("taskId", task.getIdAsString()); + response.put("jobId", task.getJobIdAsString()); + + Map event = new HashMap<>(); + event.put("event", "photoUploaded"); + event.put("taskId", task.getIdAsString()); + event.put("jobId", task.getJobIdAsString()); + event.put("photosCount", 1); + event.put("timestamp", now()); + messagingTemplate.convertAndSend("/topic/tasks/" + task.getIdAsString(), event); + + log.info("Photo upload (single json) successful: taskId={}, jobId={}, photoId={}", + taskId, task.getJobIdAsString(), photo.getIdAsString()); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + return badRequest(response, "Ungültige taskId"); + } catch (Exception e) { + log.error("Fehler beim Speichern des Fotos: {}", e.getMessage(), e); + return serverError(response, "Fehler beim Speichern des Fotos"); + } + } + + private Map initResponse(String type) { + Map map = new HashMap<>(); + map.put("timestamp", now()); + map.put("type", type); + return map; + } + + private String now() { + return LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } + + private ResponseEntity> badRequest(Map response, String msg) { + response.put("success", false); + response.put("message", msg); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + + } + + private ResponseEntity> notFound(Map response, String msg) { + response.put("success", false); + response.put("message", msg); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); + } + + private ResponseEntity> serverError(Map response, String msg) { + response.put("success", false); + response.put("message", msg); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } +} diff --git a/src/main/java/de/assecutor/votianlt/model/Photo.java b/src/main/java/de/assecutor/votianlt/model/Photo.java new file mode 100644 index 0000000..730e8c3 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/model/Photo.java @@ -0,0 +1,123 @@ +package de.assecutor.votianlt.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.bson.types.ObjectId; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Photo entity for storing photo data from task completions. + * References the job ObjectId and stores base64 encoded photos. + */ +@Document(collection = "photos") +public class Photo { + + @Id + private ObjectId id; + + private ObjectId jobId; + private ObjectId taskId; + private List photos; // base64 encoded photos + private int count; + private LocalDateTime createdAt; + private String completedBy; + + // Default constructor + public Photo() { + this.createdAt = LocalDateTime.now(); + } + + // Constructor with parameters + public Photo(ObjectId jobId, ObjectId taskId, List photos, String completedBy) { + this(); + this.jobId = jobId; + this.taskId = taskId; + this.photos = photos; + this.count = photos != null ? photos.size() : 0; + this.completedBy = completedBy; + } + + // Getters and Setters + public ObjectId getId() { + return id; + } + + public void setId(ObjectId id) { + this.id = id; + } + + public String getIdAsString() { + return id != null ? id.toHexString() : null; + } + + public ObjectId getJobId() { + return jobId; + } + + public void setJobId(ObjectId jobId) { + this.jobId = jobId; + } + + public String getJobIdAsString() { + return jobId != null ? jobId.toHexString() : null; + } + + public ObjectId getTaskId() { + return taskId; + } + + public void setTaskId(ObjectId taskId) { + this.taskId = taskId; + } + + public String getTaskIdAsString() { + return taskId != null ? taskId.toHexString() : null; + } + + public List getPhotos() { + return photos; + } + + public void setPhotos(List photos) { + this.photos = photos; + this.count = photos != null ? photos.size() : 0; + } + + public int getCount() { + return count; + } + + public void setCount(int count) { + this.count = count; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public String getCompletedBy() { + return completedBy; + } + + public void setCompletedBy(String completedBy) { + this.completedBy = completedBy; + } + + @Override + public String toString() { + return "Photo{" + + "id=" + id + + ", jobId=" + jobId + + ", taskId=" + taskId + + ", count=" + count + + ", createdAt=" + createdAt + + ", completedBy='" + completedBy + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/repository/PhotoRepository.java b/src/main/java/de/assecutor/votianlt/repository/PhotoRepository.java new file mode 100644 index 0000000..b08eb39 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/repository/PhotoRepository.java @@ -0,0 +1,48 @@ +package de.assecutor.votianlt.repository; + +import de.assecutor.votianlt.model.Photo; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Repository interface for Photo entities. + * Provides database operations for the photos collection. + */ +@Repository +public interface PhotoRepository extends MongoRepository { + + /** + * Find all photos associated with a specific job ID. + * @param jobId The ObjectId of the job + * @return List of photos for the job + */ + List findByJobId(ObjectId jobId); + + /** + * Find all photos associated with a specific task ID. + * @param taskId The ObjectId of the task + * @return List of photos for the task + */ + List findByTaskId(ObjectId taskId); + + /** + * Find photos by job ID as string. + * @param jobId The job ID as string + * @return List of photos for the job + */ + default List findByJobId(String jobId) { + return findByJobId(new ObjectId(jobId)); + } + + /** + * Find photos by task ID as string. + * @param taskId The task ID as string + * @return List of photos for the task + */ + default List findByTaskId(String taskId) { + return findByTaskId(new ObjectId(taskId)); + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e4a48a2..08b889c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -29,14 +29,26 @@ mail.smtp.host=smtp.ionos.de mail.smtp.port=587 # WebSocket and STOMP Configuration -# WebSocket message size limits (in bytes) -spring.websocket.servlet.max-text-message-buffer-size=8192 -spring.websocket.servlet.max-binary-message-buffer-size=8192 +# WebSocket message size limits (in bytes) - Increased for large photo payloads # Enable STOMP over WebSocket spring.websocket.stomp.enabled=true # STOMP heartbeat settings (in milliseconds) spring.websocket.stomp.heartbeat.outgoing=10000 spring.websocket.stomp.heartbeat.incoming=10000 +# HTTP request size limits for large payloads +server.max-http-request-header-size=8MB +# Spring messaging size limits for STOMP +# Tomcat connector limits +server.tomcat.max-http-form-post-size=64MB +server.tomcat.max-save-post-size=64MB +server.tomcat.max-swallow-size=64MB +# Multipart upload limits for photo HTTP uploads +spring.servlet.multipart.max-file-size=32MB +spring.servlet.multipart.max-request-size=64MB +# Additional WebSocket and messaging limits for large payloads +# STOMP broker relay limits +# Jackson message converter limits +spring.jackson.default-property-inclusion=non_null # 2FA Configuration app.security.two-factor.enabled=false \ No newline at end of file