Erweiterungen

This commit is contained in:
2025-09-13 22:07:01 +02:00
parent f4656cd193
commit 5adfb9c2db
8 changed files with 801 additions and 347 deletions

View File

@@ -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": "<MongoId als String>",
"completedBy": "<optional: Name/ID des Ausführenden>",
"note": "<optional: Kommentar>"
}
```
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": [
"<base64-of-image-1>",
"<base64-of-image-2>"
]
}
```
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": "<base64-of-image>",
"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.

View File

@@ -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,8 +19,10 @@ 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
@@ -29,18 +37,45 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
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<MessageConverter> 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")
@@ -51,5 +86,7 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
registry.addEndpoint("/stomp")
.setAllowedOriginPatterns("*")
.addInterceptors(new HttpSessionHandshakeInterceptor());
log.info("WebSocket endpoints registered: /ws (with SockJS), /websocket, /stomp");
}
}

View File

@@ -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;
}
}

View File

@@ -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,12 +55,16 @@ 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<String, Object> handleMessage(Map<String, Object> 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
@@ -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<String, Object> handleTaskCompleted(Map<String, Object> payload) {
log.info("STOMP Endpoint '/app/task/completed' called with data: {}", payload);
Map<String, Object> 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<BaseTask> 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<String, Object> 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<String, Object> handlePhotoTaskCompleted(Map<String, Object> 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<String, Object> processPhotoTaskCompletion(Map<String, Object> payload) {
Map<String, Object> 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<BaseTask> 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<String, Object> extraData = (Map<String, Object>) payload.get("extraData");
if (extraData.containsKey("photos") && extraData.get("photos") != null) {
@SuppressWarnings("unchecked")
List<String> base64Photos = (List<String>) 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<String, Object> 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.

View File

@@ -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<Map<String, Object>> uploadPhotosMultipart(
@PathVariable("taskId") String taskId,
@RequestParam(value = "files") List<MultipartFile> files,
@RequestParam(value = "completedBy", required = false) String completedBy,
@RequestParam(value = "note", required = false) String note
) {
Map<String, Object> 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<BaseTask> 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<String> 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<String, Object> 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<Map<String, Object>> uploadPhotosJson(
@PathVariable("taskId") String taskId,
@RequestBody Map<String, Object> body
) {
Map<String, Object> response = initResponse("photoUploadAck");
if (!StringUtils.hasText(taskId)) {
return badRequest(response, "taskId ist erforderlich");
}
try {
ObjectId taskObjectId = new ObjectId(taskId);
Optional<BaseTask> 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<String> photos = body.get("photos") instanceof List ? (List<String>) 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<String, Object> 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": "<ObjectId>", "photo": "<base64>", "completedBy"?: "...", "note"?: "..." }
*/
@PostMapping(path = "/photos", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> uploadSinglePhotoJson(
@RequestBody Map<String, Object> body
) {
Map<String, Object> 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<BaseTask> 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<String, Object> 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<String, Object> initResponse(String type) {
Map<String, Object> 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<Map<String, Object>> badRequest(Map<String, Object> response, String msg) {
response.put("success", false);
response.put("message", msg);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
private ResponseEntity<Map<String, Object>> notFound(Map<String, Object> response, String msg) {
response.put("success", false);
response.put("message", msg);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
private ResponseEntity<Map<String, Object>> serverError(Map<String, Object> response, String msg) {
response.put("success", false);
response.put("message", msg);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}

View File

@@ -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<String> 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<String> 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<String> getPhotos() {
return photos;
}
public void setPhotos(List<String> 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 + '\'' +
'}';
}
}

View File

@@ -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<Photo, ObjectId> {
/**
* Find all photos associated with a specific job ID.
* @param jobId The ObjectId of the job
* @return List of photos for the job
*/
List<Photo> 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<Photo> 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<Photo> 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<Photo> findByTaskId(String taskId) {
return findByTaskId(new ObjectId(taskId));
}
}

View File

@@ -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