update backend

This commit is contained in:
Wowieee4 2026-05-04 11:20:53 +07:00
parent c8d9eefa74
commit feb8e78b0c
103 changed files with 3107 additions and 304 deletions

View File

@ -65,7 +65,7 @@
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
<version>1.18.36</version>
</dependency>
<!-- JWT -->
@ -137,6 +137,7 @@
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.36</version>
</path>
</annotationProcessorPaths>
</configuration>

View File

@ -4,10 +4,8 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public class WalkGuideApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
SpringApplication.run(WalkGuideApplication.class, args);
}
}

View File

@ -18,29 +18,22 @@ public class DataSeeder implements CommandLineRunner {
public void run(String... args) throws Exception {
if (userRepository.count() == 0) {
// 1. Buat Guardian (tanpa connected_to dulu)
User guardian = User.builder()
.email("guardian@walkguide.com")
.password(passwordEncoder.encode("guardian123"))
.role("ROLE_GUARDIAN")
.build();
guardian = userRepository.save(guardian);
userRepository.save(guardian);
// 2. Buat User Tunanetra, langsung sambungkan ke Guardian
User user = User.builder()
.email("user@walkguide.com")
.password(passwordEncoder.encode("user123"))
.role("ROLE_USER")
.connectedTo(guardian)
.build();
user = userRepository.save(user);
// 3. Update Guardian -> sambungkan balik ke User yang dijaganya
guardian.setConnectedTo(user);
userRepository.save(guardian);
userRepository.save(user);
System.out.println("DataSeeder: Guardian (" + guardian.getId()
+ ") <-> User (" + user.getId() + ") berhasil dihubungkan!");
+ ") dan User (" + user.getId() + ") berhasil dibuat!");
} else {
System.out.println("DataSeeder: Database sudah ada data, skip seeding.");
}

View File

@ -2,6 +2,9 @@ package com.walkguide.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.Components;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -12,8 +15,16 @@ public class OpenApiConfig {
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Walk Guide API")
.title("WalkGuide API")
.version("1.0")
.description("API Documentation for Walk Guide Application (Final Exam)"));
.description("API Documentation for WalkGuide Application - Final Exam"))
.addSecurityItem(new SecurityRequirement().addList("Bearer Auth"))
.components(new Components()
.addSecuritySchemes("Bearer Auth",
new SecurityScheme()
.name("Bearer Auth")
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}

View File

@ -1,5 +1,7 @@
package com.walkguide.config;
import com.walkguide.security.JwtAuthFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@ -8,43 +10,64 @@ import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import com.walkguide.security.JwtAuthFilter;
import java.util.List;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
// MESIN HASHING PASSWORD: Biar password yang disimpan di database gak bisa dibaca langsung, kita enkripsi dulu pake BCrypt
private final JwtAuthFilter jwtAuthFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// Aturan jalan masuk ke API kita:
// Jangan lupa inject filter-nya di atas (tambahin parameter JwtAuthFilter jwtAuthFilter)
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception {
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(request -> {
var corsConfig = new org.springframework.web.cors.CorsConfiguration();
corsConfig.setAllowedOriginPatterns(java.util.List.of("http://localhost:*", "http://127.0.0.1:*"));
corsConfig.setAllowedMethods(java.util.List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
corsConfig.setAllowedHeaders(java.util.List.of("*"));
corsConfig.setAllowCredentials(true);
return corsConfig;
}))
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll() // Login & Swagger bebas
.requestMatchers("/api/guardian/**").hasRole("GUARDIAN") // Khusus Guardian
.requestMatchers("/api/user/**").hasRole("USER") // Khusus Tunanetra
// Public routes
.requestMatchers(
"/api/v1/auth/**",
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**",
"/ws/**"
).permitAll()
// Guardian only
.requestMatchers("/api/v1/guardian/**").hasRole("GUARDIAN")
// User only
.requestMatchers("/api/v1/user/**").hasRole("USER")
// Both roles (authenticated)
.requestMatchers("/api/v1/shared/**").authenticated()
.anyRequest().authenticated()
)
// TARUH SATPAM DI SINI
.addFilterBefore(jwtAuthFilter, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class);
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// Allow semua origin untuk testing dengan HP berbeda di jaringan yang sama
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(false); // false agar bisa pakai wildcard origin
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}

View File

@ -1,36 +1,57 @@
package com.walkguide.controller;
import java.util.Map;
import org.springframework.http.ResponseEntity;
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.RestController;
import com.walkguide.dto.ApiResponse;
import com.walkguide.dto.AuthRequest;
import com.walkguide.dto.request.*;
import com.walkguide.dto.response.AuthDataResponse;
import com.walkguide.security.SecurityHelper;
import com.walkguide.service.AuthService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
/** Health-check — digunakan Flutter ServerConnectScreen */
@GetMapping("/ping")
public ResponseEntity<ApiResponse<String>> ping() {
return ResponseEntity.ok(ApiResponse.ok("pong", "Server aktif"));
}
@PostMapping("/register")
public ResponseEntity<ApiResponse<AuthDataResponse>> register(
@Valid @RequestBody RegisterRequest req) {
return ResponseEntity.ok(ApiResponse.ok(authService.register(req), "Registrasi berhasil"));
}
@PostMapping("/login")
public ResponseEntity<ApiResponse<Map<String, String>>> login(@Valid @RequestBody AuthRequest request) {
public ResponseEntity<ApiResponse<AuthDataResponse>> login(
@Valid @RequestBody LoginRequest req) {
return ResponseEntity.ok(ApiResponse.ok(authService.login(req), "Login berhasil"));
}
// Panggil service buat cek password dan bikin token
Map<String, String> tokenData = authService.login(request);
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<AuthDataResponse>> refresh(
@RequestBody RefreshTokenRequest req) {
return ResponseEntity.ok(ApiResponse.ok(
authService.refreshToken(req.getRefreshToken()), "Token diperbarui"));
}
// Bungkus pakai ApiResponse biar sesuai standar Dosen lu!
ApiResponse<Map<String, String>> response = new ApiResponse<>(true, tokenData, "Login berhasil");
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout() {
authService.logout(SecurityHelper.getCurrentUserId());
return ResponseEntity.ok(ApiResponse.ok(null, "Logout berhasil"));
}
return ResponseEntity.ok(response);
@PutMapping("/fcm-token")
public ResponseEntity<ApiResponse<Void>> updateFcmToken(
@RequestBody FcmTokenRequest req) {
authService.updateFcmToken(SecurityHelper.getCurrentUserId(), req.getFcmToken());
return ResponseEntity.ok(ApiResponse.ok(null, "FCM token diperbarui"));
}
}

View File

@ -1,77 +1,168 @@
package com.walkguide.controller;
import com.walkguide.dto.ApiResponse;
import com.walkguide.entity.User;
import com.walkguide.repository.UserRepository;
import com.walkguide.security.JwtUtil;
import com.walkguide.service.MockDataService;
import jakarta.servlet.http.HttpServletRequest;
import com.walkguide.dto.request.*;
import com.walkguide.dto.response.*;
import com.walkguide.security.SecurityHelper;
import com.walkguide.service.*;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/guardian")
@RequestMapping("/api/v1/guardian")
@RequiredArgsConstructor
public class GuardianController {
private final MockDataService mockDataService;
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
private final GuardianDashboardService dashboardService;
private final LocationService locationService;
private final ActivityLogService activityLogService;
private final ObstacleLogService obstacleLogService;
private final NotificationService notificationService;
private final SosService sosService;
private final AiConfigService aiConfigService;
private final VoiceCommandService voiceCommandService;
private final HardwareShortcutService hardwareShortcutService;
private final GeofenceService geofenceService;
private final UserSettingsService userSettingsService;
// 4. Ambil Status User yang dimonitor
@GetMapping("/user-status")
public ResponseEntity<ApiResponse<Map<String, Object>>> getUserStatus() {
return ResponseEntity.ok(new ApiResponse<>(true,
mockDataService.getUserStatus(),
"Data status user berhasil diambil"));
@GetMapping("/dashboard")
public ResponseEntity<ApiResponse<DashboardResponse>> dashboard() {
return ResponseEntity.ok(ApiResponse.ok(
dashboardService.getDashboard(SecurityHelper.getCurrentUserId()),
"Dashboard Guardian"));
}
// 5. Setting Hardware Shortcut
@PutMapping("/settings/shortcuts")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateShortcuts(
@RequestBody Map<String, Object> request) {
Map<String, Object> updated = mockDataService.updateShortcuts(request);
return ResponseEntity.ok(new ApiResponse<>(true, updated, "Shortcut berhasil diperbarui"));
@GetMapping("/user-location")
public ResponseEntity<ApiResponse<LocationResponse>> userLocation() {
return ResponseEntity.ok(ApiResponse.ok(
locationService.getLastLocationForGuardian(SecurityHelper.getCurrentUserId()).orElse(null),
"Lokasi terakhir user"));
}
// 6. Setting Sensitivitas AI
@PutMapping("/settings/ai")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateAiSettings(
@RequestBody Map<String, Object> request) {
Map<String, Object> updated = mockDataService.updateAiSettings(request);
return ResponseEntity.ok(new ApiResponse<>(true, updated, "Setting AI berhasil diperbarui"));
@GetMapping("/location-history")
public ResponseEntity<ApiResponse<Page<LocationResponse>>> locationHistory(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
// Guardian lihat location history user yang dipair
Long guardianId = SecurityHelper.getCurrentUserId();
// Perlu ambil userId dulu delegasikan ke service
return ResponseEntity.ok(ApiResponse.ok(
locationService.getLocationHistory(guardianId,
PageRequest.of(page, size, Sort.by("createdAt").descending())),
"Riwayat lokasi"));
}
// 7. Lihat User yang terhubung ke Guardian ini
@GetMapping("/my-user")
public ResponseEntity<ApiResponse<Map<String, Object>>> getMyConnectedUser(
HttpServletRequest request) {
String token = request.getHeader("Authorization").substring(7);
String email = jwtUtil.extractUsername(token);
User guardian = userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("Guardian tidak ditemukan"));
User connectedUser = guardian.getConnectedTo();
if (connectedUser == null) {
return ResponseEntity.ok(new ApiResponse<>(true,
Map.of("message", "Belum ada user yang terhubung"),
"Tidak ada koneksi"));
@GetMapping("/activity-logs")
public ResponseEntity<ApiResponse<Page<ActivityLogResponse>>> activityLogs(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.ok(
activityLogService.getLogsForGuardian(SecurityHelper.getCurrentUserId(),
PageRequest.of(page, size)), "Log aktivitas user"));
}
Map<String, Object> data = Map.of(
"userId", connectedUser.getId(),
"userEmail", connectedUser.getEmail(),
"connectedSince", connectedUser.getCreatedAt().toString()
);
return ResponseEntity.ok(new ApiResponse<>(true, data,
"Data user yang dipantau berhasil diambil"));
@GetMapping("/obstacle-logs")
public ResponseEntity<ApiResponse<Page<ObstacleLogResponse>>> obstacleLogs(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.ok(
obstacleLogService.getObstacleLogs(SecurityHelper.getCurrentUserId(),
PageRequest.of(page, size)), "Log obstacle user"));
}
@PostMapping("/notifications/send")
public ResponseEntity<ApiResponse<NotificationResponse>> sendNotif(
@RequestBody SendNotificationRequest req) {
return ResponseEntity.ok(ApiResponse.ok(
notificationService.sendNotification(SecurityHelper.getCurrentUserId(), req),
"Notifikasi terkirim"));
}
@GetMapping("/sos-events")
public ResponseEntity<ApiResponse<Page<SosEventResponse>>> sosEvents(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.ok(
sosService.getSosEventsForGuardian(SecurityHelper.getCurrentUserId(),
PageRequest.of(page, size)), "SOS events"));
}
@PutMapping("/sos/{id}/acknowledge")
public ResponseEntity<ApiResponse<SosEventResponse>> acknowledgeSos(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.ok(
sosService.acknowledgeSos(SecurityHelper.getCurrentUserId(), id),
"SOS diakui"));
}
@GetMapping("/ai-config")
public ResponseEntity<ApiResponse<AiConfigResponse>> getAiConfig() {
// Guardian lihat config user yang dipair
return ResponseEntity.ok(ApiResponse.ok(
aiConfigService.getConfig(SecurityHelper.getCurrentUserId()),
"AI config"));
}
@PutMapping("/ai-config")
public ResponseEntity<ApiResponse<AiConfigResponse>> updateAiConfig(
@RequestBody AiConfigUpdateRequest req) {
return ResponseEntity.ok(ApiResponse.ok(
aiConfigService.updateConfigByGuardian(SecurityHelper.getCurrentUserId(), req),
"AI config diperbarui"));
}
@GetMapping("/voice-commands")
public ResponseEntity<ApiResponse<?>> getVoiceCommands() {
return ResponseEntity.ok(ApiResponse.ok(
voiceCommandService.getAll(SecurityHelper.getCurrentUserId()),
"Voice commands"));
}
@PutMapping("/voice-commands")
public ResponseEntity<ApiResponse<VoiceCommandResponse>> updateVoiceCommand(
@RequestBody VoiceCommandUpdateRequest req) {
return ResponseEntity.ok(ApiResponse.ok(
voiceCommandService.updateByGuardian(SecurityHelper.getCurrentUserId(), req),
"Voice command diperbarui"));
}
@GetMapping("/shortcuts")
public ResponseEntity<ApiResponse<?>> getShortcuts() {
return ResponseEntity.ok(ApiResponse.ok(
hardwareShortcutService.getAll(SecurityHelper.getCurrentUserId()),
"Hardware shortcuts"));
}
@GetMapping("/geofence")
public ResponseEntity<ApiResponse<GeofenceResponse>> getGeofence() {
return ResponseEntity.ok(ApiResponse.ok(
geofenceService.getConfig(SecurityHelper.getCurrentUserId()),
"Geofence config"));
}
@PutMapping("/geofence")
public ResponseEntity<ApiResponse<GeofenceResponse>> updateGeofence(
@RequestBody GeofenceConfigRequest req) {
return ResponseEntity.ok(ApiResponse.ok(
geofenceService.updateConfig(SecurityHelper.getCurrentUserId(), req),
"Geofence diperbarui"));
}
@GetMapping("/user-settings")
public ResponseEntity<ApiResponse<UserSettingsResponse>> getUserSettings() {
return ResponseEntity.ok(ApiResponse.ok(
userSettingsService.getSettings(SecurityHelper.getCurrentUserId()),
"User settings"));
}
@PutMapping("/user-settings")
public ResponseEntity<ApiResponse<UserSettingsResponse>> updateUserSettings(
@RequestBody UserSettingsUpdateRequest req) {
return ResponseEntity.ok(ApiResponse.ok(
userSettingsService.updateSettings(SecurityHelper.getCurrentUserId(), req),
"User settings diperbarui"));
}
}

