sakthivikram/geo-location
0
1package com.georeport.service;2 3import com.georeport.dto.*;4import com.georeport.entity.*;5import com.georeport.exception.BadRequestException;6import com.georeport.exception.ResourceNotFoundException;7import com.georeport.mapper.IssueMapper;8import com.georeport.repository.*;9import jakarta.persistence.EntityManager;10import jakarta.persistence.PersistenceContext;11import jakarta.persistence.TypedQuery;12import jakarta.persistence.criteria.*;13import org.springframework.beans.factory.annotation.Autowired;14import org.springframework.data.domain.Page;15import org.springframework.data.domain.PageImpl;16import org.springframework.data.domain.Pageable;17import org.springframework.messaging.simp.SimpMessagingTemplate;18import org.springframework.stereotype.Service;19import org.springframework.transaction.annotation.Transactional;20import org.springframework.web.multipart.MultipartFile;21 22import java.io.IOException;23import java.time.LocalDateTime;24import java.util.ArrayList;25import java.util.HashMap;26import java.util.List;27import java.util.Map;28import java.util.stream.Collectors;29 30/**31 * Service for issue management operations.32 * Handles CRUD operations, geo-queries, and status updates.33 */34@Service35public class IssueService {36 37 // Rough conversion: 1 degree latitude โ 111km38 private static final double METERS_PER_DEGREE = 111000.0;39 40 @PersistenceContext41 private EntityManager entityManager;42 43 @Autowired44 private IssueRepository issueRepository;45 46 @Autowired47 private IssueCategoryRepository categoryRepository;48 49 @Autowired50 private IssueImageRepository imageRepository;51 52 @Autowired53 private IssueStatusHistoryRepository statusHistoryRepository;54 55 @Autowired56 private UserRepository userRepository;57 58 @Autowired59 private NotificationRepository notificationRepository;60 61 @Autowired62 private FileStorageService fileStorageService;63 64 @Autowired65 private IssueMapper issueMapper;66 67 @Autowired68 private DepartmentRoutingService departmentRoutingService;69 70 @Autowired71 private SimpMessagingTemplate messagingTemplate;72 73 /**74 * Create a new issue75 */76 @Transactional77 public IssueResponse createIssue(CreateIssueRequest request, User reporter, List<MultipartFile> images) {78 // Get category79 IssueCategory category = categoryRepository.findById(request.getCategoryId())80 .orElseThrow(() -> new ResourceNotFoundException("Category", "id", request.getCategoryId()));81 82 // --- SMART ROUTING: auto-assign department based on category ---83 String assignedDepartment = departmentRoutingService.route(category);84 85 // Create issue with lat/lng86 Issue issue = Issue.builder()87 .title(request.getTitle())88 .description(request.getDescription())89 .latitude(request.getLatitude())90 .longitude(request.getLongitude())91 .address(request.getAddress())92 .ward(request.getWard())93 .landmark(request.getLandmark())94 .category(category)95 .reporter(reporter)96 .contactPhone(request.getContactPhone())97 .contactEmail(request.getContactEmail())98 .status(IssueStatus.SUBMITTED)99 .priority(request.getPriority() != null ? request.getPriority() : IssuePriority.MEDIUM)100 .department(assignedDepartment)101 .build();102 103 issue = issueRepository.save(issue);104 105 // Add status history106 IssueStatusHistory history = IssueStatusHistory.builder()107 .issue(issue)108 .newStatus(IssueStatus.SUBMITTED)109 .changedBy(reporter)110 .changeReason("Issue created")111 .build();112 statusHistoryRepository.save(history);113 114 // Handle image uploads115 if (images != null && !images.isEmpty()) {116 for (int i = 0; i < images.size(); i++) {117 MultipartFile file = images.get(i);118 if (!file.isEmpty()) {119 try {120 String filename = fileStorageService.storeFile(file);121 IssueImage image = IssueImage.builder()122 .issue(issue)123 .fileName(filename)124 .originalName(file.getOriginalFilename())125 .filePath(fileStorageService.getUploadPath().resolve(filename).toString())126 .fileSize(file.getSize())127 .contentType(file.getContentType())128 .isPrimary(i == 0)129 .build();130 imageRepository.save(image);131 issue.getImages().add(image);132 } catch (IOException e) {133 throw new BadRequestException("Failed to upload image: " + e.getMessage());134 }135 }136 }137 }138 139 // Broadcast new issue to admin dashboard140 broadcastIssueUpdate("NEW_ISSUE", issue);141 142 return issueMapper.toResponse(issue);143 }144 145 /**146 * Get issue by ID147 */148 @Transactional(readOnly = true)149 public IssueResponse getIssueById(Long id) {150 Issue issue = issueRepository.findById(id)151 .orElseThrow(() -> new ResourceNotFoundException("Issue", "id", id));152 return issueMapper.toResponse(issue);153 }154 155 /**156 * Get all issues for a reporter157 */158 @Transactional(readOnly = true)159 public List<IssueResponse> getIssuesByReporter(Long reporterId) {160 return issueRepository.findByReporterIdOrderByCreatedAtDesc(reporterId)161 .stream()162 .map(issueMapper::toResponse)163 .collect(Collectors.toList());164 }165 166 /**167 * Get all issues with optional filters168 */169 @Transactional(readOnly = true)170 public Page<IssueResponse> getAllIssues(IssueStatus status, Long categoryId, String ward,171 LocalDateTime startDate, LocalDateTime endDate, Pageable pageable) {172 return issueRepository.findWithFilters(status, categoryId, ward, startDate, endDate, pageable)173 .map(issueMapper::toResponse);174 }175 176 /**177 * Get nearby issues178 */179 @Transactional(readOnly = true)180 public List<IssueResponse> getNearbyIssues(double latitude, double longitude, double radiusMeters) {181 // Convert meters to degrees (approximate)182 double radiusDegrees = radiusMeters / METERS_PER_DEGREE;183 184 return issueRepository.findNearbyIssues(latitude, longitude, radiusDegrees)185 .stream()186 .map(issueMapper::toResponse)187 .collect(Collectors.toList());188 }189 190 /**191 * Get issues within bounding box (for map viewport)192 */193 @Transactional(readOnly = true)194 public List<IssueResponse> getIssuesInBounds(double minLng, double minLat, double maxLng, double maxLat) {195 return issueRepository.findIssuesInBoundingBox(minLng, minLat, maxLng, maxLat)196 .stream()197 .map(issueMapper::toResponse)198 .collect(Collectors.toList());199 }200 201 /**202 * Advanced search with dynamic filtering using Criteria API203 */204 @Transactional(readOnly = true)205 public Page<IssueResponse> searchIssues(IssueSearchRequest request, Pageable pageable) {206 CriteriaBuilder cb = entityManager.getCriteriaBuilder();207 CriteriaQuery<Issue> query = cb.createQuery(Issue.class);208 Root<Issue> root = query.from(Issue.class);209 210 List<Predicate> predicates = new ArrayList<>();211 212 // Keyword search (title or description)213 if (request.getKeyword() != null && !request.getKeyword().trim().isEmpty()) {214 String keyword = "%" + request.getKeyword().toLowerCase() + "%";215 Predicate titleMatch = cb.like(cb.lower(root.get("title")), keyword);216 Predicate descMatch = cb.like(cb.lower(root.get("description")), keyword);217 predicates.add(cb.or(titleMatch, descMatch));218 }219 220 // Filter by statuses221 if (request.getStatuses() != null && !request.getStatuses().isEmpty()) {222 predicates.add(root.get("status").in(request.getStatuses()));223 }224 225 // Filter by category IDs226 if (request.getCategoryIds() != null && !request.getCategoryIds().isEmpty()) {227 predicates.add(root.get("category").get("id").in(request.getCategoryIds()));228 }229 230 // Filter by priorities231 if (request.getPriorities() != null && !request.getPriorities().isEmpty()) {232 predicates.add(root.get("priority").in(request.getPriorities()));233 }234 235 // Filter by ward236 if (request.getWard() != null && !request.getWard().trim().isEmpty()) {237 predicates.add(cb.equal(root.get("ward"), request.getWard()));238 }239 240 // Date range filter241 if (request.getStartDate() != null) {242 predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), request.getStartDate()));243 }244 if (request.getEndDate() != null) {245 predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), request.getEndDate()));246 }247 248 // Radius/proximity filter249 if (request.getLatitude() != null && request.getLongitude() != null && request.getRadiusMeters() != null) {250 double radiusDegrees = request.getRadiusMeters() / METERS_PER_DEGREE;251 predicates.add(cb.between(root.get("latitude"),252 request.getLatitude() - radiusDegrees,253 request.getLatitude() + radiusDegrees));254 predicates.add(cb.between(root.get("longitude"),255 request.getLongitude() - radiusDegrees,256 request.getLongitude() + radiusDegrees));257 }258 259 // Apply predicates260 if (!predicates.isEmpty()) {261 query.where(cb.and(predicates.toArray(new Predicate[0])));262 }263 264 // Sorting265 String sortBy = request.getSortBy() != null ? request.getSortBy() : "createdAt";266 if ("desc".equalsIgnoreCase(request.getSortDirection())) {267 query.orderBy(cb.desc(root.get(sortBy)));268 } else {269 query.orderBy(cb.asc(root.get(sortBy)));270 }271 272 // Execute query with pagination273 TypedQuery<Issue> typedQuery = entityManager.createQuery(query);274 typedQuery.setFirstResult((int) pageable.getOffset());275 typedQuery.setMaxResults(pageable.getPageSize());276 277 List<Issue> results = typedQuery.getResultList();278 279 // Get total count for pagination280 CriteriaQuery<Long> countQuery = cb.createQuery(Long.class);281 Root<Issue> countRoot = countQuery.from(Issue.class);282 countQuery.select(cb.count(countRoot));283 284 // Rebuild predicates for count query (same filters)285 List<Predicate> countPredicates = new ArrayList<>();286 if (request.getKeyword() != null && !request.getKeyword().trim().isEmpty()) {287 String keyword = "%" + request.getKeyword().toLowerCase() + "%";288 countPredicates.add(cb.or(289 cb.like(cb.lower(countRoot.get("title")), keyword),290 cb.like(cb.lower(countRoot.get("description")), keyword)));291 }292 if (request.getStatuses() != null && !request.getStatuses().isEmpty()) {293 countPredicates.add(countRoot.get("status").in(request.getStatuses()));294 }295 if (request.getCategoryIds() != null && !request.getCategoryIds().isEmpty()) {296 countPredicates.add(countRoot.get("category").get("id").in(request.getCategoryIds()));297 }298 if (request.getPriorities() != null && !request.getPriorities().isEmpty()) {299 countPredicates.add(countRoot.get("priority").in(request.getPriorities()));300 }301 if (request.getWard() != null && !request.getWard().trim().isEmpty()) {302 countPredicates.add(cb.equal(countRoot.get("ward"), request.getWard()));303 }304 if (request.getStartDate() != null) {305 countPredicates.add(cb.greaterThanOrEqualTo(countRoot.get("createdAt"), request.getStartDate()));306 }307 if (request.getEndDate() != null) {308 countPredicates.add(cb.lessThanOrEqualTo(countRoot.get("createdAt"), request.getEndDate()));309 }310 if (request.getLatitude() != null && request.getLongitude() != null && request.getRadiusMeters() != null) {311 double radiusDegrees = request.getRadiusMeters() / METERS_PER_DEGREE;312 countPredicates.add(cb.between(countRoot.get("latitude"),313 request.getLatitude() - radiusDegrees, request.getLatitude() + radiusDegrees));314 countPredicates.add(cb.between(countRoot.get("longitude"),315 request.getLongitude() - radiusDegrees, request.getLongitude() + radiusDegrees));316 }317 318 if (!countPredicates.isEmpty()) {319 countQuery.where(cb.and(countPredicates.toArray(new Predicate[0])));320 }321 322 Long total = entityManager.createQuery(countQuery).getSingleResult();323 324 List<IssueResponse> responseList = results.stream()325 .map(issueMapper::toResponse)326 .collect(Collectors.toList());327 328 return new PageImpl<>(responseList, pageable, total);329 }330 331 /**332 * Update issue status (admin action)333 */334 @Transactional335 public IssueResponse updateIssueStatus(Long issueId, UpdateStatusRequest request, User admin) {336 Issue issue = issueRepository.findById(issueId)337 .orElseThrow(() -> new ResourceNotFoundException("Issue", "id", issueId));338 339 IssueStatus oldStatus = issue.getStatus();340 issue.setStatus(request.getStatus());341 342 // Handle status-specific updates343 if (request.getStatus() == IssueStatus.RESOLVED) {344 issue.setResolvedAt(LocalDateTime.now());345 issue.setResolutionNotes(request.getNotes());346 } else if (request.getStatus() == IssueStatus.REJECTED) {347 issue.setRejectionReason(request.getNotes());348 }349 350 // Assign to admin if provided351 if (request.getAssignedToId() != null) {352 User assignee = userRepository.findById(request.getAssignedToId())353 .orElseThrow(() -> new ResourceNotFoundException("User", "id", request.getAssignedToId()));354 issue.setAssignedTo(assignee);355 }356 357 issue = issueRepository.save(issue);358 359 // Add status history360 IssueStatusHistory history = IssueStatusHistory.builder()361 .issue(issue)362 .oldStatus(oldStatus)363 .newStatus(request.getStatus())364 .changedBy(admin)365 .changeReason(request.getNotes())366 .build();367 statusHistoryRepository.save(history);368 369 // Create notification for reporter370 createNotification(issue.getReporter(), issue,371 "Issue Status Updated",372 String.format("Your issue '%s' status changed to %s", issue.getTitle(),373 request.getStatus().getDisplayName()),374 "STATUS_UPDATE");375 376 // Broadcast update via WebSocket377 broadcastIssueUpdate("STATUS_UPDATE", issue);378 379 // Send personal notification to reporter380 sendPersonalNotification(issue.getReporter().getId(), issue);381 382 return issueMapper.toResponse(issue);383 }384 385 /**386 * Get status history for an issue387 */388 @Transactional(readOnly = true)389 public List<StatusHistoryResponse> getStatusHistory(Long issueId) {390 return statusHistoryRepository.findByIssueIdOrderByCreatedAtDesc(issueId)391 .stream()392 .map(issueMapper::toStatusHistoryResponse)393 .collect(Collectors.toList());394 }395 396 /**397 * Get dashboard statistics398 */399 @Transactional(readOnly = true)400 public DashboardStats getDashboardStats() {401 long total = issueRepository.count();402 long submitted = issueRepository.countByStatus(IssueStatus.SUBMITTED);403 long inProgress = issueRepository.countByStatus(IssueStatus.IN_PROGRESS);404 long resolved = issueRepository.countByStatus(IssueStatus.RESOLVED);405 long rejected = issueRepository.countByStatus(IssueStatus.REJECTED);406 407 Map<String, Long> byCategory = new HashMap<>();408 issueRepository.getIssueCountByCategory().forEach(row -> byCategory.put((String) row[0], (Long) row[1]));409 410 Map<String, Long> byWard = new HashMap<>();411 issueRepository.findAllDistinctWards().forEach(ward -> byWard.put(ward, issueRepository.countByWard(ward)));412 413 return DashboardStats.builder()414 .totalIssues(total)415 .submittedCount(submitted)416 .inProgressCount(inProgress)417 .resolvedCount(resolved)418 .rejectedCount(rejected)419 .issuesByCategory(byCategory)420 .issuesByWard(byWard)421 .build();422 }423 424 /**425 * Get all categories426 */427 @Transactional(readOnly = true)428 public List<CategoryResponse> getAllCategories() {429 return categoryRepository.findByIsActiveTrueOrderByPriorityAsc()430 .stream()431 .map(issueMapper::toCategoryResponse)432 .collect(Collectors.toList());433 }434 435 /**436 * Get issues filtered by department (for department dashboards).437 * Each department sees ONLY their own complaints.438 */439 @Transactional(readOnly = true)440 public List<IssueResponse> getIssuesByDepartment(String department) {441 return issueRepository.findByDepartmentOrderByCreatedAtDesc(department)442 .stream()443 .map(issueMapper::toResponse)444 .collect(Collectors.toList());445 }446 447 /**448 * Get per-department stats summary.449 */450 @Transactional(readOnly = true)451 public Map<String, Long> getDepartmentStats() {452 Map<String, Long> stats = new HashMap<>();453 issueRepository.getIssueCountByDepartment()454 .forEach(row -> stats.put((String) row[0], (Long) row[1]));455 return stats;456 }457 458 /**459 * Delete issue (Admin or Reporter only)460 */461 @Transactional462 public void deleteIssue(Long issueId, User user) {463 Issue issue = issueRepository.findById(issueId)464 .orElseThrow(() -> new ResourceNotFoundException("Issue", "id", issueId));465 466 // Check permission467 boolean isAdmin = user.getRoles().stream()468 .anyMatch(r -> r.getName() == RoleType.ROLE_ADMIN);469 470 if (!isAdmin && !issue.getReporter().getId().equals(user.getId())) {471 throw new BadRequestException("You do not have permission to delete this issue");472 }473 474 // Delete associated files475 if (issue.getImages() != null) {476 for (IssueImage img : issue.getImages()) {477 fileStorageService.deleteFile(img.getFileName());478 }479 }480 481 issueRepository.delete(issue);482 }483 484 /**485 * Update issue details (Reporter only)486 */487 @Transactional488 public IssueResponse updateIssueDetails(Long issueId, CreateIssueRequest request, User user) {489 Issue issue = issueRepository.findById(issueId)490 .orElseThrow(() -> new ResourceNotFoundException("Issue", "id", issueId));491 492 // Check permission: Only reporter can edit details493 if (!issue.getReporter().getId().equals(user.getId())) {494 throw new BadRequestException("Only the reporter can edit this issue");495 }496 497 // Allow edits only if status is SUBMITTED or IN_PROGRESS498 if (issue.getStatus() != IssueStatus.SUBMITTED && issue.getStatus() != IssueStatus.IN_PROGRESS) {499 throw new BadRequestException("Cannot edit issue in " + issue.getStatus() + " status");500 }501 502 // Update fields503 issue.setTitle(request.getTitle());504 issue.setDescription(request.getDescription());505 issue.setLatitude(request.getLatitude());506 issue.setLongitude(request.getLongitude());507 issue.setAddress(request.getAddress());508 issue.setWard(request.getWard());509 issue.setLandmark(request.getLandmark());510 if (request.getPriority() != null) {511 issue.setPriority(request.getPriority());512 }513 514 // Update category if changed515 if (!issue.getCategory().getId().equals(request.getCategoryId())) {516 IssueCategory newCategory = categoryRepository.findById(request.getCategoryId())517 .orElseThrow(() -> new ResourceNotFoundException("Category", "id", request.getCategoryId()));518 issue.setCategory(newCategory);519 }520 521 issue = issueRepository.save(issue);522 return issueMapper.toResponse(issue);523 }524 525 /**526 * Create notification for a user527 */528 private void createNotification(User user, Issue issue, String title, String message, String type) {529 Notification notification = Notification.builder()530 .user(user)531 .issue(issue)532 .title(title)533 .message(message)534 .type(type)535 .isRead(false)536 .build();537 notificationRepository.save(notification);538 }539 540 /**541 * Broadcast issue update via WebSocket542 */543 private void broadcastIssueUpdate(String eventType, Issue issue) {544 Map<String, Object> payload = new HashMap<>();545 payload.put("eventType", eventType);546 payload.put("issue", issueMapper.toResponse(issue));547 payload.put("timestamp", System.currentTimeMillis());548 549 messagingTemplate.convertAndSend("/topic/issues", payload);550 }551 552 /**553 * Send personal notification via WebSocket554 */555 private void sendPersonalNotification(Long userId, Issue issue) {556 Map<String, Object> payload = new HashMap<>();557 payload.put("eventType", "NOTIFICATION");558 payload.put("issueId", issue.getId());559 payload.put("title", issue.getTitle());560 payload.put("status", issue.getStatus().name());561 payload.put("timestamp", System.currentTimeMillis());562 563 messagingTemplate.convertAndSendToUser(564 userId.toString(),565 "/queue/notifications",566 payload);567 }568}569 