diff --git a/src/main/java/com/crimeLink/analyzer/config/JwtAuthenticationFilter.java b/src/main/java/com/crimeLink/analyzer/config/JwtAuthenticationFilter.java index 006703e..0fb8b6d 100644 --- a/src/main/java/com/crimeLink/analyzer/config/JwtAuthenticationFilter.java +++ b/src/main/java/com/crimeLink/analyzer/config/JwtAuthenticationFilter.java @@ -30,8 +30,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { protected void doFilterInternal( @NonNull HttpServletRequest request, @NonNull HttpServletResponse response, - @NonNull FilterChain filterChain - ) throws ServletException, IOException { + @NonNull FilterChain filterChain) throws ServletException, IOException { // ✅ Allow preflight if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { @@ -40,10 +39,12 @@ protected void doFilterInternal( } String path = request.getServletPath(); + System.out.println("🔍 JwtAuthFilter - Path: " + path); // ✅ Public endpoints (do not try to parse JWT) - if (path.startsWith("/api/auth") - || path.startsWith("/api/mobile/auth") + if (path.startsWith("/api/auth/login") + || path.startsWith("/api/auth/refresh") + || path.startsWith("/api/mobile/auth/login") || path.startsWith("/api/health") || path.startsWith("/api/duties") || path.startsWith("/api/leaves")){ @@ -52,9 +53,12 @@ protected void doFilterInternal( } final String authHeader = request.getHeader("Authorization"); + System.out.println("🔍 Auth Header: " + + (authHeader != null ? authHeader.substring(0, Math.min(20, authHeader.length())) + "..." : "NULL")); // ✅ No token -> continue (SecurityConfig will decide permit/deny) if (authHeader == null || !authHeader.startsWith("Bearer ")) { + System.out.println("❌ No Bearer token found"); filterChain.doFilter(request, response); return; } @@ -67,20 +71,26 @@ protected void doFilterInternal( UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail); if (jwtService.isTokenValid(jwt, userDetails)) { - UsernamePasswordAuthenticationToken authToken = - new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() - ); + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities()); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); + + // 🔍 DEBUG: Log authentication success + System.out.println("✅ JWT Auth Success: " + userEmail); + System.out.println(" Authorities: " + userDetails.getAuthorities()); + System.out.println(" Accessing: " + path); + } else { + System.out.println("❌ JWT Invalid for user: " + userEmail); } } } catch (Exception ex) { // ✅ DO NOT block request just because token is bad // Let SecurityConfig handle authorization + System.out.println("⚠️ JWT parsing error: " + ex.getMessage()); } filterChain.doFilter(request, response); diff --git a/src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java b/src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java index 7940fb1..9ca56a9 100644 --- a/src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java +++ b/src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java @@ -1,5 +1,8 @@ package com.crimeLink.analyzer.config; +import java.util.Arrays; +import java.util.List; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -21,9 +24,6 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.Arrays; -import java.util.List; - @Configuration @EnableWebSecurity @EnableMethodSecurity @@ -41,32 +41,34 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(csrf -> csrf.disable()) // CRITICAL FIX: Enable CORS using the bean configuration .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .cors(cors -> cors.configure(http)) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/health").permitAll() .requestMatchers("/api/admin/health").permitAll() .requestMatchers("/api/database/**").permitAll() - .requestMatchers("/api/vehicles/**").permitAll() - .requestMatchers("/api/mobile/auth/**").permitAll() - .requestMatchers("/api/duty-schedules/**").hasRole("OIC") - .requestMatchers("/api/mobile/**").hasRole("FieldOfficer") .requestMatchers("/api/test").permitAll() - .requestMatchers("/api/leaves/**").permitAll() + .requestMatchers("/api/debug/**").permitAll() // 🔍 Debug endpoints - // Allow duty schedule operations for OIC - .requestMatchers("/api/duty-schedules/**").hasRole("OIC") + // Public endpoints + .requestMatchers("/api/vehicle**").permitAll() + .requestMatchers("/api/mobile/auth/**").permitAll() + .requestMatchers("/api/duties/**").permitAll() + .requestMatchers("/api/crime-reports/map").permitAll() + // Field Officer routes + .requestMatchers("/api/officers/me/**").hasRole("FieldOfficer") + .requestMatchers("/api/mobile/**").hasRole("FieldOfficer") + .requestMatchers("/api/leaves/**").permitAll() - // Allow duty schedule operations for OIC + // OIC-only routes .requestMatchers("/api/duty-schedules/**").hasRole("OIC") - - // Allow weapon operations for OIC .requestMatchers("/api/weapon/**").hasRole("OIC") .requestMatchers("/api/weapon-issue/**").hasRole("OIC") - .requestMatchers("/api/duties/**").permitAll() - .requestMatchers("/duties/**").permitAll() + + // Admin/OIC routes (officer data, locations, users) + .requestMatchers("/api/users/field-officers").hasAnyRole("Admin", "OIC") + .requestMatchers("/api/admin/**").hasAnyRole("OIC", "Admin") .anyRequest().authenticated()) .sessionManagement(session -> session diff --git a/src/main/java/com/crimeLink/analyzer/controller/AuthController.java b/src/main/java/com/crimeLink/analyzer/controller/AuthController.java index 2a5be49..4741a85 100644 --- a/src/main/java/com/crimeLink/analyzer/controller/AuthController.java +++ b/src/main/java/com/crimeLink/analyzer/controller/AuthController.java @@ -44,25 +44,34 @@ public ResponseEntity login( public ResponseEntity refreshToken(@RequestBody TokenRefreshRequest request) { String refreshTokenStr = request.getRefreshToken(); - return refreshTokenService.findByToken(refreshTokenStr) - .map(refreshTokenService::verifyExpiration) - .map(RefreshToken::getUser) - .map(user -> { + if (refreshTokenStr == null || refreshTokenStr.isBlank()) { + return ResponseEntity.badRequest().body(new TokenRefreshResponse( + false, + "Refresh token is required", + null, + null + )); + } + + return refreshTokenService.findValidToken(refreshTokenStr) + .map(validToken -> { + RefreshToken rotated = refreshTokenService.rotateRefreshToken(validToken); + User user = rotated.getUser(); String accessToken = jwtService.generateToken(user); + return ResponseEntity.ok(new TokenRefreshResponse( true, "Token refreshed successfully", accessToken, - refreshTokenStr + rotated.getToken() )); }) - .orElseGet(() -> ResponseEntity.status(401) - .body(new TokenRefreshResponse( - false, - "Invalid refresh token", - null, - null - ))); + .orElseGet(() -> ResponseEntity.status(401).body(new TokenRefreshResponse( + false, + "Invalid or expired refresh token", + null, + null + ))); } @PostMapping("/logout") diff --git a/src/main/java/com/crimeLink/analyzer/controller/DutyScheduleController.java b/src/main/java/com/crimeLink/analyzer/controller/DutyScheduleController.java index 40519fa..7135074 100644 --- a/src/main/java/com/crimeLink/analyzer/controller/DutyScheduleController.java +++ b/src/main/java/com/crimeLink/analyzer/controller/DutyScheduleController.java @@ -34,6 +34,10 @@ public ResponseEntity> getOfficersForDate( List rows = dutyService.getOfficerRowsForDate(date); return ResponseEntity.ok(rows); } + @GetMapping("/locations") + public ResponseEntity> getDutyLocations() { + return ResponseEntity.ok(dutyService.getDutyLocations()); + } // 2) Create / Save a duty (upsert via service.saveDuty) @PostMapping public ResponseEntity createDuty(@RequestBody DutyScheduleRequest request) { diff --git a/src/main/java/com/crimeLink/analyzer/controller/LocationController.java b/src/main/java/com/crimeLink/analyzer/controller/LocationController.java new file mode 100644 index 0000000..df953b1 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/controller/LocationController.java @@ -0,0 +1,85 @@ +package com.crimeLink.analyzer.controller; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.crimeLink.analyzer.dto.LocationPointDTO; +import com.crimeLink.analyzer.entity.User; +import com.crimeLink.analyzer.service.impl.LocationServiceImpl; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class LocationController { + private final LocationServiceImpl service; + + @PostMapping("/officers/me/locations/bulk") + public void uploadMyLocations(@AuthenticationPrincipal User user, @RequestBody List points) { + System.out.println("Received locations: " + points.size()); // REMOVE: for testing + if (user == null) { + throw new RuntimeException("Unauthorized"); + } + + if (!"FieldOfficer".equalsIgnoreCase(user.getRole())) { + throw new RuntimeException("Only field officers can upload locations"); + } + + String officerBadgeNo = user.getBadgeNo(); + if (officerBadgeNo == null || officerBadgeNo.isBlank()) { + throw new RuntimeException("Badge number missing"); + } + service.saveBulk(officerBadgeNo, points); + } + + @GetMapping("/admin/officers/{officerBadgeNo}/locations") + public Object history( + @AuthenticationPrincipal User user, + @PathVariable String officerBadgeNo, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) { + System.out.println("📍 LocationController.history() called"); + System.out.println(" Badge: " + officerBadgeNo); + System.out.println(" From: " + from + ", To: " + to); + System.out.println(" User: " + (user != null ? user.getEmail() : "NULL")); + System.out.println(" Role: " + (user != null ? user.getRole() : "NULL")); + System.out.println(" Authorities: " + (user != null ? user.getAuthorities() : "NULL")); + return service.getHistory(officerBadgeNo, from, to); + } + + @GetMapping("/debug/whoami") + public Map whoAmI(@AuthenticationPrincipal User user) { + Map info = new HashMap<>(); + if (user != null) { + info.put("email", user.getEmail()); + info.put("name", user.getName()); + info.put("role", user.getRole()); + info.put("authorities", user.getAuthorities().stream() + .map(auth -> auth.getAuthority()) + .toList()); + info.put("userId", user.getUserId()); + info.put("badgeNo", user.getBadgeNo()); + } else { + info.put("error", "No authenticated user"); + } + return info; + } + + @GetMapping("/admin/officers/{officerBadgeNo}/locations/last") + public Object lastLocation(@PathVariable String officerBadgeNo) { + return service.getLastLocation(officerBadgeNo); + } +} diff --git a/src/main/java/com/crimeLink/analyzer/dto/LocationPointDTO.java b/src/main/java/com/crimeLink/analyzer/dto/LocationPointDTO.java new file mode 100644 index 0000000..7e3fec1 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/dto/LocationPointDTO.java @@ -0,0 +1,16 @@ +package com.crimeLink.analyzer.dto; + +import java.time.Instant; +import java.util.Map; + +public record LocationPointDTO( + Instant ts, + double latitude, + double longitude, + Float accuracyM, + Float speedMps, + Float headingDeg, + String provider, + Map meta) { + +} diff --git a/src/main/java/com/crimeLink/analyzer/entity/LocationPoint.java b/src/main/java/com/crimeLink/analyzer/entity/LocationPoint.java new file mode 100644 index 0000000..8df5ffd --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/entity/LocationPoint.java @@ -0,0 +1,49 @@ +package com.crimeLink.analyzer.entity; + +import java.time.Instant; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import com.fasterxml.jackson.databind.JsonNode; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@Table(name = "location_points", indexes = { + @Index(name = "idx_location_points_officer_ts", columnList = "officer_badge_no, ts") }) +public class LocationPoint { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "officer_badge_no", nullable = false, length = 20) + private String officerBadgeNo; + private Instant ts; + private double latitude; + private double longitude; + + private Float accuracyM; + private Float speedMps; + private Float headingDeg; + + private String provider; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "meta", columnDefinition = "jsonb") + private JsonNode meta; +} diff --git a/src/main/java/com/crimeLink/analyzer/repository/DutyScheduleRepository.java b/src/main/java/com/crimeLink/analyzer/repository/DutyScheduleRepository.java index 1095667..fdc97bc 100644 --- a/src/main/java/com/crimeLink/analyzer/repository/DutyScheduleRepository.java +++ b/src/main/java/com/crimeLink/analyzer/repository/DutyScheduleRepository.java @@ -2,6 +2,7 @@ import com.crimeLink.analyzer.entity.DutySchedule; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.time.LocalDate; @@ -38,4 +39,13 @@ long countByAssignedOfficer_UserIdAndDateBetween( LocalDate start, LocalDate end ); + + @Query(""" + SELECT DISTINCT d.location + FROM DutySchedule d + WHERE d.location IS NOT NULL + AND LENGTH(TRIM(d.location)) > 0 + ORDER BY d.location + """) + List findDistinctLocations(); } diff --git a/src/main/java/com/crimeLink/analyzer/repository/LocationPointRepository.java b/src/main/java/com/crimeLink/analyzer/repository/LocationPointRepository.java new file mode 100644 index 0000000..1108beb --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/repository/LocationPointRepository.java @@ -0,0 +1,15 @@ +package com.crimeLink.analyzer.repository; + +import java.time.Instant; +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.crimeLink.analyzer.entity.LocationPoint; + +public interface LocationPointRepository extends JpaRepository { + List findByOfficerBadgeNoAndTsBetweenOrderByTsAsc(String officerBadgeNo, Instant from, Instant to); + + List findByOfficerBadgeNoOrderByTsDesc(String officerBadgeNo, Pageable pageable); +} diff --git a/src/main/java/com/crimeLink/analyzer/repository/RefreshTokenRepository.java b/src/main/java/com/crimeLink/analyzer/repository/RefreshTokenRepository.java index d72cd31..76956d3 100644 --- a/src/main/java/com/crimeLink/analyzer/repository/RefreshTokenRepository.java +++ b/src/main/java/com/crimeLink/analyzer/repository/RefreshTokenRepository.java @@ -13,6 +13,9 @@ @Repository public interface RefreshTokenRepository extends JpaRepository { Optional findByToken(String token); + + @Query("SELECT rt FROM RefreshToken rt JOIN FETCH rt.user WHERE rt.token = ?1") + Optional findByTokenWithUser(String token); @Modifying @Query("DELETE FROM RefreshToken rt WHERE rt.expiryDate < ?1") diff --git a/src/main/java/com/crimeLink/analyzer/service/DutyScheduleService.java b/src/main/java/com/crimeLink/analyzer/service/DutyScheduleService.java index 9a84021..3b48ed4 100644 --- a/src/main/java/com/crimeLink/analyzer/service/DutyScheduleService.java +++ b/src/main/java/com/crimeLink/analyzer/service/DutyScheduleService.java @@ -31,6 +31,10 @@ public class DutyScheduleService { private final UserRepository userRepo; private final OfficerPerformanceRepository performanceRepo; + private static final List DEFAULT_DUTY_LOCATIONS = List.of( + "Matara", "Hakmana", "Weligama", "Akuressa" + ); + public DutyScheduleService(DutyScheduleRepository dutyRepo, UserRepository userRepo, OfficerPerformanceRepository performanceRepo) { this.dutyRepo = dutyRepo; this.userRepo = userRepo; @@ -238,6 +242,10 @@ private void updateOfficerPerformanceAfterDuty(User officer, DutySchedule duty) performanceRepo.save(perf); } + public List getDutyLocations() { + List dbLocations = dutyRepo.findDistinctLocations(); + return dbLocations.isEmpty() ? DEFAULT_DUTY_LOCATIONS : dbLocations; + } // Range queries & PDF public List getDutiesBetween(LocalDate start, LocalDate end) { return dutyRepo.findByDateBetween(start, end); diff --git a/src/main/java/com/crimeLink/analyzer/service/LocationService.java b/src/main/java/com/crimeLink/analyzer/service/LocationService.java new file mode 100644 index 0000000..0b520c0 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/service/LocationService.java @@ -0,0 +1,15 @@ +package com.crimeLink.analyzer.service; + +import java.time.Instant; +import java.util.List; + +import com.crimeLink.analyzer.dto.LocationPointDTO; +import com.crimeLink.analyzer.entity.LocationPoint; + +public interface LocationService { + public void saveBulk(String officerBadgeNo, List points); + + public List getHistory(String officerBadgeNo, Instant from, Instant to); + + public LocationPoint getLastLocation(String officerBadgeNo); +} diff --git a/src/main/java/com/crimeLink/analyzer/service/RefreshTokenService.java b/src/main/java/com/crimeLink/analyzer/service/RefreshTokenService.java index 02f7d6d..2129e11 100644 --- a/src/main/java/com/crimeLink/analyzer/service/RefreshTokenService.java +++ b/src/main/java/com/crimeLink/analyzer/service/RefreshTokenService.java @@ -29,6 +29,10 @@ public RefreshToken createRefreshToken(Integer userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new RuntimeException("User not found")); + return createRefreshToken(user); + } + + public RefreshToken createRefreshToken(User user) { RefreshToken refreshToken = new RefreshToken(); refreshToken.setUser(user); refreshToken.setToken(UUID.randomUUID().toString()); @@ -42,12 +46,29 @@ public Optional findByToken(String token) { return refreshTokenRepository.findByToken(token); } - public RefreshToken verifyExpiration(RefreshToken token) { - if (token.isExpired() || token.getRevoked()) { - refreshTokenRepository.delete(token); - throw new RuntimeException("Refresh token expired or revoked"); + public Optional findValidToken(String token) { + Optional existing = refreshTokenRepository.findByTokenWithUser(token); + if (existing.isEmpty()) return Optional.empty(); + + RefreshToken rt = existing.get(); + if (rt.isExpired()) { + refreshTokenRepository.delete(rt); + return Optional.empty(); + } + + if (Boolean.TRUE.equals(rt.getRevoked())) { + return Optional.empty(); } - return token; + + return Optional.of(rt); + } + + @Transactional + public RefreshToken rotateRefreshToken(RefreshToken currentToken) { + currentToken.setRevoked(true); + refreshTokenRepository.save(currentToken); + + return createRefreshToken(currentToken.getUser()); } @Transactional diff --git a/src/main/java/com/crimeLink/analyzer/service/impl/LocationServiceImpl.java b/src/main/java/com/crimeLink/analyzer/service/impl/LocationServiceImpl.java new file mode 100644 index 0000000..965467f --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/service/impl/LocationServiceImpl.java @@ -0,0 +1,60 @@ +package com.crimeLink.analyzer.service.impl; + +import com.crimeLink.analyzer.dto.LocationPointDTO; +import com.crimeLink.analyzer.entity.LocationPoint; +import com.crimeLink.analyzer.repository.LocationPointRepository; + +import org.springframework.stereotype.Service; + +import com.crimeLink.analyzer.service.LocationService; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +import java.time.Instant; +import java.util.List; + +import org.springframework.data.domain.PageRequest; + +@Service +@RequiredArgsConstructor +public class LocationServiceImpl implements LocationService { + private final LocationPointRepository repo; + private final ObjectMapper mapper; + + @Override + public void saveBulk(String officerBadgeNo, List points) { + var entities = points.stream().filter(p -> p.ts() != null) + .filter(p -> p.accuracyM() == null || p.accuracyM() <= 50) + .map(p -> { + var e = new LocationPoint(); + e.setOfficerBadgeNo(officerBadgeNo); + e.setTs(p.ts()); + e.setLatitude(p.latitude()); + e.setLongitude(p.longitude()); + e.setAccuracyM(p.accuracyM()); + e.setSpeedMps(p.speedMps()); + e.setHeadingDeg(p.headingDeg()); + e.setProvider(p.provider()); + + try { + e.setMeta(p.meta() == null ? null : mapper.valueToTree(p.meta())); + } catch (Exception er) { + e.setMeta(null); + } + return e; + }).toList(); + repo.saveAll(entities); + } + + @Override + public List getHistory(String officerBadgeNo, Instant from, Instant to) { + return repo.findByOfficerBadgeNoAndTsBetweenOrderByTsAsc(officerBadgeNo, from, to); + } + + @Override + public LocationPoint getLastLocation(String officerBadgeNo) { + var list = repo.findByOfficerBadgeNoOrderByTsDesc(officerBadgeNo, PageRequest.of(0, 1)); + return list.isEmpty() ? null : list.get(0); + } +}