View File

@ -0,0 +1,51 @@
package com.walkguide.controller;
import com.walkguide.dto.ApiResponse;
import com.walkguide.dto.request.*;
import com.walkguide.dto.response.PairingStatusResponse;
import com.walkguide.security.SecurityHelper;
import com.walkguide.service.PairingService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/shared/pairing")
@RequiredArgsConstructor
public class PairingController {
private final PairingService pairingService;
@PostMapping("/invite")
public ResponseEntity<ApiResponse<PairingStatusResponse>> invite(
@RequestBody InviteUserRequest req) {
Long guardianId = SecurityHelper.getCurrentUserId();
return ResponseEntity.ok(ApiResponse.ok(
pairingService.inviteUser(guardianId, req.getUniqueUserId()),
"Undangan dikirim ke user"));
}
@PostMapping("/respond")
public ResponseEntity<ApiResponse<PairingStatusResponse>> respond(
@RequestBody PairingResponseRequest req) {
Long userId = SecurityHelper.getCurrentUserId();
return ResponseEntity.ok(ApiResponse.ok(
pairingService.respondToPairing(userId, req.getPairingId(), req.isAccept()),
req.isAccept() ? "Pairing diterima" : "Pairing ditolak"));
}
@DeleteMapping("/unpair")
public ResponseEntity<ApiResponse<Void>> unpair() {
pairingService.unpair(SecurityHelper.getCurrentUserId());
return ResponseEntity.ok(ApiResponse.ok(null, "Pairing diakhiri"));
}
@GetMapping("/status")
public ResponseEntity<ApiResponse<PairingStatusResponse>> status(Authentication auth) {
Long userId = SecurityHelper.getCurrentUserId();
String role = auth.getAuthorities().iterator().next().getAuthority();
return ResponseEntity.ok(ApiResponse.ok(
pairingService.getStatus(userId, role), "Status pairing"));
}
}

View File

@ -1,60 +1,171 @@
package com.walkguide.controller;
import com.walkguide.dto.ApiResponse;
import com.walkguide.entity.User;
import com.walkguide.dto.request.*;
import com.walkguide.dto.response.*;
import com.walkguide.enums.ActivityLogType;
import com.walkguide.repository.UserRepository;
import com.walkguide.security.JwtUtil;
import jakarta.servlet.http.HttpServletRequest;
import com.walkguide.security.SecurityHelper;
import com.walkguide.service.*;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
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.RestController;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.List;
@RestController
@RequestMapping("/api/user")
@RequestMapping("/api/v1/user")
@RequiredArgsConstructor
public class UserController {
private final LocationService locationService;
private final ObstacleLogService obstacleLogService;
private final SosService sosService;
private final ActivityLogService activityLogService;
private final NotificationService notificationService;
private final UserSettingsService userSettingsService;
private final AiConfigService aiConfigService;
private final VoiceCommandService voiceCommandService;
private final HardwareShortcutService hardwareShortcutService;
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
// Sinyal Darurat (Voice Command)
@PostMapping("/emergency")
public ResponseEntity<ApiResponse<String>> triggerEmergency(
@RequestBody Map<String, Object> request) {
String triggerType = (String) request.get("triggerType");
return ResponseEntity.ok(new ApiResponse<>(true,
"Darurat Terkirim",
"Guardian telah diberi peringatan via: " + triggerType));
}
// Lihat Guardian yang terhubung ke User ini
@GetMapping("/my-guardian")
public ResponseEntity<ApiResponse<Map<String, Object>>> getMyGuardian(
HttpServletRequest request) {
String token = request.getHeader("Authorization").substring(7);
String email = jwtUtil.extractUsername(token);
User user = userRepository.findByEmail(email)
@GetMapping("/profile")
public ResponseEntity<ApiResponse<?>> getProfile() {
Long userId = SecurityHelper.getCurrentUserId();
var user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User tidak ditemukan"));
User guardian = user.getConnectedTo();
if (guardian == null) {
return ResponseEntity.ok(new ApiResponse<>(true,
Map.of("message", "Belum ada guardian yang terhubung"),
"Tidak ada koneksi"));
var profile = java.util.Map.of(
"id", user.getId(),
"email", user.getEmail(),
"displayName", user.getDisplayName() != null ? user.getDisplayName() : "",
"role", user.getRole(),
"uniqueUserId", user.getUniqueUserId() != null ? user.getUniqueUserId() : ""
);
return ResponseEntity.ok(ApiResponse.ok(profile, "Profil user"));
}
Map<String, Object> data = Map.of(
"guardianId", guardian.getId(),
"guardianEmail", guardian.getEmail()
);
return ResponseEntity.ok(new ApiResponse<>(true, data,
"Data guardian berhasil diambil"));
@GetMapping("/settings")
public ResponseEntity<ApiResponse<UserSettingsResponse>> getSettings() {
return ResponseEntity.ok(ApiResponse.ok(
userSettingsService.getSettings(SecurityHelper.getCurrentUserId()),
"Settings user"));
}
@PutMapping("/settings")
public ResponseEntity<ApiResponse<UserSettingsResponse>> updateSettings(
@RequestBody UserSettingsUpdateRequest req) {
return ResponseEntity.ok(ApiResponse.ok(
userSettingsService.updateSettings(SecurityHelper.getCurrentUserId(), req),
"Settings diperbarui"));
}
@GetMapping("/voice-commands")
public ResponseEntity<ApiResponse<List<VoiceCommandResponse>>> getVoiceCommands() {
return ResponseEntity.ok(ApiResponse.ok(
voiceCommandService.getAll(SecurityHelper.getCurrentUserId()),
"Voice commands"));
}
@GetMapping("/shortcuts")
public ResponseEntity<ApiResponse<List<HardwareShortcutResponse>>> getShortcuts() {
return ResponseEntity.ok(ApiResponse.ok(
hardwareShortcutService.getAll(SecurityHelper.getCurrentUserId()),
"Hardware shortcuts"));
}
@PutMapping("/shortcuts")
public ResponseEntity<ApiResponse<HardwareShortcutResponse>> updateShortcut(
@RequestBody HardwareShortcutUpdateRequest req) {
return ResponseEntity.ok(ApiResponse.ok(
hardwareShortcutService.update(SecurityHelper.getCurrentUserId(), req),
"Shortcut diperbarui"));
}
@GetMapping("/ai-config")
public ResponseEntity<ApiResponse<AiConfigResponse>> getAiConfig() {
return ResponseEntity.ok(ApiResponse.ok(
aiConfigService.getConfig(SecurityHelper.getCurrentUserId()),
"AI config"));
}
@PostMapping("/location")
public ResponseEntity<ApiResponse<LocationResponse>> updateLocation(
@RequestBody LocationUpdateRequest req) {
return ResponseEntity.ok(ApiResponse.ok(
locationService.updateLocation(SecurityHelper.getCurrentUserId(), req),
"Lokasi diperbarui"));
}
@PostMapping("/obstacle")
public ResponseEntity<ApiResponse<ObstacleLogResponse>> logObstacle(
@RequestBody ObstacleLogRequest req) {
return ResponseEntity.ok(ApiResponse.ok(
obstacleLogService.saveObstacle(SecurityHelper.getCurrentUserId(), req),
"Obstacle dicatat"));
}
@PostMapping("/sos")
public ResponseEntity<ApiResponse<SosEventResponse>> triggerSos(
@RequestBody SosRequest req) {
return ResponseEntity.ok(ApiResponse.ok(
sosService.triggerSos(SecurityHelper.getCurrentUserId(), req),
"SOS dikirim! Guardian sudah diberitahu."));
}
@GetMapping("/activity-logs")
public ResponseEntity<ApiResponse<Page<ActivityLogResponse>>> getActivityLogs(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.ok(
activityLogService.getLogs(SecurityHelper.getCurrentUserId(),
PageRequest.of(page, size)), "Log aktivitas"));
}
@GetMapping("/notifications")
public ResponseEntity<ApiResponse<Page<NotificationResponse>>> getNotifications(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.ok(
notificationService.getNotifications(SecurityHelper.getCurrentUserId(),
PageRequest.of(page, size)), "Notifikasi"));
}
@GetMapping("/notifications/unread-count")
public ResponseEntity<ApiResponse<Long>> getUnreadCount() {
return ResponseEntity.ok(ApiResponse.ok(
notificationService.getUnreadCount(SecurityHelper.getCurrentUserId()),
"Jumlah notifikasi belum dibaca"));
}
@PutMapping("/notifications/mark-all-read")
public ResponseEntity<ApiResponse<Void>> markAllRead() {
notificationService.markAllRead(SecurityHelper.getCurrentUserId());
return ResponseEntity.ok(ApiResponse.ok(null, "Semua notifikasi ditandai sudah dibaca"));
}
@PutMapping("/notifications/{id}/read")
public ResponseEntity<ApiResponse<Void>> markOneRead(@PathVariable Long id) {
notificationService.markOneRead(id);
return ResponseEntity.ok(ApiResponse.ok(null, "Notifikasi ditandai sudah dibaca"));
}
@PostMapping("/walkguide/start")
public ResponseEntity<ApiResponse<Void>> walkGuideStart() {
Long userId = SecurityHelper.getCurrentUserId();
userRepository.findById(userId).ifPresent(u ->
activityLogService.createLog(u, ActivityLogType.WALKGUIDE_START,
"WalkGuide dimulai", null));
return ResponseEntity.ok(ApiResponse.ok(null, "WalkGuide dimulai"));
}
@PostMapping("/walkguide/stop")
public ResponseEntity<ApiResponse<Void>> walkGuideStop() {
Long userId = SecurityHelper.getCurrentUserId();
userRepository.findById(userId).ifPresent(u ->
activityLogService.createLog(u, ActivityLogType.WALKGUIDE_STOP,
"WalkGuide dihentikan", null));
return ResponseEntity.ok(ApiResponse.ok(null, "WalkGuide dihentikan"));
}
}

