Update UI + Agora Call

This commit is contained in:
2026-05-27 19:36:01 +07:00
parent a629357e8c
commit 3cb32a4d69
42 changed files with 6516 additions and 1285 deletions
@@ -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"));
}
}
}
@@ -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)
@@ -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;
}
@@ -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"));
}
}
@@ -8,7 +8,16 @@ spring:
datasource:
url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
username: ${DB_USERNAME:5803024001}
password: ${DB_PASSWORD:pw5803024001}
password: ${DB_PASSWORD:pw5803024001}
hikari:
maximum-pool-size: ${DB_POOL_MAX:1}
minimum-idle: ${DB_POOL_MIN_IDLE:0}
connection-timeout: ${DB_CONNECTION_TIMEOUT:10000}
idle-timeout: ${DB_IDLE_TIMEOUT:30000}
max-lifetime: ${DB_MAX_LIFETIME:120000}
flyway:
enabled: ${FLYWAY_ENABLED:false}
jpa:
show-sql: true
@@ -21,8 +30,8 @@ jwt:
expiration: ${JWT_EXPIRATION:86400000}
agora:
app-id: ${AGORA_APP_ID:}
app-certificate: ${AGORA_APP_CERTIFICATE:}
app-id: ${AGORA_APP_ID:e36c2b6592e34cfda1f6ea6432a5e68d}
app-certificate: ${AGORA_APP_CERTIFICATE:70a4288475734a8c92ff8686c66cbc77}
logging:
level:
@@ -1,11 +1,19 @@
# ===== SERVER =====
spring.config.import=optional:file:./secrets.properties
server.port=${SERVER_PORT:8080}
server.address=${SERVER_ADDRESS:0.0.0.0}
# ===== POSTGRESQL CONNECTION =====
spring.datasource.url=${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
spring.datasource.username=${DB_USERNAME:5803024001}
spring.datasource.password=${DB_PASSWORD:pw5803024001}
spring.datasource.driver-class-name=org.postgresql.Driver
# ===== HIKARI POOL (keep DB classroom slots low) =====
spring.datasource.hikari.maximum-pool-size=${DB_POOL_MAX:1}
spring.datasource.hikari.minimum-idle=${DB_POOL_MIN_IDLE:0}
spring.datasource.hikari.connection-timeout=${DB_CONNECTION_TIMEOUT:10000}
spring.datasource.hikari.idle-timeout=${DB_IDLE_TIMEOUT:30000}
spring.datasource.hikari.max-lifetime=${DB_MAX_LIFETIME:120000}
# ===== JPA / HIBERNATE =====
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
@@ -27,9 +35,13 @@ springdoc.swagger-ui.path=/swagger-ui.html
springdoc.api-docs.path=/v3/api-docs
# ===== AGORA RTC =====
agora.app-id=${AGORA_APP_ID:}
agora.app-id=${AGORA_APP_ID:e36c2b6592e34cfda1f6ea6432a5e68d}
agora.app-certificate=${AGORA_APP_CERTIFICATE:}
# ===== FIREBASE =====
firebase.credentials-path=${FIREBASE_CREDENTIALS_PATH:classpath:firebase/google-services-admin.json}
firebase.notifications-collection=${FIREBASE_NOTIFICATIONS_COLLECTION:notifications}
# ===== WEBSOCKET =====
# WebSocket auto-dikonfigurasi oleh WebSocketConfig.java
@@ -4,10 +4,11 @@ import com.walkguide.dto.request.SosRequest;
import com.walkguide.dto.response.SosEventResponse;
import com.walkguide.entity.PairingRelation;
import com.walkguide.entity.SosEvent;
import com.walkguide.entity.User;
import com.walkguide.enums.PairingStatus;
import com.walkguide.enums.SosStatus;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.entity.User;
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;
import org.junit.jupiter.api.BeforeEach;
@@ -79,10 +80,10 @@ class SosServiceTest {
req.setLat(-7.257);
req.setLng(112.752);
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
.thenReturn(Optional.empty()); // tidak ada guardian → skip FCM
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(activePairing));
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
SosEventResponse result = sosService.triggerSos(2L, req);
@@ -103,10 +104,10 @@ class SosServiceTest {
req.setLat(-7.257);
req.setLng(112.752);
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
.thenReturn(Optional.empty());
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(activePairing));
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
ArgumentCaptor<SosEvent> captor = ArgumentCaptor.forClass(SosEvent.class);
@@ -147,12 +148,27 @@ class SosServiceTest {
SosRequest req = new SosRequest();
req.setTriggerType("MANUAL");
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
when(userRepository.findById(99L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> sosService.triggerSos(99L, req))
.isInstanceOf(ResourceNotFoundException.class);
}
when(userRepository.findById(99L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> sosService.triggerSos(99L, req))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
@DisplayName("triggerSos - tanpa pairing aktif: harus throw PairingException dan tidak simpan SOS")
void triggerSos_unpaired_shouldThrowPairingException() {
SosRequest req = new SosRequest();
req.setTriggerType("MANUAL");
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> sosService.triggerSos(2L, req))
.isInstanceOf(PairingException.class)
.hasMessageContaining("Guardian aktif");
verify(sosEventRepository, never()).save(any(SosEvent.class));
}
// ===== acknowledgeSos TESTS =====