Update UI + Agora Call
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
package com.walkguide.config;
|
||||
|
||||
import com.google.auth.oauth2.GoogleCredentials;
|
||||
import com.google.firebase.FirebaseApp;
|
||||
import com.google.firebase.FirebaseOptions;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FirebaseConfig {
|
||||
|
||||
private final ResourceLoader resourceLoader;
|
||||
|
||||
@Value("${firebase.credentials-path:classpath:firebase/google-services-admin.json}")
|
||||
private String credentialsPath;
|
||||
|
||||
@PostConstruct
|
||||
void initializeFirebase() {
|
||||
if (!FirebaseApp.getApps().isEmpty()) {
|
||||
log.info("[FIREBASE] FirebaseApp already initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Resource resource = resourceLoader.getResource(credentialsPath);
|
||||
if (!resource.exists() || !resource.isReadable()) {
|
||||
log.warn("[FIREBASE] Credential not found/readable at {}. FCM runs in log-only fallback.", credentialsPath);
|
||||
return;
|
||||
}
|
||||
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
FirebaseOptions options = FirebaseOptions.builder()
|
||||
.setCredentials(GoogleCredentials.fromStream(in))
|
||||
.build();
|
||||
FirebaseApp.initializeApp(options);
|
||||
}
|
||||
|
||||
log.info("[FIREBASE] Firebase Admin initialized from {}", credentialsPath);
|
||||
} catch (Exception e) {
|
||||
log.warn("[FIREBASE] Failed to initialize Firebase Admin. FCM fallback active: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,12 @@ import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -36,35 +39,76 @@ public class CallController {
|
||||
@Operation(summary = "Generate Agora token", description = "Caller requests a token before joining Agora")
|
||||
public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken(
|
||||
@Valid @RequestBody CallTokenRequest req) {
|
||||
|
||||
Long callerId = SecurityHelper.getCurrentUserId();
|
||||
AgoraTokenResponse response = agoraTokenService.generateToken(callerId, req.getReceiverId());
|
||||
|
||||
log.info("[CALL] Token generated | caller={} receiver={} channel={}",
|
||||
callerId, req.getReceiverId(), response.getChannelName());
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate"));
|
||||
}
|
||||
|
||||
@PostMapping("/notify")
|
||||
@Operation(summary = "Notify receiver of incoming call")
|
||||
public ResponseEntity<ApiResponse<Void>> notifyCall(
|
||||
@Valid @RequestBody CallNotifyRequest req) {
|
||||
|
||||
public ResponseEntity<ApiResponse<Void>> notifyCall(@Valid @RequestBody CallNotifyRequest req) {
|
||||
Long callerId = SecurityHelper.getCurrentUserId();
|
||||
String message = callNotificationService.notifyIncomingCall(callerId, req);
|
||||
return ResponseEntity.ok(ApiResponse.ok(null, message));
|
||||
}
|
||||
|
||||
@PostMapping("/accept")
|
||||
@Operation(summary = "Receiver accepts incoming call")
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> acceptCall(@RequestBody Map<String, String> body) {
|
||||
Long receiverId = SecurityHelper.getCurrentUserId();
|
||||
Long callerId = Long.parseLong(body.get("callerId"));
|
||||
String channelName = body.get("channelName");
|
||||
return ResponseEntity.ok(ApiResponse.ok(
|
||||
callNotificationService.acceptCall(receiverId, callerId, channelName),
|
||||
"Call accepted"
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/pending")
|
||||
@Operation(summary = "Get pending incoming call for logged-in receiver")
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> pendingCall() {
|
||||
Long receiverId = SecurityHelper.getCurrentUserId();
|
||||
return ResponseEntity.ok(ApiResponse.ok(callNotificationService.getPendingCall(receiverId), "Pending call"));
|
||||
}
|
||||
|
||||
@DeleteMapping("/pending")
|
||||
@Operation(summary = "Clear pending incoming call for logged-in receiver")
|
||||
public ResponseEntity<ApiResponse<Void>> clearPendingCall() {
|
||||
Long receiverId = SecurityHelper.getCurrentUserId();
|
||||
callNotificationService.clearPendingCall(receiverId);
|
||||
return ResponseEntity.ok(ApiResponse.ok(null, "Pending call cleared"));
|
||||
}
|
||||
|
||||
@GetMapping("/accepted")
|
||||
@Operation(summary = "Get accepted call for logged-in caller")
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> acceptedCall() {
|
||||
Long callerId = SecurityHelper.getCurrentUserId();
|
||||
return ResponseEntity.ok(ApiResponse.ok(callNotificationService.getAcceptedCall(callerId), "Accepted call"));
|
||||
}
|
||||
|
||||
@DeleteMapping("/accepted")
|
||||
@Operation(summary = "Clear accepted call for logged-in caller")
|
||||
public ResponseEntity<ApiResponse<Void>> clearAcceptedCall() {
|
||||
Long callerId = SecurityHelper.getCurrentUserId();
|
||||
callNotificationService.clearAcceptedCall(callerId);
|
||||
return ResponseEntity.ok(ApiResponse.ok(null, "Accepted call cleared"));
|
||||
}
|
||||
|
||||
@GetMapping("/state")
|
||||
@Operation(summary = "Get call state by Agora channel")
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> callState(@RequestParam String channelName) {
|
||||
return ResponseEntity.ok(ApiResponse.ok(callNotificationService.getCallState(channelName), "Call state"));
|
||||
}
|
||||
|
||||
@PostMapping("/end")
|
||||
@Operation(summary = "Notify end of call")
|
||||
public ResponseEntity<ApiResponse<Void>> endCall(
|
||||
@RequestBody Map<String, Long> body) {
|
||||
|
||||
public ResponseEntity<ApiResponse<Void>> endCall(@RequestBody Map<String, String> body) {
|
||||
Long callerId = SecurityHelper.getCurrentUserId();
|
||||
Long otherId = body.get("otherId");
|
||||
callNotificationService.notifyCallEnded(callerId, otherId);
|
||||
|
||||
Long otherId = Long.parseLong(body.get("otherId"));
|
||||
String channelName = body.get("channelName");
|
||||
callNotificationService.notifyCallEnded(callerId, otherId, channelName);
|
||||
return ResponseEntity.ok(ApiResponse.ok(null, "Call ended"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -1,6 +1,7 @@
|
||||
package com.walkguide.exception;
|
||||
|
||||
import com.walkguide.dto.ApiResponse;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
@@ -29,6 +30,13 @@ public class GlobalExceptionHandler {
|
||||
.body(ApiResponse.error("VALIDATION_ERROR", msg));
|
||||
}
|
||||
|
||||
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
public ResponseEntity<ApiResponse<Object>> handleDataIntegrity(DataIntegrityViolationException ex) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(ApiResponse.error("DATA_CONFLICT",
|
||||
"Data pairing lama masih bentrok. Refresh status atau unpair dulu, lalu coba lagi."));
|
||||
}
|
||||
@ExceptionHandler(RuntimeException.class)
|
||||
public ResponseEntity<ApiResponse<Object>> handleRuntime(RuntimeException ex) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
+116
-15
@@ -4,11 +4,14 @@ import com.walkguide.dto.request.CallNotifyRequest;
|
||||
import com.walkguide.entity.User;
|
||||
import com.walkguide.exception.ResourceNotFoundException;
|
||||
import com.walkguide.repository.UserRepository;
|
||||
import com.walkguide.websocket.LocationBroadcaster;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -17,29 +20,38 @@ public class CallNotificationService {
|
||||
|
||||
private final FcmService fcmService;
|
||||
private final UserRepository userRepository;
|
||||
private final LocationBroadcaster locationBroadcaster;
|
||||
private final Map<Long, Map<String, String>> pendingCalls = new ConcurrentHashMap<>();
|
||||
private final Map<Long, Map<String, String>> acceptedCalls = new ConcurrentHashMap<>();
|
||||
private final Map<String, Map<String, String>> callStates = new ConcurrentHashMap<>();
|
||||
|
||||
public String notifyIncomingCall(Long callerId, CallNotifyRequest req) {
|
||||
User caller = userRepository.findById(callerId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
|
||||
|
||||
User receiver = userRepository.findById(req.getReceiverId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
|
||||
|
||||
String callerName = caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail();
|
||||
Map<String, String> payload = new HashMap<>();
|
||||
payload.put("type", "INCOMING_CALL");
|
||||
payload.put("status", "RINGING");
|
||||
payload.put("callerId", String.valueOf(callerId));
|
||||
payload.put("receiverId", String.valueOf(receiver.getId()));
|
||||
payload.put("callerName", callerName);
|
||||
payload.put("channelName", req.getChannelName());
|
||||
payload.put("agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "");
|
||||
payload.put("receiverUid", String.valueOf(req.getReceiverUid()));
|
||||
|
||||
pendingCalls.put(receiver.getId(), payload);
|
||||
acceptedCalls.remove(callerId);
|
||||
callStates.put(req.getChannelName(), payload);
|
||||
locationBroadcaster.broadcastCall(receiver.getId(), payload);
|
||||
|
||||
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
|
||||
log.warn("[CALL] Receiver {} has no FCM token; push notification skipped", req.getReceiverId());
|
||||
return "Panggilan dikirim (receiver mungkin tidak menerima push notification)";
|
||||
return "Panggilan dikirim via realtime fallback.";
|
||||
}
|
||||
|
||||
String callerName = caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail();
|
||||
Map<String, String> payload = Map.of(
|
||||
"type", "INCOMING_CALL",
|
||||
"callerId", String.valueOf(callerId),
|
||||
"callerName", callerName,
|
||||
"channelName", req.getChannelName(),
|
||||
"agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "",
|
||||
"receiverUid", String.valueOf(req.getReceiverUid())
|
||||
);
|
||||
|
||||
fcmService.sendHighPriority(
|
||||
receiver.getFcmToken(),
|
||||
"Panggilan Masuk",
|
||||
@@ -52,22 +64,111 @@ public class CallNotificationService {
|
||||
return "Notifikasi panggilan berhasil dikirim";
|
||||
}
|
||||
|
||||
public Map<String, String> acceptCall(Long receiverId, Long callerId, String channelName) {
|
||||
User receiver = userRepository.findById(receiverId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
|
||||
userRepository.findById(callerId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
|
||||
|
||||
pendingCalls.remove(receiverId);
|
||||
String receiverName = receiver.getDisplayName() != null ? receiver.getDisplayName() : receiver.getEmail();
|
||||
Map<String, String> payload = new HashMap<>(getCallState(channelName));
|
||||
payload.put("type", "CALL_ACCEPTED");
|
||||
payload.put("status", "ACCEPTED");
|
||||
payload.put("callerId", String.valueOf(callerId));
|
||||
payload.put("receiverId", String.valueOf(receiverId));
|
||||
payload.put("receiverName", receiverName);
|
||||
payload.put("channelName", channelName != null ? channelName : "");
|
||||
payload.put("acceptedBy", String.valueOf(receiverId));
|
||||
payload.put("acceptedAt", String.valueOf(System.currentTimeMillis()));
|
||||
|
||||
acceptedCalls.put(callerId, payload);
|
||||
if (channelName != null && !channelName.isBlank()) {
|
||||
callStates.put(channelName, payload);
|
||||
}
|
||||
locationBroadcaster.broadcastCall(callerId, payload);
|
||||
locationBroadcaster.broadcastCall(receiverId, payload);
|
||||
log.info("[CALL] Call accepted | caller={} receiver={} channel={}", callerId, receiverId, channelName);
|
||||
return payload;
|
||||
}
|
||||
|
||||
public Map<String, String> getPendingCall(Long receiverId) {
|
||||
return pendingCalls.get(receiverId);
|
||||
}
|
||||
|
||||
public void clearPendingCall(Long receiverId) {
|
||||
pendingCalls.remove(receiverId);
|
||||
}
|
||||
|
||||
public Map<String, String> getAcceptedCall(Long callerId) {
|
||||
return acceptedCalls.get(callerId);
|
||||
}
|
||||
|
||||
public void clearAcceptedCall(Long callerId) {
|
||||
acceptedCalls.remove(callerId);
|
||||
}
|
||||
|
||||
public Map<String, String> getCallState(String channelName) {
|
||||
if (channelName == null || channelName.isBlank()) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
return callStates.getOrDefault(channelName, new HashMap<>());
|
||||
}
|
||||
|
||||
public void notifyCallEnded(Long callerId, Long otherId) {
|
||||
notifyCallEnded(callerId, otherId, null);
|
||||
}
|
||||
|
||||
public void notifyCallEnded(Long callerId, Long otherId, String channelName) {
|
||||
if (otherId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearPendingCall(otherId);
|
||||
clearPendingCall(callerId);
|
||||
clearAcceptedCall(callerId);
|
||||
clearAcceptedCall(otherId);
|
||||
|
||||
String resolvedChannel = channelName;
|
||||
if (resolvedChannel == null || resolvedChannel.isBlank()) {
|
||||
resolvedChannel = findActiveChannel(callerId, otherId);
|
||||
}
|
||||
Map<String, String> payload = new HashMap<>(getCallState(resolvedChannel));
|
||||
payload.put("type", "CALL_ENDED");
|
||||
payload.put("status", "ENDED");
|
||||
payload.put("callerId", String.valueOf(callerId));
|
||||
payload.put("otherId", String.valueOf(otherId));
|
||||
payload.put("channelName", resolvedChannel != null ? resolvedChannel : "");
|
||||
payload.put("endedBy", String.valueOf(callerId));
|
||||
payload.put("endedAt", String.valueOf(System.currentTimeMillis()));
|
||||
if (resolvedChannel != null && !resolvedChannel.isBlank()) {
|
||||
callStates.put(resolvedChannel, payload);
|
||||
}
|
||||
|
||||
locationBroadcaster.broadcastCall(otherId, payload);
|
||||
locationBroadcaster.broadcastCall(callerId, payload);
|
||||
|
||||
userRepository.findById(otherId).ifPresent(other -> {
|
||||
if (other.getFcmToken() == null || other.getFcmToken().isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
fcmService.sendToToken(
|
||||
other.getFcmToken(),
|
||||
"Panggilan Berakhir",
|
||||
"Panggilan telah berakhir",
|
||||
Map.of("type", "CALL_ENDED", "callerId", String.valueOf(callerId))
|
||||
payload
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private String findActiveChannel(Long userA, Long userB) {
|
||||
String a = String.valueOf(userA);
|
||||
String b = String.valueOf(userB);
|
||||
return callStates.entrySet().stream()
|
||||
.filter(e -> a.equals(e.getValue().get("callerId")) && b.equals(e.getValue().get("receiverId"))
|
||||
|| b.equals(e.getValue().get("callerId")) && a.equals(e.getValue().get("receiverId")))
|
||||
.map(Map.Entry::getKey)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,130 @@
|
||||
package com.walkguide.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import com.google.cloud.Timestamp;
|
||||
import com.google.cloud.firestore.Firestore;
|
||||
import com.google.firebase.FirebaseApp;
|
||||
import com.google.firebase.cloud.FirestoreClient;
|
||||
import com.google.firebase.messaging.AndroidConfig;
|
||||
import com.google.firebase.messaging.AndroidNotification;
|
||||
import com.google.firebase.messaging.FirebaseMessaging;
|
||||
import com.google.firebase.messaging.Message;
|
||||
import com.google.firebase.messaging.Notification;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* FCM Service untuk push notification.
|
||||
* Saat ini dalam mode LOG-ONLY agar tidak butuh Firebase credentials dulu.
|
||||
* Untuk enable FCM nyata: uncomment bagian Firebase dan tambah dependency Firebase Admin SDK.
|
||||
* FCM Service untuk push notification dan audit notifikasi ke Firestore.
|
||||
* Jika Firebase credential belum tersedia, service tetap aman berjalan dalam mode log-only.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FcmService {
|
||||
|
||||
public void sendToToken(String fcmToken, String title, String body, Map<String, String> data) {
|
||||
if (fcmToken == null || fcmToken.isBlank()) {
|
||||
log.warn("[FCM] Token kosong, skip notifikasi: {} - {}", title, body);
|
||||
return;
|
||||
}
|
||||
// LOG ONLY untuk sekarang
|
||||
log.info("[FCM] TO={} | TITLE={} | BODY={} | DATA={}", fcmToken, title, body, data);
|
||||
private static final Logger log = LoggerFactory.getLogger(FcmService.class);
|
||||
|
||||
// TODO: uncomment ini setelah tambah Firebase Admin SDK ke pom.xml
|
||||
// dan taruh google-services-admin.json di src/main/resources/firebase/
|
||||
//
|
||||
// try {
|
||||
// Message message = Message.builder()
|
||||
// .setToken(fcmToken)
|
||||
// .setNotification(Notification.builder().setTitle(title).setBody(body).build())
|
||||
// .putAllData(data != null ? data : Map.of())
|
||||
// .setAndroidConfig(AndroidConfig.builder()
|
||||
// .setPriority(AndroidConfig.Priority.HIGH)
|
||||
// .build())
|
||||
// .build();
|
||||
// String response = FirebaseMessaging.getInstance().send(message);
|
||||
// log.info("[FCM] Sent successfully: {}", response);
|
||||
// } catch (FirebaseMessagingException e) {
|
||||
// log.error("[FCM] Failed to send: {}", e.getMessage());
|
||||
// }
|
||||
public void sendToToken(String fcmToken, String title, String body, Map<String, String> data) {
|
||||
sendInternal(fcmToken, title, body, data, false);
|
||||
}
|
||||
|
||||
public void sendHighPriority(String fcmToken, String title, String body, Map<String, String> data) {
|
||||
// SOS dan incoming call pakai ini - sama untuk sekarang
|
||||
sendToToken(fcmToken, title, body, data);
|
||||
sendInternal(fcmToken, title, body, data, true);
|
||||
}
|
||||
|
||||
@Value("${firebase.notifications-collection:notifications}")
|
||||
private String notificationsCollection;
|
||||
|
||||
private void sendInternal(String fcmToken, String title, String body, Map<String, String> data, boolean highPriority) {
|
||||
Map<String, String> safeData = data != null ? data : Map.of();
|
||||
String status = "SKIPPED";
|
||||
String messageId = null;
|
||||
|
||||
if (fcmToken == null || fcmToken.isBlank()) {
|
||||
log.warn("[FCM] Token kosong, skip notifikasi: {} - {}", title, body);
|
||||
saveNotificationAudit(fcmToken, title, body, safeData, highPriority, status, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (FirebaseApp.getApps().isEmpty()) {
|
||||
status = "LOG_ONLY";
|
||||
log.info("[FCM] LOG_ONLY TO={} | TITLE={} | BODY={} | DATA={}",
|
||||
maskToken(fcmToken), title, body, safeData);
|
||||
saveNotificationAudit(fcmToken, title, body, safeData, highPriority, status, null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
AndroidConfig.Priority priority = highPriority
|
||||
? AndroidConfig.Priority.HIGH
|
||||
: AndroidConfig.Priority.NORMAL;
|
||||
|
||||
AndroidNotification androidNotification = AndroidNotification.builder()
|
||||
.setChannelId(highPriority ? "walkguide_urgent" : "walkguide_alerts")
|
||||
.setPriority(highPriority
|
||||
? AndroidNotification.Priority.MAX
|
||||
: AndroidNotification.Priority.DEFAULT)
|
||||
.build();
|
||||
|
||||
Message message = Message.builder()
|
||||
.setToken(fcmToken)
|
||||
.setNotification(Notification.builder()
|
||||
.setTitle(title != null ? title : "WalkGuide")
|
||||
.setBody(body != null ? body : "")
|
||||
.build())
|
||||
.putAllData(safeData)
|
||||
.setAndroidConfig(AndroidConfig.builder()
|
||||
.setPriority(priority)
|
||||
.setNotification(androidNotification)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
messageId = FirebaseMessaging.getInstance().send(message);
|
||||
status = "SENT";
|
||||
log.info("[FCM] Sent {} notification successfully: {}", highPriority ? "high-priority" : "normal", messageId);
|
||||
} catch (Exception e) {
|
||||
status = "FAILED";
|
||||
log.error("[FCM] Failed to send notification: {}", e.getMessage());
|
||||
} finally {
|
||||
saveNotificationAudit(fcmToken, title, body, safeData, highPriority, status, messageId);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveNotificationAudit(String fcmToken, String title, String body, Map<String, String> data,
|
||||
boolean highPriority, String status, String messageId) {
|
||||
if (FirebaseApp.getApps().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Firestore firestore = FirestoreClient.getFirestore();
|
||||
Map<String, Object> doc = new HashMap<>();
|
||||
doc.put("title", title);
|
||||
doc.put("body", body);
|
||||
doc.put("type", data.getOrDefault("type", "GENERAL"));
|
||||
doc.put("data", data);
|
||||
doc.put("priority", highPriority ? "HIGH" : "NORMAL");
|
||||
doc.put("status", status);
|
||||
doc.put("messageId", messageId);
|
||||
doc.put("recipientTokenMasked", maskToken(fcmToken));
|
||||
doc.put("createdAt", Timestamp.ofTimeSecondsAndNanos(Instant.now().getEpochSecond(), 0));
|
||||
|
||||
firestore.collection(notificationsCollection).add(doc).get();
|
||||
log.debug("[FIRESTORE] Notification audit saved | type={} status={}", doc.get("type"), status);
|
||||
} catch (Exception e) {
|
||||
log.warn("[FIRESTORE] Notification audit skipped: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String maskToken(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
int visible = Math.min(6, token.length());
|
||||
return "***" + token.substring(token.length() - visible);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import com.walkguide.enums.*;
|
||||
import com.walkguide.exception.PairingException;
|
||||
import com.walkguide.exception.ResourceNotFoundException;
|
||||
import com.walkguide.repository.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -18,7 +17,6 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PairingService {
|
||||
|
||||
private final PairingRelationRepository pairingRelationRepository;
|
||||
@@ -34,6 +32,22 @@ public class PairingService {
|
||||
private static final int PAIRING_CODE_TTL_MINUTES = 15;
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
public PairingService(PairingRelationRepository pairingRelationRepository,
|
||||
UserRepository userRepository,
|
||||
VoiceCommandConfigRepository voiceCommandConfigRepository,
|
||||
HardwareShortcutRepository hardwareShortcutRepository,
|
||||
AiConfigRepository aiConfigRepository,
|
||||
ActivityLogService activityLogService,
|
||||
FcmService fcmService) {
|
||||
this.pairingRelationRepository = pairingRelationRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.voiceCommandConfigRepository = voiceCommandConfigRepository;
|
||||
this.hardwareShortcutRepository = hardwareShortcutRepository;
|
||||
this.aiConfigRepository = aiConfigRepository;
|
||||
this.activityLogService = activityLogService;
|
||||
this.fcmService = fcmService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PairingCodeResponse getOrCreatePairingCode(Long userId) {
|
||||
User user = userRepository.findById(userId)
|
||||
@@ -69,7 +83,6 @@ public class PairingService {
|
||||
|
||||
@Transactional
|
||||
public PairingStatusResponse inviteUser(Long guardianId, String submittedCode) {
|
||||
// Guardian tidak boleh punya pairing ACTIVE atau PENDING
|
||||
if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) {
|
||||
throw new PairingException("Kamu sudah memiliki user yang dipair. Unpair dulu sebelum invite user baru.");
|
||||
}
|
||||
@@ -88,6 +101,52 @@ public class PairingService {
|
||||
throw new PairingException("User ini sudah dipair dengan Guardian lain.");
|
||||
}
|
||||
|
||||
var existingGuardianPairing = pairingRelationRepository.findByGuardian_Id(guardianId);
|
||||
if (existingGuardianPairing.isPresent()) {
|
||||
PairingRelation existing = existingGuardianPairing.get();
|
||||
if (existing.getStatus() == PairingStatus.ACTIVE) {
|
||||
if (existing.getUser().getId().equals(user.getId())) {
|
||||
return buildStatus(existing, guardian, existing.getUser(), "GUARDIAN");
|
||||
}
|
||||
throw new PairingException(
|
||||
"Guardian sudah pairing aktif dengan User lain. Unpair dulu sebelum invite User baru.");
|
||||
}
|
||||
if (existing.getStatus() == PairingStatus.PENDING) {
|
||||
if (existing.getUser().getId().equals(user.getId())) {
|
||||
sendPairingInviteNotification(existing, guardian, user);
|
||||
return buildStatus(existing, guardian, user, "GUARDIAN");
|
||||
}
|
||||
throw new PairingException(
|
||||
"Guardian masih punya undangan pairing yang menunggu respons User.");
|
||||
}
|
||||
}
|
||||
|
||||
var existingUserPairing = pairingRelationRepository.findByUser_Id(user.getId());
|
||||
if (existingUserPairing.isPresent()) {
|
||||
PairingRelation existing = existingUserPairing.get();
|
||||
if (existing.getStatus() == PairingStatus.ACTIVE) {
|
||||
throw new PairingException("User ini sudah dipair dengan Guardian lain.");
|
||||
}
|
||||
if (existing.getStatus() == PairingStatus.PENDING) {
|
||||
if (existing.getGuardian().getId().equals(guardianId)) {
|
||||
sendPairingInviteNotification(existing, guardian, user);
|
||||
return buildStatus(existing, guardian, user, "GUARDIAN");
|
||||
}
|
||||
throw new PairingException("User ini masih punya undangan pairing dari Guardian lain.");
|
||||
}
|
||||
}
|
||||
|
||||
if (existingGuardianPairing.isPresent()) {
|
||||
pairingRelationRepository.delete(existingGuardianPairing.get());
|
||||
pairingRelationRepository.flush();
|
||||
}
|
||||
if (existingUserPairing.isPresent()
|
||||
&& (existingGuardianPairing.isEmpty()
|
||||
|| !existingUserPairing.get().getId().equals(existingGuardianPairing.get().getId()))) {
|
||||
pairingRelationRepository.delete(existingUserPairing.get());
|
||||
pairingRelationRepository.flush();
|
||||
}
|
||||
|
||||
PairingRelation pairing = PairingRelation.builder()
|
||||
.guardian(guardian)
|
||||
.user(user)
|
||||
@@ -99,11 +158,7 @@ public class PairingService {
|
||||
user.setPairingCodeExpiresAt(null);
|
||||
userRepository.save(user);
|
||||
|
||||
// Kirim FCM ke user
|
||||
fcmService.sendToToken(user.getFcmToken(),
|
||||
"Pairing Request",
|
||||
"Guardian " + guardian.getDisplayName() + " mengundang kamu untuk terhubung",
|
||||
Map.of("type", "PAIRING_INVITE", "guardianName", guardian.getDisplayName()));
|
||||
sendPairingInviteNotification(pairing, guardian, user);
|
||||
|
||||
activityLogService.createLog(guardian, ActivityLogType.PAIRING_INVITE_SENT,
|
||||
"Guardian mengirim invite ke " + user.getDisplayName(), null);
|
||||
@@ -195,6 +250,13 @@ public class PairingService {
|
||||
// ========== PRIVATE ==========
|
||||
|
||||
private void seedDefaults(Long guardianId, Long userId) {
|
||||
voiceCommandConfigRepository.deleteByUserId(userId);
|
||||
hardwareShortcutRepository.deleteByUserId(userId);
|
||||
aiConfigRepository.findByUserId(userId).ifPresent(aiConfigRepository::delete);
|
||||
voiceCommandConfigRepository.flush();
|
||||
hardwareShortcutRepository.flush();
|
||||
aiConfigRepository.flush();
|
||||
|
||||
// Voice commands default
|
||||
List<VoiceCommandConfig> defaults = List.of(
|
||||
vc(guardianId, userId, VoiceCommandKey.OPEN_WALKGUIDE, "Open Walkguide"),
|
||||
@@ -261,6 +323,15 @@ public class PairingService {
|
||||
return user;
|
||||
}
|
||||
|
||||
private void sendPairingInviteNotification(PairingRelation pairing, User guardian, User user) {
|
||||
fcmService.sendToToken(user.getFcmToken(),
|
||||
"Pairing Request",
|
||||
"Guardian " + guardian.getDisplayName() + " mengundang kamu untuk terhubung",
|
||||
Map.of(
|
||||
"type", "PAIRING_INVITE",
|
||||
"pairingId", pairing.getId().toString(),
|
||||
"guardianName", guardian.getDisplayName()));
|
||||
}
|
||||
private void assignNewPairingCode(User user, LocalDateTime now) {
|
||||
String candidate;
|
||||
do {
|
||||
@@ -307,3 +378,4 @@ public class PairingService {
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.walkguide.entity.User;
|
||||
import com.walkguide.enums.ActivityLogType;
|
||||
import com.walkguide.enums.PairingStatus;
|
||||
import com.walkguide.enums.SosStatus;
|
||||
import com.walkguide.exception.PairingException;
|
||||
import com.walkguide.exception.ResourceNotFoundException;
|
||||
import com.walkguide.repository.*;
|
||||
import com.walkguide.websocket.LocationBroadcaster;
|
||||
@@ -36,6 +37,14 @@ public class SosService {
|
||||
|
||||
@Transactional
|
||||
public SosEventResponse triggerSos(Long userId, SosRequest req) {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
|
||||
|
||||
var activePairing = pairingRelationRepository
|
||||
.findByUser_IdAndStatus(userId, PairingStatus.ACTIVE)
|
||||
.orElseThrow(() -> new PairingException(
|
||||
"SOS hanya bisa dikirim setelah User terhubung dengan Guardian aktif."));
|
||||
|
||||
SosEvent sos = SosEvent.builder()
|
||||
.userId(userId)
|
||||
.triggerType(req.getTriggerType() != null ? req.getTriggerType() : "MANUAL")
|
||||
@@ -46,18 +55,13 @@ public class SosService {
|
||||
sos = sosEventRepository.save(sos);
|
||||
final SosEvent savedSos = sos;
|
||||
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
|
||||
|
||||
activityLogService.createLog(user, ActivityLogType.SOS_TRIGGERED,
|
||||
"SOS dikirim via " + sos.getTriggerType(), null);
|
||||
|
||||
SosEventResponse sosResponse = toResponse(savedSos);
|
||||
|
||||
// Kirim ke Guardian via FCM (background) + WebSocket (foreground)
|
||||
pairingRelationRepository.findByUser_IdAndStatus(userId, PairingStatus.ACTIVE)
|
||||
.ifPresent(pairing -> {
|
||||
User guardian = pairing.getGuardian();
|
||||
User guardian = activePairing.getGuardian();
|
||||
String guardianFcm = guardian.getFcmToken();
|
||||
String locStr = req.getLat() != null
|
||||
? String.format("Lat:%.4f,Lng:%.4f", req.getLat(), req.getLng())
|
||||
@@ -78,7 +82,6 @@ public class SosService {
|
||||
|
||||
log.info("[SOS] Alert sent to Guardian={} for User={} | trigger={}",
|
||||
guardian.getId(), userId, savedSos.getTriggerType());
|
||||
});
|
||||
|
||||
return sosResponse;
|
||||
}
|
||||
|
||||
+20
-39
@@ -3,68 +3,49 @@ package com.walkguide.websocket;
|
||||
import com.walkguide.dto.response.LocationResponse;
|
||||
import com.walkguide.dto.response.NotificationResponse;
|
||||
import com.walkguide.dto.response.SosEventResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Service untuk broadcast pesan real-time via WebSocket (STOMP).
|
||||
*
|
||||
* Dipakai oleh:
|
||||
* - LocationService → broadcast GPS ke Guardian
|
||||
* - SosService → broadcast SOS ke Guardian
|
||||
* - NotificationService→ broadcast notif ke User
|
||||
*
|
||||
* PATTERN: Observer — Guardian/User subscribe ke topic,
|
||||
* LocationBroadcaster push data saat ada update.
|
||||
*/
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class LocationBroadcaster {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LocationBroadcaster.class);
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
/**
|
||||
* Broadcast lokasi GPS user ke Guardian yang subscribe.
|
||||
* Guardian Flutter subscribe ke: /topic/location/{userId}
|
||||
*
|
||||
* @param userId ID dari ROLE_USER (bukan guardian)
|
||||
* @param location Response lokasi terbaru
|
||||
*/
|
||||
public LocationBroadcaster(SimpMessagingTemplate messagingTemplate) {
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
}
|
||||
|
||||
public void broadcastLocation(Long userId, LocationResponse location) {
|
||||
String destination = "/topic/location/" + userId;
|
||||
messagingTemplate.convertAndSend(destination, location);
|
||||
log.debug("[WS] Location broadcast → {} | lat={} lng={}",
|
||||
log.debug("[WS] Location broadcast -> {} | lat={} lng={}",
|
||||
destination, location.getLat(), location.getLng());
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast SOS event ke Guardian secara real-time.
|
||||
* Guardian Flutter subscribe ke: /queue/sos/{guardianId}
|
||||
*
|
||||
* @param guardianId ID dari ROLE_GUARDIAN
|
||||
* @param sos SOS event yang baru di-trigger
|
||||
*/
|
||||
public void broadcastSos(Long guardianId, SosEventResponse sos) {
|
||||
String destination = "/queue/sos/" + guardianId;
|
||||
messagingTemplate.convertAndSend(destination, sos);
|
||||
log.info("[WS] SOS broadcast → {} | userId={} status={}",
|
||||
log.info("[WS] SOS broadcast -> {} | userId={} status={}",
|
||||
destination, sos.getUserId(), sos.getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast notifikasi dari Guardian ke User secara real-time.
|
||||
* User Flutter subscribe ke: /queue/notif/{userId}
|
||||
*
|
||||
* @param userId ID dari ROLE_USER yang menerima notif
|
||||
* @param notification Notifikasi yang baru dikirim Guardian
|
||||
*/
|
||||
public void broadcastNotification(Long userId, NotificationResponse notification) {
|
||||
String destination = "/queue/notif/" + userId;
|
||||
messagingTemplate.convertAndSend(destination, notification);
|
||||
log.debug("[WS] Notification broadcast → {} | type={}",
|
||||
log.debug("[WS] Notification broadcast -> {} | type={}",
|
||||
destination, notification.getNotifType());
|
||||
}
|
||||
|
||||
public void broadcastCall(Long receiverId, Map<String, String> payload) {
|
||||
String destination = "/queue/call/" + receiverId;
|
||||
messagingTemplate.convertAndSend(destination, payload);
|
||||
log.info("[WS] Call broadcast -> {} | type={} status={} channel={}",
|
||||
destination, payload.get("type"), payload.get("status"), payload.get("channelName"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user