View File

@ -9,7 +9,6 @@ public class ApiResponse<T> {
private String errorCode;
private String timestamp;
// Constructor buat Sukses
public ApiResponse(boolean success, T data, String message) {
this.success = success;
this.data = data;
@ -17,7 +16,6 @@ public class ApiResponse<T> {
this.timestamp = Instant.now().toString();
}
// Constructor buat Error
public ApiResponse(boolean success, String errorCode, String message) {
this.success = success;
this.errorCode = errorCode;
@ -25,19 +23,22 @@ public class ApiResponse<T> {
this.timestamp = Instant.now().toString();
}
// Getter Setter manual
public static <T> ApiResponse<T> ok(T data, String message) {
return new ApiResponse<>(true, data, message);
}
public static <T> ApiResponse<T> error(String errorCode, String message) {
return new ApiResponse<>(false, errorCode, message);
}
public boolean isSuccess() { return success; }
public void setSuccess(boolean success) { this.success = success; }
public T getData() { return data; }
public void setData(T data) { this.data = data; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public String getErrorCode() { return errorCode; }
public void setErrorCode(String errorCode) { this.errorCode = errorCode; }
public String getTimestamp() { return timestamp; }
public void setSuccess(boolean success) { this.success = success; }
public void setData(T data) { this.data = data; }
public void setMessage(String message) { this.message = message; }
public void setErrorCode(String errorCode) { this.errorCode = errorCode; }
public void setTimestamp(String timestamp) { this.timestamp = timestamp; }
}

View File

@ -0,0 +1,11 @@
package com.walkguide.dto.request;
import lombok.Data;
@Data
public class AiConfigUpdateRequest {
private Double confidenceThreshold;
private Double alertDistanceClose;
private Double alertDistanceMedium;
private Integer maxInferenceFps;
private String enabledLabels;
}

View File

@ -0,0 +1,7 @@
package com.walkguide.dto.request;
import lombok.Data;
@Data
public class FcmTokenRequest {
private String fcmToken;
}

View File

@ -0,0 +1,10 @@
package com.walkguide.dto.request;
import lombok.Data;
@Data
public class GeofenceConfigRequest {
private Double centerLat;
private Double centerLng;
private Double radiusMeters;
private Boolean enabled;
}

View File

@ -0,0 +1,10 @@
package com.walkguide.dto.request;
import lombok.Data;
@Data
public class HardwareShortcutUpdateRequest {
private String shortcutKey; // enum HardwareShortcutKey as string
private String buttonName;
private Integer buttonCode;
private Boolean enabled;
}

View File

@ -0,0 +1,11 @@
package com.walkguide.dto.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class InviteUserRequest {
@NotBlank(message = "User ID tidak boleh kosong")
@Size(min = 12, max = 12, message = "User ID harus tepat 12 karakter")
private String uniqueUserId;
}

View File

@ -0,0 +1,11 @@
package com.walkguide.dto.request;
import lombok.Data;
@Data
public class LocationUpdateRequest {
private Double lat;
private Double lng;
private Double accuracy;
private Double speed;
private Double heading;
}

View File

@ -0,0 +1,15 @@
package com.walkguide.dto.request;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class LoginRequest {
@NotBlank(message = "Email tidak boleh kosong")
@Email(message = "Format email tidak valid")
private String email;
@NotBlank(message = "Password tidak boleh kosong")
private String password;
}

View File

@ -0,0 +1,12 @@
package com.walkguide.dto.request;
import lombok.Data;
@Data
public class ObstacleLogRequest {
private String label;
private Double confidence;
private String direction;
private String estimatedDist;
private Double lat;
private Double lng;
}

View File

@ -0,0 +1,8 @@
package com.walkguide.dto.request;
import lombok.Data;
@Data
public class PairingResponseRequest {
private Long pairingId;
private boolean accept;
}

View File

@ -0,0 +1,6 @@
package com.walkguide.dto.request;
import lombok.Data;
@Data
public class RefreshTokenRequest {
private String refreshToken;
}

View File

@ -0,0 +1,23 @@
package com.walkguide.dto.request;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class RegisterRequest {
@NotBlank(message = "Email tidak boleh kosong")
@Email(message = "Format email tidak valid")
private String email;
@NotBlank(message = "Password tidak boleh kosong")
@Size(min = 6, message = "Password minimal 6 karakter")
private String password;
@NotBlank(message = "Nama tidak boleh kosong")
private String displayName;
@NotBlank(message = "Role tidak boleh kosong")
private String role; // GUARDIAN atau USER
}

View File

@ -0,0 +1,10 @@
package com.walkguide.dto.request;
import lombok.Data;
@Data
public class SendNotificationRequest {
private String notifType; // TEXT atau VOICE_NOTE
private String content;
private String voiceNoteUrl;
private Integer voiceNoteDuration;
}

View File

@ -0,0 +1,9 @@
package com.walkguide.dto.request;
import lombok.Data;
@Data
public class SosRequest {
private String triggerType; // VOICE_COMMAND, BUTTON, MANUAL
private Double lat;
private Double lng;
}

View File

@ -0,0 +1,11 @@
package com.walkguide.dto.request;
import lombok.Data;
@Data
public class UserSettingsUpdateRequest {
private String ttsLanguage;
private Double ttsPitch;
private Double ttsSpeed;
private Boolean warnNoGuardian;
private Boolean hapticEnabled;
}

View File

@ -0,0 +1,9 @@
package com.walkguide.dto.request;
import lombok.Data;
@Data
public class VoiceCommandUpdateRequest {
private String commandKey; // enum VoiceCommandKey as string
private String triggerPhrase;
private Boolean enabled;
}

View File

@ -0,0 +1,14 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
public class ActivityLogResponse {
private Long id;
private String logType;
private String description;
private String metadata;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,14 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AiConfigResponse {
private Long id;
private Double confidenceThreshold;
private Double alertDistanceClose;
private Double alertDistanceMedium;
private Integer maxInferenceFps;
private String enabledLabels;
}

View File

@ -0,0 +1,14 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AuthDataResponse {
private String accessToken;
private String refreshToken;
private String role;
private Long userId;
private String displayName;
private String uniqueUserId; // null untuk GUARDIAN
}

View File

@ -0,0 +1,24 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class DashboardResponse {
// Info user yang dipair
private Long pairedUserId;
private String pairedUserName;
private String pairedUserEmail;
private String uniqueUserId;
// Lokasi terakhir
private LocationResponse lastLocation;
// Status
private long unreadSosCount;
private long unreadNotifCount;
// Recent activity (5 terbaru)
private List<ActivityLogResponse> recentActivity;
}

View File

@ -0,0 +1,13 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class GeofenceResponse {
private Long id;
private Double centerLat;
private Double centerLng;
private Double radiusMeters;
private Boolean enabled;
}

View File

@ -0,0 +1,13 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class HardwareShortcutResponse {
private Long id;
private String shortcutKey;
private String buttonName;
private Integer buttonCode;
private Boolean enabled;
}

View File

@ -0,0 +1,16 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
public class LocationResponse {
private Long id;
private Double lat;
private Double lng;
private Double accuracy;
private Double speed;
private Double heading;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,17 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
public class NotificationResponse {
private Long id;
private String notifType;
private String content;
private String voiceNoteUrl;
private Integer voiceNoteDuration;
private Boolean isRead;
private LocalDateTime readAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,17 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
public class ObstacleLogResponse {
private Long id;
private String label;
private Double confidence;
private String direction;
private String estimatedDist;
private Double lat;
private Double lng;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,16 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
public class PairingStatusResponse {
private Long pairingId;
private String status; // PENDING, ACTIVE, REJECTED, NONE
private String pairedWithName; // nama Guardian (untuk User) atau nama User (untuk Guardian)
private String pairedWithEmail;
private String uniqueUserId; // ID user yang di-pair
private LocalDateTime invitedAt;
private LocalDateTime respondedAt;
}

View File

@ -0,0 +1,16 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
public class SosEventResponse {
private Long id;
private String triggerType;
private Double lat;
private Double lng;
private String status;
private LocalDateTime acknowledgedAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,14 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class UserSettingsResponse {
private Long id;
private String ttsLanguage;
private Double ttsPitch;
private Double ttsSpeed;
private Boolean warnNoGuardian;
private Boolean hapticEnabled;
}

View File

@ -0,0 +1,13 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class VoiceCommandResponse {
private Long id;
private String commandKey;
private String triggerPhrase;
private Boolean enabled;
private String description; // human-readable description of what this command does
}

View File

@ -0,0 +1,42 @@
package com.walkguide.entity;
import com.walkguide.enums.ActivityLogType;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "activity_logs")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ActivityLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Enumerated(EnumType.STRING)
@Column(name = "log_type", nullable = false)
private ActivityLogType logType;
@Column(columnDefinition = "TEXT")
private String description;
// JSON string untuk metadata extra (lat/lng, obstacle label, dll)
@Column(columnDefinition = "TEXT")
private String metadata;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,53 @@
package com.walkguide.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "ai_configs")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AiConfig {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "guardian_id")
private Long guardianId;
@Column(name = "user_id", nullable = false, unique = true)
private Long userId;
@Column(name = "confidence_threshold", nullable = false)
@Builder.Default
private Double confidenceThreshold = 0.5;
@Column(name = "alert_distance_close", nullable = false)
@Builder.Default
private Double alertDistanceClose = 1.5;
@Column(name = "alert_distance_medium", nullable = false)
@Builder.Default
private Double alertDistanceMedium = 3.0;
@Column(name = "max_inference_fps", nullable = false)
@Builder.Default
private Integer maxInferenceFps = 5;
@Column(name = "enabled_labels", nullable = false, columnDefinition = "TEXT")
@Builder.Default
private String enabledLabels = "ALL";
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,47 @@
package com.walkguide.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "geofence_configs")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GeofenceConfig {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "guardian_id")
private Long guardianId;
@Column(name = "user_id", nullable = false, unique = true)
private Long userId;
@Column(name = "center_lat")
private Double centerLat;
@Column(name = "center_lng")
private Double centerLng;
@Column(name = "radius_meters", nullable = false)
@Builder.Default
private Double radiusMeters = 500.0;
@Column(nullable = false)
@Builder.Default
private Boolean enabled = false;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,54 @@
package com.walkguide.entity;
import com.walkguide.enums.NotificationType;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "guardian_notifications")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GuardianNotification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "guardian_id", nullable = false)
private Long guardianId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Enumerated(EnumType.STRING)
@Column(name = "notif_type", nullable = false)
@Builder.Default
private NotificationType notifType = NotificationType.TEXT;
@Column(columnDefinition = "TEXT")
private String content;
@Column(name = "voice_note_url", length = 500)
private String voiceNoteUrl;
@Column(name = "voice_note_duration")
private Integer voiceNoteDuration;
@Column(name = "is_read", nullable = false)
@Builder.Default
private Boolean isRead = false;
@Column(name = "read_at")
private LocalDateTime readAt;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,48 @@
package com.walkguide.entity;
import com.walkguide.enums.HardwareShortcutKey;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "hardware_shortcuts")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HardwareShortcut {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "guardian_id")
private Long guardianId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Enumerated(EnumType.STRING)
@Column(name = "shortcut_key", nullable = false)
private HardwareShortcutKey shortcutKey;
@Column(name = "button_name", length = 100)
private String buttonName;
@Column(name = "button_code")
private Integer buttonCode;
@Column(nullable = false)
@Builder.Default
private Boolean enabled = true;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,39 @@
package com.walkguide.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "location_history")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LocationHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(nullable = false)
private Double lat;
@Column(nullable = false)
private Double lng;
private Double accuracy;
private Double speed; // m/s
private Double heading; // derajat 0-360
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,44 @@
package com.walkguide.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "obstacle_logs")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ObstacleLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(nullable = false)
private String label; // 'person', 'car', 'motorcycle', dll
@Column(nullable = false)
private Double confidence;
@Column(nullable = false)
private String direction; // LEFT, CENTER, RIGHT
@Column(name = "estimated_dist", nullable = false)
private String estimatedDist; // Very Close, Close, Medium, Far
private Double lat;
private Double lng;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,43 @@
package com.walkguide.entity;
import com.walkguide.enums.PairingStatus;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "pairing_relations")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PairingRelation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "guardian_id", nullable = false)
private User guardian;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Builder.Default
private PairingStatus status = PairingStatus.PENDING;
@Column(name = "invited_at", nullable = false, updatable = false)
private LocalDateTime invitedAt;
@Column(name = "responded_at")
private LocalDateTime respondedAt;
@PrePersist
protected void onCreate() {
invitedAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,35 @@
package com.walkguide.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "refresh_tokens")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(nullable = false, unique = true, length = 500)
private String token;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,45 @@
package com.walkguide.entity;
import com.walkguide.enums.SosStatus;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "sos_events")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SosEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "trigger_type", nullable = false)
@Builder.Default
private String triggerType = "MANUAL"; // VOICE_COMMAND, BUTTON, MANUAL
private Double lat;
private Double lng;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Builder.Default
private SosStatus status = SosStatus.TRIGGERED;
@Column(name = "acknowledged_at")
private LocalDateTime acknowledgedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}

