package com.walkguide.service; import com.walkguide.dto.response.PairingStatusResponse; import com.walkguide.dto.response.PairingCodeResponse; import com.walkguide.entity.*; 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; import java.security.SecureRandom; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; @Service @RequiredArgsConstructor public class PairingService { private final PairingRelationRepository pairingRelationRepository; private final UserRepository userRepository; private final VoiceCommandConfigRepository voiceCommandConfigRepository; private final HardwareShortcutRepository hardwareShortcutRepository; private final AiConfigRepository aiConfigRepository; private final ActivityLogService activityLogService; private final FcmService fcmService; private static final String CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; private static final int PAIRING_CODE_LENGTH = 8; private static final int PAIRING_CODE_TTL_MINUTES = 15; private static final SecureRandom RANDOM = new SecureRandom(); @Transactional public PairingCodeResponse getOrCreatePairingCode(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan")); if (!"ROLE_USER".equals(user.getRole())) { throw new PairingException("Hanya akun User yang bisa membuat pairing code."); } LocalDateTime now = LocalDateTime.now(); if (user.getPairingCode() == null || user.getPairingCodeExpiresAt() == null || !user.getPairingCodeExpiresAt().isAfter(now)) { assignNewPairingCode(user, now); userRepository.save(user); } return buildPairingCodeResponse(user, now); } @Transactional public PairingCodeResponse regeneratePairingCode(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan")); if (!"ROLE_USER".equals(user.getRole())) { throw new PairingException("Hanya akun User yang bisa membuat pairing code."); } LocalDateTime now = LocalDateTime.now(); assignNewPairingCode(user, now); userRepository.save(user); activityLogService.createLog(user, ActivityLogType.PAIRING_INVITE_SENT, "User membuat pairing code baru", null); return buildPairingCodeResponse(user, now); } @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."); } if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.PENDING)) { throw new PairingException("Kamu sudah punya invite yang menunggu. Tunggu user menerima atau tolak dulu."); } User guardian = userRepository.findById(guardianId) .orElseThrow(() -> new ResourceNotFoundException("Guardian tidak ditemukan")); User user = resolveUserByPairingCode(submittedCode); if (!"ROLE_USER".equals(user.getRole())) { throw new PairingException("ID tersebut bukan milik User. Pastikan kamu memasukkan ID yang benar."); } if (pairingRelationRepository.existsByUser_IdAndStatus(user.getId(), PairingStatus.ACTIVE)) { throw new PairingException("User ini sudah dipair dengan Guardian lain."); } PairingRelation pairing = PairingRelation.builder() .guardian(guardian) .user(user) .status(PairingStatus.PENDING) .build(); pairing = pairingRelationRepository.save(pairing); user.setPairingCode(null); 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())); activityLogService.createLog(guardian, ActivityLogType.PAIRING_INVITE_SENT, "Guardian mengirim invite ke " + user.getDisplayName(), null); return buildStatus(pairing, guardian, user, "GUARDIAN"); } @Transactional public PairingStatusResponse respondToPairing(Long userId, Long pairingId, boolean accept) { PairingRelation pairing = pairingRelationRepository.findById(pairingId) .orElseThrow(() -> new ResourceNotFoundException("Pairing tidak ditemukan")); if (!pairing.getUser().getId().equals(userId)) { throw new PairingException("Kamu tidak berhak merespons pairing ini"); } if (pairing.getStatus() != PairingStatus.PENDING) { throw new PairingException("Pairing ini sudah direspons sebelumnya"); } User user = pairing.getUser(); User guardian = pairing.getGuardian(); pairing.setStatus(accept ? PairingStatus.ACTIVE : PairingStatus.REJECTED); pairing.setRespondedAt(LocalDateTime.now()); pairingRelationRepository.save(pairing); if (accept) { seedDefaults(guardian.getId(), user.getId()); activityLogService.createLog(user, ActivityLogType.PAIRING_ACCEPTED, "User menerima pairing dengan Guardian " + guardian.getDisplayName(), null); fcmService.sendToToken(guardian.getFcmToken(), "Pairing Berhasil!", user.getDisplayName() + " menerima undangan pairing kamu", Map.of("type", "PAIRING_RESPONSE", "accepted", "true")); } else { activityLogService.createLog(user, ActivityLogType.PAIRING_REJECTED, "User menolak pairing dengan Guardian " + guardian.getDisplayName(), null); fcmService.sendToToken(guardian.getFcmToken(), "Pairing Ditolak", user.getDisplayName() + " menolak undangan pairing kamu", Map.of("type", "PAIRING_RESPONSE", "accepted", "false")); } return buildStatus(pairing, guardian, user, "USER"); } @Transactional public void unpair(Long requesterId) { var pairing = pairingRelationRepository.findByGuardian_Id(requesterId) .or(() -> pairingRelationRepository.findByUser_Id(requesterId)) .orElseThrow(() -> new ResourceNotFoundException("Tidak ada pairing yang aktif")); User guardian = pairing.getGuardian(); User user = pairing.getUser(); // Hapus semua konfigurasi yang terkait dengan pasangan ini voiceCommandConfigRepository.deleteByUserId(user.getId()); hardwareShortcutRepository.deleteByUserId(user.getId()); aiConfigRepository.findByUserId(user.getId()).ifPresent(aiConfigRepository::delete); pairingRelationRepository.delete(pairing); // Beritahu pihak lain boolean requesterIsGuardian = guardian.getId().equals(requesterId); String otherFcmToken = requesterIsGuardian ? user.getFcmToken() : guardian.getFcmToken(); String requesterName = requesterIsGuardian ? guardian.getDisplayName() : user.getDisplayName(); fcmService.sendToToken(otherFcmToken, "Pairing Diakhiri", requesterName + " mengakhiri koneksi pairing", Map.of("type", "PAIRING_DISSOLVED")); activityLogService.createLog(guardian, ActivityLogType.PAIRING_DISSOLVED, "Pairing antara " + guardian.getDisplayName() + " dan " + user.getDisplayName() + " diakhiri", null); } public PairingStatusResponse getStatus(Long userId, String role) { if ("ROLE_GUARDIAN".equals(role)) { return pairingRelationRepository.findByGuardian_Id(userId) .map(p -> buildStatus(p, p.getGuardian(), p.getUser(), "GUARDIAN")) .orElse(PairingStatusResponse.builder().status("NONE").build()); } else { return pairingRelationRepository.findByUser_Id(userId) .map(p -> buildStatus(p, p.getGuardian(), p.getUser(), "USER")) .orElse(PairingStatusResponse.builder().status("NONE").build()); } } // ========== PRIVATE ========== private void seedDefaults(Long guardianId, Long userId) { // Voice commands default List defaults = List.of( vc(guardianId, userId, VoiceCommandKey.OPEN_WALKGUIDE, "Open Walkguide"), vc(guardianId, userId, VoiceCommandKey.START_WALKGUIDE, "Start Walkguide"), vc(guardianId, userId, VoiceCommandKey.STOP_WALKGUIDE, "Stop Walkguide"), vc(guardianId, userId, VoiceCommandKey.CALL_GUARDIAN, "Call Guardian"), vc(guardianId, userId, VoiceCommandKey.OPEN_NOTIFICATION, "Open Notifications"), vc(guardianId, userId, VoiceCommandKey.READ_ALL_NOTIF, "Read All My Notifications"), vc(guardianId, userId, VoiceCommandKey.OPEN_SOS, "Open SOS"), vc(guardianId, userId, VoiceCommandKey.SEND_SOS, "Send SOS"), vc(guardianId, userId, VoiceCommandKey.WHERE_AM_I, "Where Am I"), vc(guardianId, userId, VoiceCommandKey.OPEN_ACTIVITY, "Open Activity Log"), vc(guardianId, userId, VoiceCommandKey.OPEN_NAVIGATION, "Open Navigation"), vc(guardianId, userId, VoiceCommandKey.OPEN_SETTINGS, "Open Settings"), vc(guardianId, userId, VoiceCommandKey.REPEAT_LAST, "Repeat"), vc(guardianId, userId, VoiceCommandKey.STOP_TTS, "Stop") ); voiceCommandConfigRepository.saveAll(defaults); // Hardware shortcuts default (Vol Up = Call Guardian, Vol Down = Start WalkGuide) List shortcuts = List.of( hs(guardianId, userId, HardwareShortcutKey.CALL_GUARDIAN, "Volume Up", 24), hs(guardianId, userId, HardwareShortcutKey.START_WALKGUIDE, "Volume Down", 25), hs(guardianId, userId, HardwareShortcutKey.SEND_SOS, null, null), hs(guardianId, userId, HardwareShortcutKey.STOP_WALKGUIDE, null, null), hs(guardianId, userId, HardwareShortcutKey.OPEN_NOTIFICATION, null, null) ); hardwareShortcutRepository.saveAll(shortcuts); // AI config default aiConfigRepository.save(AiConfig.builder() .guardianId(guardianId) .userId(userId) .build()); } private VoiceCommandConfig vc(Long gId, Long uId, VoiceCommandKey key, String phrase) { return VoiceCommandConfig.builder() .guardianId(gId).userId(uId).commandKey(key).triggerPhrase(phrase).build(); } private HardwareShortcut hs(Long gId, Long uId, HardwareShortcutKey key, String name, Integer code) { return HardwareShortcut.builder() .guardianId(gId).userId(uId).shortcutKey(key) .buttonName(name).buttonCode(code) .enabled(name != null).build(); } private User resolveUserByPairingCode(String submittedCode) { if (submittedCode == null || submittedCode.isBlank()) { throw new PairingException("Pairing code tidak boleh kosong."); } String code = submittedCode.trim().toUpperCase(); User user = userRepository.findByPairingCode(code) .orElseThrow(() -> new ResourceNotFoundException( "Pairing code '" + code + "' tidak ditemukan. Minta User generate kode baru.")); if (user.getPairingCodeExpiresAt() == null || !user.getPairingCodeExpiresAt().isAfter(LocalDateTime.now())) { user.setPairingCode(null); user.setPairingCodeExpiresAt(null); userRepository.save(user); throw new PairingException("Pairing code sudah kadaluarsa. Minta User generate kode baru."); } return user; } private void assignNewPairingCode(User user, LocalDateTime now) { String candidate; do { candidate = randomCode(); } while (userRepository.findByPairingCode(candidate).isPresent()); user.setPairingCode(candidate); user.setPairingCodeExpiresAt(now.plusMinutes(PAIRING_CODE_TTL_MINUTES)); } private String randomCode() { StringBuilder sb = new StringBuilder(PAIRING_CODE_LENGTH); for (int i = 0; i < PAIRING_CODE_LENGTH; i++) { sb.append(CODE_CHARS.charAt(RANDOM.nextInt(CODE_CHARS.length()))); } return sb.toString(); } private PairingCodeResponse buildPairingCodeResponse(User user, LocalDateTime now) { long seconds = Math.max(0, ChronoUnit.SECONDS.between(now, user.getPairingCodeExpiresAt())); return PairingCodeResponse.builder() .pairingCode(user.getPairingCode()) .expiresAt(user.getPairingCodeExpiresAt()) .expiresInSeconds(seconds) .build(); } private PairingStatusResponse buildStatus(PairingRelation p, User guardian, User user, String viewerRole) { String pairedWithName = "GUARDIAN".equals(viewerRole) ? user.getDisplayName() : guardian.getDisplayName(); String pairedWithEmail = "GUARDIAN".equals(viewerRole) ? user.getEmail() : guardian.getEmail(); return PairingStatusResponse.builder() .pairingId(p.getId()) .status(p.getStatus().name()) .pairedWithId("GUARDIAN".equals(viewerRole) ? user.getId() : guardian.getId()) .pairedWithName(pairedWithName) .pairedWithEmail(pairedWithEmail) .uniqueUserId(user.getUniqueUserId()) .pairingCode(user.getPairingCode()) .pairingCodeExpiresAt(user.getPairingCodeExpiresAt()) .invitedAt(p.getInvitedAt()) .respondedAt(p.getRespondedAt()) .build(); } }