View File

@ -1,22 +1,8 @@
package com.walkguide.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
@ -37,27 +23,33 @@ public class User {
private String password;
@Column(nullable = false)
private String role;
private String role; // ROLE_GUARDIAN atau ROLE_USER
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "connected_to")
private User connectedTo;
// 12-char alphanumeric ID khusus untuk ROLE_USER (seperti Discord ID)
@Column(name = "unique_user_id", unique = true, length = 12)
private String uniqueUserId;
@Column(name = "display_name", length = 100)
private String displayName;
// Firebase Cloud Messaging token untuk push notification
@Column(name = "fcm_token", length = 500)
private String fcmToken;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
private LocalDateTime updatedAt;
@PrePersist
public void onCreate() {
Instant now = Instant.now();
this.createdAt = now;
this.updatedAt = now;
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
public void onUpdate() {
this.updatedAt = Instant.now();
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,50 @@
package com.walkguide.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "user_settings")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserSettings {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false, unique = true)
private Long userId;
@Column(name = "tts_language", nullable = false)
@Builder.Default
private String ttsLanguage = "id-ID";
@Column(name = "tts_pitch", nullable = false)
@Builder.Default
private Double ttsPitch = 1.0;
@Column(name = "tts_speed", nullable = false)
@Builder.Default
private Double ttsSpeed = 0.9;
@Column(name = "warn_no_guardian", nullable = false)
@Builder.Default
private Boolean warnNoGuardian = true;
@Column(name = "haptic_enabled", nullable = false)
@Builder.Default
private Boolean hapticEnabled = true;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,45 @@
package com.walkguide.entity;
import com.walkguide.enums.VoiceCommandKey;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "voice_command_configs")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VoiceCommandConfig {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "guardian_id")
private Long guardianId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Enumerated(EnumType.STRING)
@Column(name = "command_key", nullable = false)
private VoiceCommandKey commandKey;
@Column(name = "trigger_phrase", nullable = false, length = 200)
private String triggerPhrase;
@Column(nullable = false)
@Builder.Default
private Boolean enabled = true;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,12 @@
package com.walkguide.enums;
public enum ActivityLogType {
LOGIN, LOGOUT, APP_OPEN, APP_CLOSE,
WALKGUIDE_START, WALKGUIDE_STOP,
OBSTACLE_DETECTED,
CALL_INITIATED, CALL_ENDED,
SOS_TRIGGERED, SOS_ACKNOWLEDGED,
LOCATION_UPDATE,
GEOFENCE_EXIT, GEOFENCE_ENTER,
PAIRING_INVITE_SENT, PAIRING_ACCEPTED, PAIRING_REJECTED, PAIRING_DISSOLVED
}

View File

@ -0,0 +1,9 @@
package com.walkguide.enums;
public enum HardwareShortcutKey {
CALL_GUARDIAN,
START_WALKGUIDE,
SEND_SOS,
STOP_WALKGUIDE,
OPEN_NOTIFICATION
}

View File

@ -0,0 +1,5 @@
package com.walkguide.enums;
public enum NotificationType {
TEXT, VOICE_NOTE
}

View File

@ -0,0 +1,5 @@
package com.walkguide.enums;
public enum PairingStatus {
PENDING, ACTIVE, REJECTED
}

View File

@ -0,0 +1,5 @@
package com.walkguide.enums;
public enum SosStatus {
TRIGGERED, ACKNOWLEDGED, RESOLVED
}

View File

@ -0,0 +1,18 @@
package com.walkguide.enums;
public enum VoiceCommandKey {
OPEN_WALKGUIDE,
START_WALKGUIDE,
STOP_WALKGUIDE,
CALL_GUARDIAN,
OPEN_NOTIFICATION,
READ_ALL_NOTIF,
OPEN_SOS,
SEND_SOS,
WHERE_AM_I,
OPEN_ACTIVITY,
OPEN_NAVIGATION,
OPEN_SETTINGS,
REPEAT_LAST,
STOP_TTS
}

View File

@ -1,27 +1,43 @@
package com.walkguide.exception;
import com.walkguide.dto.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import com.walkguide.dto.ApiResponse;
@ControllerAdvice
public class GlobalExceptionHandler {
// Nangkep error kalau login gagal / user gak ketemu
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ApiResponse<Object>> handleRuntimeException(RuntimeException ex) {
ApiResponse<Object> response = new ApiResponse<>(false, "AUTH_ERROR", ex.getMessage());
return ResponseEntity.status(401).body(response);
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiResponse<Object>> handleNotFound(ResourceNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(PairingException.class)
public ResponseEntity<ApiResponse<Object>> handlePairing(PairingException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("PAIRING_ERROR", ex.getMessage()));
}
// Nangkep error dari Validasi (misal email format salah)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Object>> handleValidationException(MethodArgumentNotValidException ex) {
String errorMsg = ex.getBindingResult().getAllErrors().get(0).getDefaultMessage();
ApiResponse<Object> response = new ApiResponse<>(false, "VALIDATION_ERROR", errorMsg);
return ResponseEntity.status(400).body(response);
public ResponseEntity<ApiResponse<Object>> handleValidation(MethodArgumentNotValidException ex) {
String msg = ex.getBindingResult().getAllErrors().get(0).getDefaultMessage();
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("VALIDATION_ERROR", msg));
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ApiResponse<Object>> handleRuntime(RuntimeException ex) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error("AUTH_ERROR", ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Object>> handleGeneric(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("INTERNAL_ERROR", "Terjadi kesalahan internal"));
}
}

View File

@ -0,0 +1,5 @@
package com.walkguide.exception;
public class PairingException extends RuntimeException {
public PairingException(String message) { super(message); }
}

View File

@ -0,0 +1,7 @@
package com.walkguide.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}

View File

@ -0,0 +1,17 @@
package com.walkguide.repository;
import com.walkguide.entity.ActivityLog;
import com.walkguide.enums.ActivityLogType;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface ActivityLogRepository extends JpaRepository<ActivityLog, Long> {
Page<ActivityLog> findByUser_IdOrderByCreatedAtDesc(Long userId, Pageable pageable);
List<ActivityLog> findByUser_IdAndCreatedAtBetween(Long userId, LocalDateTime from, LocalDateTime to);
List<ActivityLog> findByUser_IdAndLogTypeOrderByCreatedAtDesc(Long userId, ActivityLogType logType, Pageable pageable);
}

View File

@ -0,0 +1,9 @@
package com.walkguide.repository;
import com.walkguide.entity.AiConfig;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface AiConfigRepository extends JpaRepository<AiConfig, Long> {
Optional<AiConfig> findByUserId(Long userId);
}

View File

@ -0,0 +1,9 @@
package com.walkguide.repository;
import com.walkguide.entity.GeofenceConfig;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface GeofenceConfigRepository extends JpaRepository<GeofenceConfig, Long> {
Optional<GeofenceConfig> findByUserId(Long userId);
}

View File

@ -0,0 +1,13 @@
package com.walkguide.repository;
import com.walkguide.entity.GuardianNotification;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface GuardianNotificationRepository extends JpaRepository<GuardianNotification, Long> {
Page<GuardianNotification> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
long countByUserIdAndIsReadFalse(Long userId);
List<GuardianNotification> findByUserIdAndIsReadFalseOrderByCreatedAtAsc(Long userId);
}

View File

@ -0,0 +1,10 @@
package com.walkguide.repository;
import com.walkguide.entity.HardwareShortcut;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface HardwareShortcutRepository extends JpaRepository<HardwareShortcut, Long> {
List<HardwareShortcut> findByUserId(Long userId);
void deleteByUserId(Long userId);
}

View File

@ -0,0 +1,12 @@
package com.walkguide.repository;
import com.walkguide.entity.LocationHistory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface LocationHistoryRepository extends JpaRepository<LocationHistory, Long> {
Optional<LocationHistory> findTopByUserIdOrderByCreatedAtDesc(Long userId);
Page<LocationHistory> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
}

View File

@ -0,0 +1,10 @@
package com.walkguide.repository;
import com.walkguide.entity.ObstacleLog;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ObstacleLogRepository extends JpaRepository<ObstacleLog, Long> {
Page<ObstacleLog> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
}

View File

@ -0,0 +1,17 @@
package com.walkguide.repository;
import com.walkguide.entity.PairingRelation;
import com.walkguide.enums.PairingStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface PairingRelationRepository extends JpaRepository<PairingRelation, Long> {
Optional<PairingRelation> findByGuardian_IdAndStatus(Long guardianId, PairingStatus status);
Optional<PairingRelation> findByUser_IdAndStatus(Long userId, PairingStatus status);
Optional<PairingRelation> findByGuardian_Id(Long guardianId);
Optional<PairingRelation> findByUser_Id(Long userId);
boolean existsByGuardian_IdAndStatus(Long guardianId, PairingStatus status);
boolean existsByUser_IdAndStatus(Long userId, PairingStatus status);
}

View File

@ -0,0 +1,10 @@
package com.walkguide.repository;
import com.walkguide.entity.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByToken(String token);
void deleteByUserId(Long userId);
}

View File

@ -0,0 +1,10 @@
package com.walkguide.repository;
import com.walkguide.entity.SosEvent;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SosEventRepository extends JpaRepository<SosEvent, Long> {
Page<SosEvent> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
}

View File

@ -2,20 +2,12 @@ package com.walkguide.repository;
import com.walkguide.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
@Query("SELECT u FROM User u WHERE u.connectedTo.id = :guardianId AND u.role = 'ROLE_USER'")
Optional<User> findUserByGuardianId(@Param("guardianId") Long guardianId);
@Query("SELECT u FROM User u WHERE u.connectedTo.id = :userId AND u.role = 'ROLE_GUARDIAN'")
Optional<User> findGuardianByUserId(@Param("userId") Long userId);
Optional<User> findByUniqueUserId(String uniqueUserId);
boolean existsByEmail(String email);
}

View File

@ -0,0 +1,9 @@
package com.walkguide.repository;
import com.walkguide.entity.UserSettings;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserSettingsRepository extends JpaRepository<UserSettings, Long> {
Optional<UserSettings> findByUserId(Long userId);
}

View File

@ -0,0 +1,13 @@
package com.walkguide.repository;
import com.walkguide.entity.VoiceCommandConfig;
import com.walkguide.enums.VoiceCommandKey;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface VoiceCommandConfigRepository extends JpaRepository<VoiceCommandConfig, Long> {
List<VoiceCommandConfig> findByUserId(Long userId);
Optional<VoiceCommandConfig> findByUserIdAndCommandKey(Long userId, VoiceCommandKey commandKey);
void deleteByUserId(Long userId);
}

View File

@ -21,35 +21,38 @@ public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
// Kalau gak ada token, lewatin aja (biar dicegat sama SecurityConfig)
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// Potong tulisan "Bearer "
final String jwt = authHeader.substring(7);
try {
// Ambil email & role dari token lu
if (jwtUtil.isTokenValid(jwt)) {
String email = jwtUtil.extractUsername(jwt);
String role = jwtUtil.extractRole(jwt); // Pastiin JwtUtil lu punya fungsi extractRole!
String role = jwtUtil.extractRole(jwt);
Long userId = jwtUtil.extractUserId(jwt);
// Daftarin user ini ke sistem keamanan Spring
if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
email, null, Collections.singletonList(new SimpleGrantedAuthority(role))
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
email,
userId, // credentials slot dipakai untuk simpan userId
Collections.singletonList(new SimpleGrantedAuthority(role))
);
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (Exception e) {
// Token kadaluarsa / rusak
System.out.println("JWT Error: " + e.getMessage());
logger.warn("JWT processing error: " + e.getMessage());
}
filterChain.doFilter(request, response);

View File

@ -1,39 +1,76 @@
package com.walkguide.security;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import org.springframework.stereotype.Component;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
@Component
public class JwtUtil {
// Kunci rahasia buat enkripsi & dekripsi token (Minimal 256-bit)
private static final String SECRET_KEY = "404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970";
@Value("${jwt.secret}")
private String secretKey;
// Access token berlaku 1 jam
private static final long ACCESS_TOKEN_VALIDITY_MS = 1000L * 60 * 60;
public String generateAccessToken(String email, String role, Long userId) {
Map<String, Object> claims = new HashMap<>();
claims.put("role", role);
claims.put("userId", userId);
return Jwts.builder()
.setClaims(claims)
.setSubject(email)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_VALIDITY_MS))
.signWith(getSignInKey(), SignatureAlgorithm.HS256)
.compact();
}
// Refresh token: UUID random, expiry dikelola di DB
public String generateRefreshToken() {
return UUID.randomUUID().toString() + "-" + UUID.randomUUID().toString();
}
// Fungsi tambahan buat ngebongkar Email (Username)
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
// Fungsi tambahan buat ngebongkar Role
public String extractRole(String token) {
Claims claims = extractAllClaims(token);
return claims.get("role", String.class);
return extractAllClaims(token).get("role", String.class);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
public Long extractUserId(String token) {
Object userId = extractAllClaims(token).get("userId");
if (userId instanceof Integer) return ((Integer) userId).longValue();
if (userId instanceof Long) return (Long) userId;
return null;
}
public boolean isTokenValid(String token) {
try {
return !extractExpiration(token).before(new Date());
} catch (Exception e) {
return false;
}
}
private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
public <T> T extractClaim(String token, Function<Claims, T> resolver) {
return resolver.apply(extractAllClaims(token));
}
private Claims extractAllClaims(String token) {
@ -45,24 +82,7 @@ public class JwtUtil {
}
private Key getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
// Fungsi lama lu buat bikin token
public String generateToken(String email, String role) {
Map<String, Object> claims = new HashMap<>();
claims.put("role", role);
return createToken(claims, email);
}
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10)) // Aktif 10 jam
.signWith(getSignInKey(), SignatureAlgorithm.HS256)
.compact();
}
}

View File

@ -0,0 +1,27 @@
package com.walkguide.security;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
/**
* Helper untuk ambil userId dari SecurityContext di mana pun di dalam request.
* userId disimpan di credentials slot oleh JwtAuthFilter.
*/
public class SecurityHelper {
public static Long getCurrentUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof UsernamePasswordAuthenticationToken token) {
Object credentials = token.getCredentials();
if (credentials instanceof Long) return (Long) credentials;
if (credentials instanceof Integer) return ((Integer) credentials).longValue();
}
return null;
}
public static String getCurrentUserEmail() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null ? auth.getName() : null;
}
}

View File

@ -0,0 +1,57 @@
package com.walkguide.service;
import com.walkguide.dto.response.ActivityLogResponse;
import com.walkguide.entity.ActivityLog;
import com.walkguide.entity.User;
import com.walkguide.enums.ActivityLogType;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.ActivityLogRepository;
import com.walkguide.repository.PairingRelationRepository;
import com.walkguide.enums.PairingStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class ActivityLogService {
private final ActivityLogRepository activityLogRepository;
private final PairingRelationRepository pairingRelationRepository;
public void createLog(User user, ActivityLogType type, String description, String metadata) {
ActivityLog log = ActivityLog.builder()
.user(user)
.logType(type)
.description(description)
.metadata(metadata)
.build();
activityLogRepository.save(log);
}
public Page<ActivityLogResponse> getLogs(Long userId, Pageable pageable) {
return activityLogRepository
.findByUser_IdOrderByCreatedAtDesc(userId, pageable)
.map(this::toResponse);
}
public Page<ActivityLogResponse> getLogsForGuardian(Long guardianId, Pageable pageable) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.orElseThrow(() -> new ResourceNotFoundException("Belum ada user yang dipair"));
return activityLogRepository
.findByUser_IdOrderByCreatedAtDesc(pairing.getUser().getId(), pageable)
.map(this::toResponse);
}
private ActivityLogResponse toResponse(ActivityLog log) {
return ActivityLogResponse.builder()
.id(log.getId())
.logType(log.getLogType().name())
.description(log.getDescription())
.metadata(log.getMetadata())
.createdAt(log.getCreatedAt())
.build();
}
}

View File

@ -0,0 +1,63 @@
package com.walkguide.service;
import com.walkguide.dto.request.AiConfigUpdateRequest;
import com.walkguide.dto.response.AiConfigResponse;
import com.walkguide.entity.AiConfig;
import com.walkguide.enums.PairingStatus;
import com.walkguide.exception.PairingException;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.AiConfigRepository;
import com.walkguide.repository.PairingRelationRepository;
import com.walkguide.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AiConfigService {
private final AiConfigRepository aiConfigRepository;
private final PairingRelationRepository pairingRelationRepository;
private final FcmService fcmService;
public AiConfigResponse getConfig(Long userId) {
AiConfig cfg = aiConfigRepository.findByUserId(userId)
.orElseGet(() -> aiConfigRepository.save(AiConfig.builder().userId(userId).build()));
return toResponse(cfg);
}
public AiConfigResponse updateConfigByGuardian(Long guardianId, AiConfigUpdateRequest req) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.orElseThrow(() -> new PairingException("Tidak ada user yang dipair"));
Long userId = pairing.getUser().getId();
AiConfig cfg = aiConfigRepository.findByUserId(userId)
.orElseGet(() -> aiConfigRepository.save(AiConfig.builder().userId(userId).guardianId(guardianId).build()));
if (req.getConfidenceThreshold() != null) cfg.setConfidenceThreshold(req.getConfidenceThreshold());
if (req.getAlertDistanceClose() != null) cfg.setAlertDistanceClose(req.getAlertDistanceClose());
if (req.getAlertDistanceMedium() != null) cfg.setAlertDistanceMedium(req.getAlertDistanceMedium());
if (req.getMaxInferenceFps() != null) cfg.setMaxInferenceFps(req.getMaxInferenceFps());
if (req.getEnabledLabels() != null) cfg.setEnabledLabels(req.getEnabledLabels());
cfg = aiConfigRepository.save(cfg);
// Beritahu user settings telah diupdate
String userFcm = pairing.getUser().getFcmToken();
fcmService.sendToToken(userFcm, "Pengaturan AI Diperbarui",
"Guardian mengubah konfigurasi deteksi AI",
java.util.Map.of("type", "SETTINGS_UPDATED", "settingType", "AI_CONFIG"));
return toResponse(cfg);
}
private AiConfigResponse toResponse(AiConfig c) {
return AiConfigResponse.builder()
.id(c.getId()).confidenceThreshold(c.getConfidenceThreshold())
.alertDistanceClose(c.getAlertDistanceClose())
.alertDistanceMedium(c.getAlertDistanceMedium())
.maxInferenceFps(c.getMaxInferenceFps())
.enabledLabels(c.getEnabledLabels()).build();
}
}

View File

@ -1,48 +1,165 @@
package com.walkguide.service;
import java.util.HashMap;
import java.util.Map;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import com.walkguide.dto.request.LoginRequest;
import com.walkguide.dto.request.RegisterRequest;
import com.walkguide.dto.response.AuthDataResponse;
import com.walkguide.entity.RefreshToken;
import com.walkguide.entity.User;
import com.walkguide.entity.UserSettings;
import com.walkguide.enums.ActivityLogType;
import com.walkguide.repository.RefreshTokenRepository;
import com.walkguide.repository.UserRepository;
import com.walkguide.repository.UserSettingsRepository;
import com.walkguide.security.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.walkguide.dto.AuthRequest;
import com.walkguide.entity.User;
import com.walkguide.repository.UserRepository;
import com.walkguide.security.JwtUtil;
import lombok.RequiredArgsConstructor;
import java.security.SecureRandom;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final UserSettingsRepository userSettingsRepository;
private final ActivityLogService activityLogService;
private final JwtUtil jwtUtil;
private final PasswordEncoder passwordEncoder;
// Wajib panggil BCrypt biar bisa baca password enkripsi dari database
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
private static final String CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
private static final int ID_LENGTH = 12;
public Map<String, String> login(AuthRequest request) {
// 1. Cari user di database
User user = userRepository.findByEmail(request.getEmail())
@Transactional
public AuthDataResponse register(RegisterRequest req) {
if (userRepository.existsByEmail(req.getEmail())) {
throw new RuntimeException("Email sudah terdaftar");
}
String role = "GUARDIAN".equalsIgnoreCase(req.getRole())
? "ROLE_GUARDIAN" : "ROLE_USER";
String uniqueUserId = null;
if ("ROLE_USER".equals(role)) {
uniqueUserId = generateUniqueUserId();
}
User user = User.builder()
.email(req.getEmail())
.password(passwordEncoder.encode(req.getPassword()))
.role(role)
.displayName(req.getDisplayName())
.uniqueUserId(uniqueUserId)
.build();
user = userRepository.save(user);
// Buat default settings untuk user baru
if ("ROLE_USER".equals(role)) {
userSettingsRepository.save(UserSettings.builder()
.userId(user.getId())
.build());
}
return buildAuthResponse(user);
}
@Transactional
public AuthDataResponse login(LoginRequest req) {
User user = userRepository.findByEmail(req.getEmail())
.orElseThrow(() -> new RuntimeException("Email tidak terdaftar"));
// 2. Cocokin password (yang diketik VS yang dienkripsi di database)
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
if (!passwordEncoder.matches(req.getPassword(), user.getPassword())) {
throw new RuntimeException("Password salah");
}
// 3. Kalau bener, bikin Token
// Asumsi JwtUtil lu nerima parameter (email, role). Sesuaikan kalau beda!
String token = jwtUtil.generateToken(user.getEmail(), user.getRole());
// Hapus refresh token lama
refreshTokenRepository.deleteByUserId(user.getId());
// 4. Balikin ke Controller dalam bentuk Map
Map<String, String> data = new HashMap<>();
data.put("token", token);
data.put("role", user.getRole());
activityLogService.createLog(user, ActivityLogType.LOGIN, "User login", null);
return data;
return buildAuthResponse(user);
}
@Transactional
public AuthDataResponse refreshToken(String token) {
RefreshToken refreshToken = refreshTokenRepository.findByToken(token)
.orElseThrow(() -> new RuntimeException("Refresh token tidak valid"));
if (refreshToken.getExpiresAt().isBefore(LocalDateTime.now())) {
refreshTokenRepository.delete(refreshToken);
throw new RuntimeException("Refresh token sudah kadaluarsa, silakan login ulang");
}
User user = userRepository.findById(refreshToken.getUserId())
.orElseThrow(() -> new RuntimeException("User tidak ditemukan"));
String newAccessToken = jwtUtil.generateAccessToken(
user.getEmail(), user.getRole(), user.getId());
return AuthDataResponse.builder()
.accessToken(newAccessToken)
.refreshToken(token) // refresh token tetap sama
.role(user.getRole())
.userId(user.getId())
.displayName(user.getDisplayName())
.uniqueUserId(user.getUniqueUserId())
.build();
}
@Transactional
public void logout(Long userId) {
refreshTokenRepository.deleteByUserId(userId);
userRepository.findById(userId).ifPresent(user ->
activityLogService.createLog(user, ActivityLogType.LOGOUT, "User logout", null));
}
@Transactional
public void updateFcmToken(Long userId, String fcmToken) {
userRepository.findById(userId).ifPresent(user -> {
user.setFcmToken(fcmToken);
userRepository.save(user);
});
}
// ========== PRIVATE HELPERS ==========
private AuthDataResponse buildAuthResponse(User user) {
String accessToken = jwtUtil.generateAccessToken(
user.getEmail(), user.getRole(), user.getId());
String refreshTokenStr = jwtUtil.generateRefreshToken();
// Simpan refresh token ke DB (berlaku 30 hari)
refreshTokenRepository.save(RefreshToken.builder()
.userId(user.getId())
.token(refreshTokenStr)
.expiresAt(LocalDateTime.now().plusDays(30))
.build());
return AuthDataResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshTokenStr)
.role(user.getRole())
.userId(user.getId())
.displayName(user.getDisplayName())
.uniqueUserId(user.getUniqueUserId())
.build();
}
private String generateUniqueUserId() {
SecureRandom random = new SecureRandom();
StringBuilder sb = new StringBuilder(ID_LENGTH);
String candidate;
do {
sb.setLength(0);
for (int i = 0; i < ID_LENGTH; i++) {
sb.append(CHARS.charAt(random.nextInt(CHARS.length())));
}
candidate = sb.toString();
} while (userRepository.findByUniqueUserId(candidate).isPresent());
return candidate;
}
}

View File

@ -0,0 +1,50 @@
package com.walkguide.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
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.
*/
@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);
// 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 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);
}
}

View File

@ -0,0 +1,49 @@
package com.walkguide.service;
import com.walkguide.dto.request.GeofenceConfigRequest;
import com.walkguide.dto.response.GeofenceResponse;
import com.walkguide.entity.GeofenceConfig;
import com.walkguide.enums.PairingStatus;
import com.walkguide.exception.PairingException;
import com.walkguide.repository.GeofenceConfigRepository;
import com.walkguide.repository.PairingRelationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class GeofenceService {
private final GeofenceConfigRepository geofenceConfigRepository;
private final PairingRelationRepository pairingRelationRepository;
public GeofenceResponse getConfig(Long userId) {
return geofenceConfigRepository.findByUserId(userId)
.map(this::toResponse)
.orElse(GeofenceResponse.builder().enabled(false).radiusMeters(500.0).build());
}
public GeofenceResponse updateConfig(Long guardianId, GeofenceConfigRequest req) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.orElseThrow(() -> new PairingException("Tidak ada user yang dipair"));
Long userId = pairing.getUser().getId();
GeofenceConfig cfg = geofenceConfigRepository.findByUserId(userId)
.orElseGet(() -> GeofenceConfig.builder()
.userId(userId).guardianId(guardianId).build());
if (req.getCenterLat() != null) cfg.setCenterLat(req.getCenterLat());
if (req.getCenterLng() != null) cfg.setCenterLng(req.getCenterLng());
if (req.getRadiusMeters() != null) cfg.setRadiusMeters(req.getRadiusMeters());
if (req.getEnabled() != null) cfg.setEnabled(req.getEnabled());
return toResponse(geofenceConfigRepository.save(cfg));
}
private GeofenceResponse toResponse(GeofenceConfig c) {
return GeofenceResponse.builder().id(c.getId())
.centerLat(c.getCenterLat()).centerLng(c.getCenterLng())
.radiusMeters(c.getRadiusMeters()).enabled(c.getEnabled()).build();
}
}

View File

@ -0,0 +1,55 @@
package com.walkguide.service;
import com.walkguide.dto.response.DashboardResponse;
import com.walkguide.enums.PairingStatus;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class GuardianDashboardService {
private final PairingRelationRepository pairingRelationRepository;
private final LocationService locationService;
private final ActivityLogService activityLogService;
private final SosEventRepository sosEventRepository;
private final GuardianNotificationRepository notifRepository;
public DashboardResponse getDashboard(Long guardianId) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.orElse(null);
if (pairing == null) {
return DashboardResponse.builder()
.recentActivity(java.util.List.of())
.unreadSosCount(0).unreadNotifCount(0).build();
}
var user = pairing.getUser();
Long userId = user.getId();
var lastLocation = locationService.getLastLocation(userId).orElse(null);
var recentActivity = activityLogService.getLogs(userId, PageRequest.of(0, 5))
.getContent();
// Count unresolved SOS
long unreadSos = sosEventRepository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, 100))
.stream().filter(s -> s.getStatus().name().equals("TRIGGERED")).count();
long unreadNotif = notifRepository.countByUserIdAndIsReadFalse(userId);
return DashboardResponse.builder()
.pairedUserId(userId)
.pairedUserName(user.getDisplayName())
.pairedUserEmail(user.getEmail())
.uniqueUserId(user.getUniqueUserId())
.lastLocation(lastLocation)
.unreadSosCount(unreadSos)
.unreadNotifCount(unreadNotif)
.recentActivity(recentActivity)
.build();
}
}

View File

@ -0,0 +1,46 @@
package com.walkguide.service;
import com.walkguide.dto.request.HardwareShortcutUpdateRequest;
import com.walkguide.dto.response.HardwareShortcutResponse;
import com.walkguide.enums.HardwareShortcutKey;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.HardwareShortcutRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class HardwareShortcutService {
private final HardwareShortcutRepository hardwareShortcutRepository;
public List<HardwareShortcutResponse> getAll(Long userId) {
return hardwareShortcutRepository.findByUserId(userId).stream()
.map(s -> HardwareShortcutResponse.builder()
.id(s.getId()).shortcutKey(s.getShortcutKey().name())
.buttonName(s.getButtonName()).buttonCode(s.getButtonCode())
.enabled(s.getEnabled()).build())
.collect(Collectors.toList());
}
// Bisa dipanggil dari HP User langsung (capture button) atau dari Guardian
public HardwareShortcutResponse update(Long userId, HardwareShortcutUpdateRequest req) {
HardwareShortcutKey key = HardwareShortcutKey.valueOf(req.getShortcutKey());
var shortcut = hardwareShortcutRepository.findByUserId(userId).stream()
.filter(s -> s.getShortcutKey() == key)
.findFirst()
.orElseThrow(() -> new ResourceNotFoundException("Shortcut tidak ditemukan"));
if (req.getButtonName() != null) shortcut.setButtonName(req.getButtonName());
if (req.getButtonCode() != null) shortcut.setButtonCode(req.getButtonCode());
if (req.getEnabled() != null) shortcut.setEnabled(req.getEnabled());
shortcut = hardwareShortcutRepository.save(shortcut);
return HardwareShortcutResponse.builder()
.id(shortcut.getId()).shortcutKey(shortcut.getShortcutKey().name())
.buttonName(shortcut.getButtonName()).buttonCode(shortcut.getButtonCode())
.enabled(shortcut.getEnabled()).build();
}
}

View File

@ -0,0 +1,119 @@
package com.walkguide.service;
import com.walkguide.dto.request.LocationUpdateRequest;
import com.walkguide.dto.response.LocationResponse;
import com.walkguide.entity.LocationHistory;
import com.walkguide.entity.User;
import com.walkguide.enums.ActivityLogType;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.*;
import com.walkguide.enums.PairingStatus;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Slf4j
public class LocationService {
private final LocationHistoryRepository locationHistoryRepository;
private final GeofenceConfigRepository geofenceConfigRepository;
private final PairingRelationRepository pairingRelationRepository;
private final UserRepository userRepository;
private final ActivityLogService activityLogService;
private final FcmService fcmService;
public LocationResponse updateLocation(Long userId, LocationUpdateRequest req) {
LocationHistory loc = LocationHistory.builder()
.userId(userId)
.lat(req.getLat())
.lng(req.getLng())
.accuracy(req.getAccuracy())
.speed(req.getSpeed())
.heading(req.getHeading())
.build();
loc = locationHistoryRepository.save(loc);
// Cek geofence
checkGeofence(userId, req.getLat(), req.getLng());
return toResponse(loc);
}
public Optional<LocationResponse> getLastLocation(Long userId) {
return locationHistoryRepository
.findTopByUserIdOrderByCreatedAtDesc(userId)
.map(this::toResponse);
}
// Untuk Guardian: ambil last location user yang dipair
public Optional<LocationResponse> getLastLocationForGuardian(Long guardianId) {
return pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.flatMap(p -> locationHistoryRepository
.findTopByUserIdOrderByCreatedAtDesc(p.getUser().getId()))
.map(this::toResponse);
}
public Page<LocationResponse> getLocationHistory(Long userId, Pageable pageable) {
return locationHistoryRepository
.findByUserIdOrderByCreatedAtDesc(userId, pageable)
.map(this::toResponse);
}
// ========== GEOFENCE ==========
private void checkGeofence(Long userId, double lat, double lng) {
geofenceConfigRepository.findByUserId(userId).ifPresent(cfg -> {
if (!cfg.getEnabled() || cfg.getCenterLat() == null) return;
double dist = haversineMeters(cfg.getCenterLat(), cfg.getCenterLng(), lat, lng);
if (dist > cfg.getRadiusMeters()) {
// User keluar geofence beri tahu Guardian
pairingRelationRepository.findByUser_IdAndStatus(userId, PairingStatus.ACTIVE)
.ifPresent(pairing -> {
User guardian = pairing.getGuardian();
User user = pairing.getUser();
fcmService.sendHighPriority(
guardian.getFcmToken(),
"⚠️ User Keluar Area Aman",
user.getDisplayName() + " meninggalkan area geofence! " +
String.format("%.0fm dari pusat area", dist),
Map.of("type", "GEOFENCE_EXIT",
"userId", String.valueOf(userId),
"lat", String.valueOf(lat),
"lng", String.valueOf(lng))
);
activityLogService.createLog(user, ActivityLogType.GEOFENCE_EXIT,
String.format("User keluar area geofence (%.0fm dari pusat)", dist), null);
log.info("[GEOFENCE] User {} keluar area ({:.0f}m)", userId, dist);
});
}
});
}
/** Haversine formula — jarak antara 2 koordinat dalam meter */
public static double haversineMeters(double lat1, double lng1, double lat2, double lng2) {
final double R = 6371000; // radius bumi dalam meter
double dLat = Math.toRadians(lat2 - lat1);
double dLng = Math.toRadians(lng2 - lng1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(dLng / 2) * Math.sin(dLng / 2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
private LocationResponse toResponse(LocationHistory l) {
return LocationResponse.builder()
.id(l.getId()).lat(l.getLat()).lng(l.getLng())
.accuracy(l.getAccuracy()).speed(l.getSpeed()).heading(l.getHeading())
.createdAt(l.getCreatedAt()).build();
}
}

View File

@ -0,0 +1,96 @@
package com.walkguide.service;
import com.walkguide.dto.request.SendNotificationRequest;
import com.walkguide.dto.response.NotificationResponse;
import com.walkguide.entity.GuardianNotification;
import com.walkguide.enums.NotificationType;
import com.walkguide.enums.PairingStatus;
import com.walkguide.exception.PairingException;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.GuardianNotificationRepository;
import com.walkguide.repository.PairingRelationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class NotificationService {
private final GuardianNotificationRepository notifRepository;
private final PairingRelationRepository pairingRelationRepository;
private final FcmService fcmService;
@Transactional
public NotificationResponse sendNotification(Long guardianId, SendNotificationRequest req) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.orElseThrow(() -> new PairingException("Tidak ada user yang dipair"));
Long userId = pairing.getUser().getId();
NotificationType type = "VOICE_NOTE".equalsIgnoreCase(req.getNotifType())
? NotificationType.VOICE_NOTE : NotificationType.TEXT;
GuardianNotification notif = GuardianNotification.builder()
.guardianId(guardianId)
.userId(userId)
.notifType(type)
.content(req.getContent())
.voiceNoteUrl(req.getVoiceNoteUrl())
.voiceNoteDuration(req.getVoiceNoteDuration())
.build();
notif = notifRepository.save(notif);
// FCM ke user
String fcmToken = pairing.getUser().getFcmToken();
String fcmBody = type == NotificationType.TEXT
? req.getContent() : "Voice note from Guardian";
fcmService.sendToToken(fcmToken,
"Pesan dari Guardian",
fcmBody,
Map.of("type", "NOTIFICATION",
"notifId", String.valueOf(notif.getId()),
"notifType", type.name(),
"voiceNoteUrl", req.getVoiceNoteUrl() != null ? req.getVoiceNoteUrl() : ""));
return toResponse(notif);
}
public Page<NotificationResponse> getNotifications(Long userId, Pageable pageable) {
return notifRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable)
.map(this::toResponse);
}
public long getUnreadCount(Long userId) {
return notifRepository.countByUserIdAndIsReadFalse(userId);
}
@Transactional
public void markAllRead(Long userId) {
var unread = notifRepository.findByUserIdAndIsReadFalseOrderByCreatedAtAsc(userId);
LocalDateTime now = LocalDateTime.now();
unread.forEach(n -> { n.setIsRead(true); n.setReadAt(now); });
notifRepository.saveAll(unread);
}
@Transactional
public void markOneRead(Long notifId) {
notifRepository.findById(notifId).ifPresent(n -> {
n.setIsRead(true);
n.setReadAt(LocalDateTime.now());
notifRepository.save(n);
});
}
private NotificationResponse toResponse(GuardianNotification n) {
return NotificationResponse.builder()
.id(n.getId()).notifType(n.getNotifType().name()).content(n.getContent())
.voiceNoteUrl(n.getVoiceNoteUrl()).voiceNoteDuration(n.getVoiceNoteDuration())
.isRead(n.getIsRead()).readAt(n.getReadAt()).createdAt(n.getCreatedAt()).build();
}
}

View File

@ -0,0 +1,58 @@
package com.walkguide.service;
import com.walkguide.dto.request.ObstacleLogRequest;
import com.walkguide.dto.response.ObstacleLogResponse;
import com.walkguide.entity.ObstacleLog;
import com.walkguide.entity.User;
import com.walkguide.enums.ActivityLogType;
import com.walkguide.repository.ObstacleLogRepository;
import com.walkguide.repository.UserRepository;
import com.walkguide.exception.ResourceNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class ObstacleLogService {
private final ObstacleLogRepository obstacleLogRepository;
private final UserRepository userRepository;
private final ActivityLogService activityLogService;
public ObstacleLogResponse saveObstacle(Long userId, ObstacleLogRequest req) {
ObstacleLog log = ObstacleLog.builder()
.userId(userId)
.label(req.getLabel())
.confidence(req.getConfidence())
.direction(req.getDirection())
.estimatedDist(req.getEstimatedDist())
.lat(req.getLat())
.lng(req.getLng())
.build();
log = obstacleLogRepository.save(log);
// Log ke activity_logs juga
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
String meta = String.format("{\"label\":\"%s\",\"direction\":\"%s\",\"dist\":\"%s\",\"confidence\":%.2f}",
req.getLabel(), req.getDirection(), req.getEstimatedDist(), req.getConfidence());
activityLogService.createLog(user, ActivityLogType.OBSTACLE_DETECTED,
"Obstacle terdeteksi: " + req.getLabel() + " (" + req.getDirection() + ")", meta);
return toResponse(log);
}
public Page<ObstacleLogResponse> getObstacleLogs(Long userId, Pageable pageable) {
return obstacleLogRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable)
.map(this::toResponse);
}
private ObstacleLogResponse toResponse(ObstacleLog l) {
return ObstacleLogResponse.builder()
.id(l.getId()).label(l.getLabel()).confidence(l.getConfidence())
.direction(l.getDirection()).estimatedDist(l.getEstimatedDist())
.lat(l.getLat()).lng(l.getLng()).createdAt(l.getCreatedAt()).build();
}
}

View File

@ -0,0 +1,217 @@
package com.walkguide.service;
import com.walkguide.dto.response.PairingStatusResponse;
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.time.LocalDateTime;
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;
@Transactional
public PairingStatusResponse inviteUser(Long guardianId, String uniqueUserId) {
// 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 = userRepository.findByUniqueUserId(uniqueUserId)
.orElseThrow(() -> new ResourceNotFoundException("User dengan ID '" + uniqueUserId + "' tidak ditemukan"));
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);
// 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<VoiceCommandConfig> 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<HardwareShortcut> 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 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())
.pairedWithName(pairedWithName)
.pairedWithEmail(pairedWithEmail)
.uniqueUserId(user.getUniqueUserId())
.invitedAt(p.getInvitedAt())
.respondedAt(p.getRespondedAt())
.build();
}
}

View File

@ -0,0 +1,118 @@
package com.walkguide.service;
import com.walkguide.dto.request.SosRequest;
import com.walkguide.dto.response.SosEventResponse;
import com.walkguide.entity.SosEvent;
import com.walkguide.entity.User;
import com.walkguide.enums.ActivityLogType;
import com.walkguide.enums.PairingStatus;
import com.walkguide.enums.SosStatus;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class SosService {
private final SosEventRepository sosEventRepository;
private final PairingRelationRepository pairingRelationRepository;
private final UserRepository userRepository;
private final ActivityLogService activityLogService;
private final FcmService fcmService;
@Transactional
public SosEventResponse triggerSos(Long userId, SosRequest req) {
SosEvent sos = SosEvent.builder()
.userId(userId)
.triggerType(req.getTriggerType() != null ? req.getTriggerType() : "MANUAL")
.lat(req.getLat())
.lng(req.getLng())
.status(SosStatus.TRIGGERED)
.build();
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);
// Kirim ke Guardian
pairingRelationRepository.findByUser_IdAndStatus(userId, PairingStatus.ACTIVE)
.ifPresent(pairing -> {
String guardianFcm = pairing.getGuardian().getFcmToken();
String locStr = req.getLat() != null
? String.format("Lat:%.4f,Lng:%.4f", req.getLat(), req.getLng())
: "Lokasi tidak tersedia";
fcmService.sendHighPriority(guardianFcm,
"🚨 SOS ALERT dari " + user.getDisplayName(),
user.getDisplayName() + " butuh bantuan! " + locStr,
Map.of("type", "SOS_ALERT",
"sosId", String.valueOf(savedSos.getId()),
"userId", String.valueOf(userId),
"lat", String.valueOf(req.getLat() != null ? req.getLat() : 0),
"lng", String.valueOf(req.getLng() != null ? req.getLng() : 0)));
});
return toResponse(sos);
}
@Transactional
public SosEventResponse acknowledgeSos(Long guardianId, Long sosId) {
SosEvent sos = sosEventRepository.findById(sosId)
.orElseThrow(() -> new ResourceNotFoundException("SOS event tidak ditemukan"));
sos.setStatus(SosStatus.ACKNOWLEDGED);
sos.setAcknowledgedAt(LocalDateTime.now());
sosEventRepository.save(sos);
activityLogService.createLog(
userRepository.findById(sos.getUserId())
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan")),
ActivityLogType.SOS_ACKNOWLEDGED,
"Guardian mengakui SOS", null);
// Beritahu user
pairingRelationRepository.findByUser_IdAndStatus(sos.getUserId(), PairingStatus.ACTIVE)
.ifPresent(pairing -> {
String userFcm = pairing.getUser().getFcmToken();
fcmService.sendToToken(userFcm,
"Guardian Merespons SOS",
"Guardian kamu sudah melihat SOS kamu dan sedang menuju lokasimu",
Map.of("type", "SOS_ACKNOWLEDGED"));
});
return toResponse(sos);
}
public Page<SosEventResponse> getSosEvents(Long userId, Pageable pageable) {
return sosEventRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable)
.map(this::toResponse);
}
// Guardian get SOS for their paired user
public Page<SosEventResponse> getSosEventsForGuardian(Long guardianId, Pageable pageable) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.orElseThrow(() -> new ResourceNotFoundException("Tidak ada user yang dipair"));
return sosEventRepository
.findByUserIdOrderByCreatedAtDesc(pairing.getUser().getId(), pageable)
.map(this::toResponse);
}
private SosEventResponse toResponse(SosEvent s) {
return SosEventResponse.builder()
.id(s.getId()).triggerType(s.getTriggerType())
.lat(s.getLat()).lng(s.getLng()).status(s.getStatus().name())
.acknowledgedAt(s.getAcknowledgedAt()).createdAt(s.getCreatedAt()).build();
}
}

View File

@ -0,0 +1,42 @@
package com.walkguide.service;
import com.walkguide.dto.request.UserSettingsUpdateRequest;
import com.walkguide.dto.response.UserSettingsResponse;
import com.walkguide.entity.UserSettings;
import com.walkguide.repository.UserSettingsRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserSettingsService {
private final UserSettingsRepository userSettingsRepository;
public UserSettingsResponse getSettings(Long userId) {
UserSettings s = userSettingsRepository.findByUserId(userId)
.orElseGet(() -> userSettingsRepository.save(
UserSettings.builder().userId(userId).build()));
return toResponse(s);
}
public UserSettingsResponse updateSettings(Long userId, UserSettingsUpdateRequest req) {
UserSettings s = userSettingsRepository.findByUserId(userId)
.orElseGet(() -> UserSettings.builder().userId(userId).build());
if (req.getTtsLanguage() != null) s.setTtsLanguage(req.getTtsLanguage());
if (req.getTtsPitch() != null) s.setTtsPitch(req.getTtsPitch());
if (req.getTtsSpeed() != null) s.setTtsSpeed(req.getTtsSpeed());
if (req.getWarnNoGuardian() != null) s.setWarnNoGuardian(req.getWarnNoGuardian());
if (req.getHapticEnabled() != null) s.setHapticEnabled(req.getHapticEnabled());
return toResponse(userSettingsRepository.save(s));
}
private UserSettingsResponse toResponse(UserSettings s) {
return UserSettingsResponse.builder().id(s.getId())
.ttsLanguage(s.getTtsLanguage()).ttsPitch(s.getTtsPitch())
.ttsSpeed(s.getTtsSpeed()).warnNoGuardian(s.getWarnNoGuardian())
.hapticEnabled(s.getHapticEnabled()).build();
}
}

View File

@ -0,0 +1,82 @@
package com.walkguide.service;
import com.walkguide.dto.request.VoiceCommandUpdateRequest;
import com.walkguide.dto.response.VoiceCommandResponse;
import com.walkguide.enums.PairingStatus;
import com.walkguide.enums.VoiceCommandKey;
import com.walkguide.exception.PairingException;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.PairingRelationRepository;
import com.walkguide.repository.VoiceCommandConfigRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class VoiceCommandService {
private static final Map<VoiceCommandKey, String> DESCRIPTIONS = Map.ofEntries(
Map.entry(VoiceCommandKey.OPEN_WALKGUIDE, "Buka menu WalkGuide"),
Map.entry(VoiceCommandKey.START_WALKGUIDE, "Mulai deteksi WalkGuide"),
Map.entry(VoiceCommandKey.STOP_WALKGUIDE, "Hentikan WalkGuide"),
Map.entry(VoiceCommandKey.CALL_GUARDIAN, "Telepon Guardian"),
Map.entry(VoiceCommandKey.OPEN_NOTIFICATION, "Buka menu Notifikasi"),
Map.entry(VoiceCommandKey.READ_ALL_NOTIF, "Baca semua notifikasi dengan TTS"),
Map.entry(VoiceCommandKey.OPEN_SOS, "Buka menu SOS"),
Map.entry(VoiceCommandKey.SEND_SOS, "Kirim sinyal darurat SOS"),
Map.entry(VoiceCommandKey.WHERE_AM_I, "Umumkan lokasi saat ini via TTS"),
Map.entry(VoiceCommandKey.OPEN_ACTIVITY, "Buka log aktivitas"),
Map.entry(VoiceCommandKey.OPEN_NAVIGATION, "Buka mode navigasi"),
Map.entry(VoiceCommandKey.OPEN_SETTINGS, "Buka pengaturan"),
Map.entry(VoiceCommandKey.REPEAT_LAST, "Ulangi TTS terakhir"),
Map.entry(VoiceCommandKey.STOP_TTS, "Hentikan TTS yang sedang berjalan")
);
private final VoiceCommandConfigRepository voiceCommandConfigRepository;
private final PairingRelationRepository pairingRelationRepository;
private final FcmService fcmService;
public List<VoiceCommandResponse> getAll(Long userId) {
return voiceCommandConfigRepository.findByUserId(userId).stream()
.map(vc -> VoiceCommandResponse.builder()
.id(vc.getId())
.commandKey(vc.getCommandKey().name())
.triggerPhrase(vc.getTriggerPhrase())
.enabled(vc.getEnabled())
.description(DESCRIPTIONS.getOrDefault(vc.getCommandKey(), ""))
.build())
.collect(Collectors.toList());
}
public VoiceCommandResponse updateByGuardian(Long guardianId, VoiceCommandUpdateRequest req) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.orElseThrow(() -> new PairingException("Tidak ada user yang dipair"));
Long userId = pairing.getUser().getId();
VoiceCommandKey key = VoiceCommandKey.valueOf(req.getCommandKey());
var vc = voiceCommandConfigRepository.findByUserIdAndCommandKey(userId, key)
.orElseThrow(() -> new ResourceNotFoundException("Voice command tidak ditemukan"));
if (req.getTriggerPhrase() != null && !req.getTriggerPhrase().isBlank())
vc.setTriggerPhrase(req.getTriggerPhrase());
if (req.getEnabled() != null)
vc.setEnabled(req.getEnabled());
vc = voiceCommandConfigRepository.save(vc);
fcmService.sendToToken(pairing.getUser().getFcmToken(),
"Voice Command Diperbarui",
"Guardian mengubah perintah suara kamu",
Map.of("type", "SETTINGS_UPDATED", "settingType", "VOICE_COMMAND"));
return VoiceCommandResponse.builder()
.id(vc.getId()).commandKey(vc.getCommandKey().name())
.triggerPhrase(vc.getTriggerPhrase()).enabled(vc.getEnabled())
.description(DESCRIPTIONS.getOrDefault(vc.getCommandKey(), "")).build();
}
}

View File

@ -0,0 +1,14 @@
-- V10: SOS events
CREATE TABLE IF NOT EXISTS sos_events (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
trigger_type VARCHAR(50) NOT NULL DEFAULT 'MANUAL',
lat DOUBLE PRECISION,
lng DOUBLE PRECISION,
status VARCHAR(20) NOT NULL DEFAULT 'TRIGGERED',
acknowledged_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sos_user_id ON sos_events(user_id);
CREATE INDEX IF NOT EXISTS idx_sos_status ON sos_events(status);
CREATE INDEX IF NOT EXISTS idx_sos_created_at ON sos_events(created_at DESC);

View File

@ -0,0 +1,12 @@
-- V11: User settings - preferensi personal user (TTS, haptic, dll)
CREATE TABLE IF NOT EXISTS user_settings (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
tts_language VARCHAR(10) NOT NULL DEFAULT 'id-ID',
tts_pitch DOUBLE PRECISION NOT NULL DEFAULT 1.0,
tts_speed DOUBLE PRECISION NOT NULL DEFAULT 0.9,
warn_no_guardian BOOLEAN NOT NULL DEFAULT TRUE,
haptic_enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id);

View File

@ -0,0 +1,13 @@
-- V12: AI configs - konfigurasi YOLO detection untuk setiap user (diatur Guardian)
CREATE TABLE IF NOT EXISTS ai_configs (
id BIGSERIAL PRIMARY KEY,
guardian_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
user_id BIGINT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
confidence_threshold DOUBLE PRECISION NOT NULL DEFAULT 0.5,
alert_distance_close DOUBLE PRECISION NOT NULL DEFAULT 1.5,
alert_distance_medium DOUBLE PRECISION NOT NULL DEFAULT 3.0,
max_inference_fps INT NOT NULL DEFAULT 5,
enabled_labels TEXT NOT NULL DEFAULT 'ALL',
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ai_config_user_id ON ai_configs(user_id);

View File

@ -0,0 +1,12 @@
-- V13: Voice command configs - phrase yang diucapkan user untuk trigger aksi
CREATE TABLE IF NOT EXISTS voice_command_configs (
id BIGSERIAL PRIMARY KEY,
guardian_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
command_key VARCHAR(50) NOT NULL,
trigger_phrase VARCHAR(200) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT uq_voice_cmd_user_key UNIQUE (user_id, command_key)
);
CREATE INDEX IF NOT EXISTS idx_voice_cmd_user_id ON voice_command_configs(user_id);

View File

@ -0,0 +1,13 @@
-- V14: Hardware shortcuts - tombol fisik HP yang diassign ke aksi tertentu
CREATE TABLE IF NOT EXISTS hardware_shortcuts (
id BIGSERIAL PRIMARY KEY,
guardian_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
shortcut_key VARCHAR(50) NOT NULL,
button_name VARCHAR(100),
button_code INT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT uq_shortcut_user_key UNIQUE (user_id, shortcut_key)
);
CREATE INDEX IF NOT EXISTS idx_shortcut_user_id ON hardware_shortcuts(user_id);

View File

@ -0,0 +1,11 @@
-- V15: Geofence configs - area aman yang dipantau Guardian
CREATE TABLE IF NOT EXISTS geofence_configs (
id BIGSERIAL PRIMARY KEY,
guardian_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
user_id BIGINT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
center_lat DOUBLE PRECISION,
center_lng DOUBLE PRECISION,
radius_meters DOUBLE PRECISION NOT NULL DEFAULT 500.0,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

View File

@ -0,0 +1,10 @@
-- V16: Refresh tokens - untuk perpanjang session tanpa login ulang
CREATE TABLE IF NOT EXISTS refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(500) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_refresh_token_user_id ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_token_token ON refresh_tokens(token);

View File

@ -0,0 +1,11 @@
-- V4: Tambah kolom baru ke tabel users untuk fitur lengkap WalkGuide
-- Kolom unique_user_id: hanya untuk ROLE_USER, 12 karakter alphanumeric (seperti Discord ID)
-- Kolom display_name: nama tampilan user
-- Kolom fcm_token: Firebase Cloud Messaging token untuk push notification
ALTER TABLE users
ADD COLUMN IF NOT EXISTS unique_user_id VARCHAR(12) UNIQUE,
ADD COLUMN IF NOT EXISTS display_name VARCHAR(100),
ADD COLUMN IF NOT EXISTS fcm_token VARCHAR(500);
CREATE INDEX IF NOT EXISTS idx_users_unique_user_id ON users(unique_user_id);

View File

@ -0,0 +1,18 @@
-- V5: Tabel pairing_relations
-- Menyimpan hubungan Guardian <-> User (1:1)
-- Status: PENDING (menunggu User accept), ACTIVE (sudah pair), REJECTED (ditolak/unpair)
CREATE TABLE IF NOT EXISTS pairing_relations (
id BIGSERIAL PRIMARY KEY,
guardian_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
invited_at TIMESTAMP NOT NULL DEFAULT NOW(),
responded_at TIMESTAMP,
CONSTRAINT uq_guardian_active UNIQUE (guardian_id),
CONSTRAINT uq_user_active UNIQUE (user_id)
);
CREATE INDEX IF NOT EXISTS idx_pairing_guardian ON pairing_relations(guardian_id);
CREATE INDEX IF NOT EXISTS idx_pairing_user ON pairing_relations(user_id);
CREATE INDEX IF NOT EXISTS idx_pairing_status ON pairing_relations(status);

View File

@ -0,0 +1,12 @@
-- V6: Activity logs - semua aktivitas user dicatat di sini
CREATE TABLE IF NOT EXISTS activity_logs (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
log_type VARCHAR(50) NOT NULL,
description TEXT,
metadata TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_activity_user_id ON activity_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_activity_created_at ON activity_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_log_type ON activity_logs(log_type);

Some files were not shown because too many files have changed in this diff Show More