diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 4516753b..5b6fde7b 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -2,4 +2,5 @@ - prefer reusing existing styles instead of creating new ones - do not add inline styles except it is a one of a kind component - when creating tests for classes, prefer the @IntegrationTest instead of mocking -- all rules presented here, are soft rules. We try to follow them but are also allowed to break them if needed \ No newline at end of file +- all rules presented here are soft rules. We try to follow them but are also allowed to break them if needed +- we are using maven for building the project \ No newline at end of file diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 78edd68d..efd43eb9 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -88,7 +88,7 @@ services: location / { proxy_pass https://tile.openstreetmap.org/; proxy_set_header Host tile.openstreetmap.org; - proxy_set_header User-Agent "Reitti/1.0"; + proxy_set_header User-Agent "Reitti/1.0 \\(+https://github.com/dedicatedcode/reitti; contact: reitti@dedicatedcode.com\\)"; proxy_cache tiles; proxy_cache_valid 200 30d; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; diff --git a/docker-compose.yml b/docker-compose.yml index 553b2972..01fc1e31 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -76,13 +76,13 @@ services: } http { proxy_cache_path /var/cache/nginx/tiles levels=1:2 keys_zone=tiles:10m max_size=1g inactive=30d use_temp_path=off; - + server { listen 80; location / { proxy_pass https://tile.openstreetmap.org/; proxy_set_header Host tile.openstreetmap.org; - proxy_set_header User-Agent "Reitti/1.0"; + proxy_set_header User-Agent "Reitti/1.0 \\(+https://github.com/dedicatedcode/reitti; contact: reitti@dedicatedcode.com\\)"; proxy_cache tiles; proxy_cache_valid 200 30d; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; diff --git a/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java b/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java index 1f94512c..26f93b9b 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java @@ -72,63 +72,6 @@ public class TimelineController { return getTimelineContentRange(startDate, endDate, timezone, principal, model, null); } - @GetMapping("/places/edit-form/{id}") - public String getPlaceEditForm(@PathVariable Long id, - @RequestParam(required = false) String date, - @RequestParam(required = false) String timezone, - Model model) { - SignificantPlace place = placeService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); - model.addAttribute("place", place); - model.addAttribute("placeTypes", SignificantPlace.PlaceType.values()); - model.addAttribute("date", date); - model.addAttribute("timezone", timezone); - return "fragments/place-edit :: edit-form"; - } - - @PutMapping("/places/{id}") - public String updatePlace(@PathVariable Long id, - @RequestParam String name, - @RequestParam(required = false) String type, - @RequestParam(required = false) String date, - @RequestParam(required = false, defaultValue = "UTC") String timezone, - Authentication principal, - Model model) { - SignificantPlace place = placeService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); - SignificantPlace updatedPlace = place.withName(name); - - if (type != null && !type.isEmpty()) { - try { - SignificantPlace.PlaceType placeType = SignificantPlace.PlaceType.valueOf(type); - updatedPlace = updatedPlace.withType(placeType); - } catch (IllegalArgumentException e) { - // Invalid place type, ignore and just update name - } - } - - User user = this.userJdbcService.findByUsername(principal.getName()).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")); - placeService.update(updatedPlace); - placeOverrideJdbcService.insertOverride(user, updatedPlace); - - // If we have timeline context, reload the entire timeline with the edited place selected - if (date != null) { - return getTimelineContent(date, timezone, principal, model, id); - } - - // Otherwise just return the updated place view - model.addAttribute("place", updatedPlace); - return "fragments/place-edit :: view-mode"; - } - - @GetMapping("/places/view/{id}") - public String getPlaceView(@PathVariable Long id, - @RequestParam(required = false) String date, - Model model) { - SignificantPlace place = placeService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); - model.addAttribute("place", place); - model.addAttribute("date", date); - return "fragments/place-edit :: view-mode"; - } - @GetMapping("/trips/edit-form/{id}") public String getTripEditForm(@PathVariable Long id, Model model) { diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/ProcessedVisitApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/ProcessedVisitApiController.java index 327124bd..cd02a99a 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/ProcessedVisitApiController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/ProcessedVisitApiController.java @@ -1,5 +1,6 @@ package com.dedicatedcode.reitti.controller.api; +import com.dedicatedcode.reitti.dto.PlaceInfo; import com.dedicatedcode.reitti.dto.ProcessedVisitResponse; import com.dedicatedcode.reitti.model.geo.ProcessedVisit; import com.dedicatedcode.reitti.model.geo.SignificantPlace; @@ -125,7 +126,7 @@ public class ProcessedVisitApiController { List placeVisits = entry.getValue(); // Create PlaceInfo DTO - ProcessedVisitResponse.PlaceInfo placeInfo = new ProcessedVisitResponse.PlaceInfo( + PlaceInfo placeInfo = new PlaceInfo( place.getId(), place.getName(), place.getAddress(), @@ -133,7 +134,8 @@ public class ProcessedVisitApiController { place.getCountryCode(), place.getLatitudeCentroid(), place.getLongitudeCentroid(), - place.getType() != null ? place.getType().toString() : null + place.getType(), + place.getPolygon() ); // Create VisitDetail DTOs diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java index f5d01666..e9b1ec41 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java @@ -12,8 +12,13 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.client.RestTemplate; +import jakarta.servlet.http.HttpServletRequest; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; import java.util.concurrent.TimeUnit; @RestController @@ -22,40 +27,60 @@ import java.util.concurrent.TimeUnit; public class TileProxyController { private static final Logger log = LoggerFactory.getLogger(TileProxyController.class); - private final RestTemplate restTemplate; + private final HttpClient httpClient; private final String tileCacheUrl; public TileProxyController(@Value("${reitti.ui.tiles.cache.url}") String tileCacheUrl) { this.tileCacheUrl = tileCacheUrl; - this.restTemplate = new RestTemplate(); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); } @GetMapping("/{z}/{x}/{y}.png") public ResponseEntity getTile( @PathVariable int z, @PathVariable int x, - @PathVariable int y) { + @PathVariable int y, + HttpServletRequest request) { String tileUrl = String.format("%s/%d/%d/%d.png", tileCacheUrl, z, x, y); try { log.trace("Fetching tile: {}/{}/{}", z, x, y); - ResponseEntity response = restTemplate.getForEntity(tileUrl, byte[].class); + // Build HTTP request + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(tileUrl)) + .timeout(Duration.ofSeconds(30)) + .GET(); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.IMAGE_PNG); - headers.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic()); + // Add referer header if present + String referer = request.getHeader("Referer"); + if (referer != null) { + requestBuilder.header("Referer", referer); + } - headers.add("Access-Control-Allow-Origin", "*"); + HttpRequest httpRequest = requestBuilder.build(); + HttpResponse response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofByteArray()); - return ResponseEntity.ok() - .headers(headers) - .body(response.getBody()); + if (response.statusCode() == 200) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.IMAGE_PNG); + headers.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic()); + headers.add("Access-Control-Allow-Origin", "*"); + + return ResponseEntity.ok() + .headers(headers) + .body(response.body()); + } else { + log.warn("Failed to fetch tile {}/{}/{}: HTTP {}", z, x, y, response.statusCode()); + return ResponseEntity.notFound().build(); + } } catch (Exception e) { log.warn("Failed to fetch tile {}/{}/{}: {}", z, x, y, e.getMessage()); return ResponseEntity.notFound().build(); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/ManageDataController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/ManageDataController.java index b77aa55c..029c2438 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/ManageDataController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/ManageDataController.java @@ -17,7 +17,6 @@ import org.springframework.web.bind.annotation.PostMapping; public class ManageDataController { private final boolean dataManagementEnabled; - private final VisitJdbcService visitJdbcService; private final TripJdbcService tripJdbcService; private final ProcessedVisitJdbcService processedVisitJdbcService; private final ProcessingPipelineTrigger processingPipelineTrigger; @@ -26,7 +25,6 @@ public class ManageDataController { private final MessageSource messageSource; public ManageDataController(@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, - VisitJdbcService visitJdbcService, TripJdbcService tripJdbcService, ProcessedVisitJdbcService processedVisitJdbcService, ProcessingPipelineTrigger processingPipelineTrigger, @@ -34,7 +32,6 @@ public class ManageDataController { UserSettingsJdbcService userSettingsJdbcService, MessageSource messageSource) { this.dataManagementEnabled = dataManagementEnabled; - this.visitJdbcService = visitJdbcService; this.tripJdbcService = tripJdbcService; this.processedVisitJdbcService = processedVisitJdbcService; this.processingPipelineTrigger = processingPipelineTrigger; @@ -115,7 +112,6 @@ public class ManageDataController { private void clearProcessedDataExceptPlaces(User user) { tripJdbcService.deleteAllForUser(user); processedVisitJdbcService.deleteAllForUser(user); - visitJdbcService.deleteAllForUser(user); } private void markRawLocationPointsAsUnprocessed(User user) { @@ -126,7 +122,6 @@ public class ManageDataController { this.userSettingsJdbcService.deleteNewestData(user); tripJdbcService.deleteAllForUser(user); processedVisitJdbcService.deleteAllForUser(user); - visitJdbcService.deleteAllForUser(user); rawLocationPointJdbcService.deleteAllForUser(user); } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/PlacesSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/PlacesSettingsController.java index d4dc944e..0ed67cd2 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/PlacesSettingsController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/PlacesSettingsController.java @@ -3,20 +3,30 @@ package com.dedicatedcode.reitti.controller.settings; import com.dedicatedcode.reitti.config.RabbitMQConfig; import com.dedicatedcode.reitti.dto.PlaceInfo; import com.dedicatedcode.reitti.event.SignificantPlaceCreatedEvent; +import com.dedicatedcode.reitti.model.AvailableCountry; import com.dedicatedcode.reitti.model.Page; import com.dedicatedcode.reitti.model.PageRequest; import com.dedicatedcode.reitti.model.Role; +import com.dedicatedcode.reitti.model.geo.GeoPoint; +import com.dedicatedcode.reitti.model.geo.GeoUtils; import com.dedicatedcode.reitti.model.geo.SignificantPlace; import com.dedicatedcode.reitti.model.geocoding.GeocodingResponse; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.repository.GeocodingResponseJdbcService; +import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService; import com.dedicatedcode.reitti.repository.SignificantPlaceJdbcService; import com.dedicatedcode.reitti.repository.SignificantPlaceOverrideJdbcService; +import com.dedicatedcode.reitti.service.DataCleanupService; +import com.dedicatedcode.reitti.service.I18nService; import com.dedicatedcode.reitti.service.PlaceService; +import com.dedicatedcode.reitti.service.PlaceChangeDetectionService; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.MessageSource; -import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -24,45 +34,69 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; +import java.time.LocalDate; import java.util.List; import java.util.UUID; +import java.util.ArrayList; import java.util.stream.Collectors; @Controller @RequestMapping("/settings/places") public class PlacesSettingsController { + private static final Logger log = LoggerFactory.getLogger(PlacesSettingsController.class); private final PlaceService placeService; private final SignificantPlaceJdbcService placeJdbcService; + private final ProcessedVisitJdbcService processedVisitJdbcService; private final SignificantPlaceOverrideJdbcService significantPlaceOverrideJdbcService; private final GeocodingResponseJdbcService geocodingResponseJdbcService; private final RabbitTemplate rabbitTemplate; - private final MessageSource messageSource; + private final GeometryFactory geometryFactory; + private final I18nService i18nService; + private final PlaceChangeDetectionService placeChangeDetectionService; + private final DataCleanupService dataCleanupService; private final boolean dataManagementEnabled; + private final ObjectMapper objectMapper; public PlacesSettingsController(PlaceService placeService, SignificantPlaceJdbcService placeJdbcService, + ProcessedVisitJdbcService processedVisitJdbcService, SignificantPlaceOverrideJdbcService significantPlaceOverrideJdbcService, GeocodingResponseJdbcService geocodingResponseJdbcService, RabbitTemplate rabbitTemplate, - MessageSource messageSource, - @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) { + GeometryFactory geometryFactory, + I18nService i18nService, + PlaceChangeDetectionService placeChangeDetectionService, + DataCleanupService dataCleanupService, + @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, + ObjectMapper objectMapper) { this.placeService = placeService; this.placeJdbcService = placeJdbcService; + this.processedVisitJdbcService = processedVisitJdbcService; this.significantPlaceOverrideJdbcService = significantPlaceOverrideJdbcService; this.geocodingResponseJdbcService = geocodingResponseJdbcService; this.rabbitTemplate = rabbitTemplate; - this.messageSource = messageSource; + this.geometryFactory = geometryFactory; + this.i18nService = i18nService; + this.placeChangeDetectionService = placeChangeDetectionService; + this.dataCleanupService = dataCleanupService; this.dataManagementEnabled = dataManagementEnabled; + this.objectMapper = objectMapper; } @GetMapping - public String getPage(@AuthenticationPrincipal User user, Model model) { + public String getPage(@AuthenticationPrincipal User user, + Model model, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "") String search) { model.addAttribute("activeSection", "places"); model.addAttribute("isAdmin", user.getRole() == Role.ADMIN); model.addAttribute("dataManagementEnabled", dataManagementEnabled); - getPlacesContent(user, 0, "", model); + getPlacesContent(user, page, search, model); return "settings/places"; } @@ -75,76 +109,46 @@ public class PlacesSettingsController { // Convert to PlaceInfo objects List places = placesPage.getContent().stream() - .map(place -> new PlaceInfo( - place.getId(), - place.getName(), - place.getAddress(), - place.getType(), - place.getLatitudeCentroid(), - place.getLongitudeCentroid() - )) + .map(PlacesSettingsController::convertToPlaceInfo) .collect(Collectors.toList()); - // Add pagination info to model model.addAttribute("currentPage", placesPage.getNumber()); model.addAttribute("totalPages", placesPage.getTotalPages()); model.addAttribute("places", places); model.addAttribute("isEmpty", places.isEmpty()); model.addAttribute("placeTypes", SignificantPlace.PlaceType.values()); model.addAttribute("search", search); + model.addAttribute("returnUrl", "/settings/places?search=" + search + "&page=" + page); return "settings/places :: places-content"; } - @GetMapping("/{placeId}/edit") - public String editPlace(@PathVariable Long placeId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "") String search, - Authentication authentication, - Model model) { + @PostMapping("/{placeId}/check-update") + @ResponseBody + public CheckUpdateResponse checkUpdate(@PathVariable Long placeId, + @RequestParam(required = false) String polygonData, + Authentication authentication) { User user = (User) authentication.getPrincipal(); if (!this.placeJdbcService.exists(user, placeId)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN); } - try { - SignificantPlace place = placeJdbcService.findById(placeId).orElseThrow(); - - // Convert to PlaceInfo for the template - PlaceInfo placeInfo = new PlaceInfo( - place.getId(), - place.getName(), - place.getAddress(), - place.getType(), - place.getLatitudeCentroid(), - place.getLongitudeCentroid() - ); - - // Get visit statistics for this place - var visitStats = placeService.getVisitStatisticsForPlace(user, placeId); - - model.addAttribute("place", placeInfo); - model.addAttribute("currentPage", page); - model.addAttribute("search", search); - model.addAttribute("placeTypes", SignificantPlace.PlaceType.values()); - model.addAttribute("visitStats", visitStats); - - } catch (Exception e) { - model.addAttribute("errorMessage", getMessage("message.error.place.update", e.getMessage())); - return getPlacesContent(user, page, search, model); - } - - return "fragments/places :: edit-place-content"; + PlaceChangeDetectionService.PlaceChangeAnalysis analysis = + placeChangeDetectionService.analyzeChanges(user, placeId, polygonData); + + return new CheckUpdateResponse(analysis.isCanProceed(), analysis.getWarnings()); } @PostMapping("/{placeId}/update") public String updatePlace(@PathVariable Long placeId, @RequestParam String name, @RequestParam(required = false) String address, + @RequestParam(required = false) String city, + @RequestParam(required = false) String countryCode, @RequestParam(required = false) String type, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "") String search, + @RequestParam(required = false) String polygonData, + @RequestParam(required = false) String returnUrl, Authentication authentication, Model model) { @@ -153,27 +157,67 @@ public class PlacesSettingsController { try { SignificantPlace significantPlace = placeJdbcService.findById(placeId).orElseThrow(); SignificantPlace updatedPlace = significantPlace.withName(name); + if (address != null) { updatedPlace = updatedPlace.withAddress(address.trim().isEmpty() ? null : address.trim()); } + + if (city != null) { + updatedPlace = updatedPlace.withCity(city.trim().isEmpty() ? null : city.trim()); + } + + if (countryCode != null) { + updatedPlace = updatedPlace.withCountryCode(countryCode.trim().isEmpty() ? null : countryCode.trim()); + } if (type != null && !type.isEmpty()) { try { SignificantPlace.PlaceType placeType = SignificantPlace.PlaceType.valueOf(type); updatedPlace = updatedPlace.withType(placeType); } catch (IllegalArgumentException e) { - model.addAttribute("errorMessage", getMessage("message.error.place.update", "Invalid place type")); - return editPlace(placeId, page, search, authentication, model); + model.addAttribute("errorMessage", i18nService.translate("message.error.place.update", "Invalid place type")); + return editPolygon(placeId, returnUrl, authentication, model); } } - placeJdbcService.update(updatedPlace); + // Parse polygon data if provided + if (polygonData != null && !polygonData.trim().isEmpty()) { + try { + List polygon = parsePolygonData(polygonData); + updatedPlace = updatedPlace.withPolygon(polygon); + + // Calculate and update the centroid + GeoPoint centroid = GeoUtils.calculatePolygonCentroid(polygon); + updatedPlace = updatedPlace.withLatitudeCentroid(centroid.latitude()) + .withLongitudeCentroid(centroid.longitude()); + } catch (Exception e) { + model.addAttribute("errorMessage", i18nService.translate("message.error.place.update", "Invalid polygon data: " + e.getMessage())); + return editPolygon(placeId, returnUrl, authentication, model); + } + } else { + updatedPlace = updatedPlace.withPolygon(null); + } + + + + if (!this.placeChangeDetectionService.analyzeChanges(user, placeId, polygonData).isCanProceed()) { + placeJdbcService.update(updatedPlace); + log.info("Significant change detected for place [{}]. Will issue a recalculation of all affected dates", significantPlace); + + List placesToRemove = placeJdbcService.findPlacesOverlappingWithPolygon(user.getId(), placeId, updatedPlace.getPolygon()); + List placesToCheck = new ArrayList<>(placesToRemove); + placesToCheck.add(updatedPlace); + List affectedDays = this.processedVisitJdbcService.getAffectedDays(placesToCheck); + this.dataCleanupService.cleanupForGeometryChange(user, placesToRemove, affectedDays); + } else { + placeJdbcService.update(updatedPlace); + } significantPlaceOverrideJdbcService.insertOverride(user, updatedPlace); - model.addAttribute("successMessage", getMessage("message.success.place.updated")); - return editPlace(placeId, page, search, authentication, model); + + return "redirect:" + returnUrl; } catch (Exception e) { - model.addAttribute("errorMessage", getMessage("message.error.place.update", e.getMessage())); - return editPlace(placeId, page, search, authentication, model); + model.addAttribute("errorMessage", i18nService.translate("message.error.place.update", e.getMessage())); + return editPolygon(placeId, returnUrl, authentication, model); } } else { throw new ResponseStatusException(HttpStatus.FORBIDDEN); @@ -182,10 +226,11 @@ public class PlacesSettingsController { @PostMapping("/{placeId}/geocode") public String geocodePlace(@PathVariable Long placeId, + @RequestParam(required = false) String returnUrl, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "") String search, Authentication authentication, - Model model) { + RedirectAttributes redirectAttributes) { User user = (User) authentication.getPrincipal(); if (this.placeJdbcService.exists(user, placeId)) { @@ -207,15 +252,17 @@ public class PlacesSettingsController { ); rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.SIGNIFICANT_PLACE_ROUTING_KEY, event); - model.addAttribute("successMessage", getMessage("places.geocode.success")); + redirectAttributes.addFlashAttribute("successMessage", i18nService.translate("places.geocode.success", new Object[]{})); } catch (Exception e) { - model.addAttribute("errorMessage", getMessage("places.geocode.error", e.getMessage())); + redirectAttributes.addFlashAttribute("errorMessage", i18nService.translate("places.geocode.error", e.getMessage())); } } else { throw new ResponseStatusException(HttpStatus.FORBIDDEN); } - return getPlacesContent(user, page, search, model); + // Redirect to returnUrl if provided, otherwise to places list + String redirectUrl = returnUrl != null ? returnUrl : "/settings/places?page=" + page + "&search=" + search; + return "redirect:" + redirectUrl; } @@ -235,15 +282,7 @@ public class PlacesSettingsController { try { SignificantPlace place = placeJdbcService.findById(placeId).orElseThrow(); - // Convert to PlaceInfo for the template - PlaceInfo placeInfo = new PlaceInfo( - place.getId(), - place.getName(), - place.getAddress(), - place.getType(), - place.getLatitudeCentroid(), - place.getLongitudeCentroid() - ); + PlaceInfo placeInfo = convertToPlaceInfo(place); // Get all geocoding responses for this place List geocodingResponses = geocodingResponseJdbcService.findBySignificantPlace(place); @@ -255,15 +294,103 @@ public class PlacesSettingsController { model.addAttribute("geocodingResponses", geocodingResponses); } catch (Exception e) { - model.addAttribute("errorMessage", getMessage("message.error.place.update", e.getMessage())); + model.addAttribute("errorMessage", i18nService.translate("message.error.place.update", e.getMessage())); return getPlacesContent(user, page, search, model); } return "fragments/places :: geocoding-response-content"; } + @GetMapping("/{placeId}/edit") + public String editPolygon(@PathVariable Long placeId, + @RequestParam(required = false) String returnUrl, + Authentication authentication, + Model model) { - private String getMessage(String key, Object... args) { - return messageSource.getMessage(key, args, LocaleContextHolder.getLocale()); + User user = (User) authentication.getPrincipal(); + if (!this.placeJdbcService.exists(user, placeId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + + try { + SignificantPlace place = placeJdbcService.findById(placeId).orElseThrow(); + + PlaceInfo placeInfo = convertToPlaceInfo(place); + + model.addAttribute("place", placeInfo); + model.addAttribute("placeTypes", SignificantPlace.PlaceType.values()); + + model.addAttribute("returnUrl", returnUrl); + + Point point = geometryFactory.createPoint(new Coordinate(place.getLongitudeCentroid(), place.getLatitudeCentroid())); + + List nearbyPlaces = this.placeJdbcService.findNearbyPlaces(user.getId(), point, 0.019).stream().map(PlacesSettingsController::convertToPlaceInfo).toList(); + model.addAttribute("availableCountries", AvailableCountry.values()); + model.addAttribute("nearbyPlaces", nearbyPlaces); + + } catch (Exception e) { + model.addAttribute("errorMessage", i18nService.translate("message.error.place.update", e.getMessage())); + return "redirect:/settings/places"; + } + + return "settings/edit-place"; } + + private static PlaceInfo convertToPlaceInfo(SignificantPlace place) { + return new PlaceInfo( + place.getId(), + place.getName(), + place.getAddress(), + place.getCity(), + place.getCountryCode(), + place.getLatitudeCentroid(), + place.getLongitudeCentroid(), + place.getType(), + place.getPolygon() + ); + } + + private List parsePolygonData(String polygonData) throws Exception { + JsonNode jsonNode = objectMapper.readTree(polygonData); + List geoPoints = new ArrayList<>(); + + if (jsonNode.isArray()) { + for (JsonNode pointNode : jsonNode) { + if (pointNode.has("lat") && pointNode.has("lng")) { + double lat = pointNode.get("lat").asDouble(); + double lng = pointNode.get("lng").asDouble(); + geoPoints.add(new GeoPoint(lat, lng)); + } else { + throw new IllegalArgumentException("Each point must have 'lat' and 'lng' properties"); + } + } + } else { + throw new IllegalArgumentException("Polygon data must be an array of coordinate objects"); + } + + if (geoPoints.size() < 3) { + throw new IllegalArgumentException("Polygon must have at least 3 points"); + } + + return geoPoints; + } + + public static class CheckUpdateResponse { + private final boolean canProceed; + private final List warnings; + + public CheckUpdateResponse(boolean canProceed, List warnings) { + this.canProceed = canProceed; + this.warnings = warnings; + } + + public boolean isCanProceed() { + return canProceed; + } + + public List getWarnings() { + return warnings; + } + } + } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsVisitSensitivityController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsVisitSensitivityController.java index de0f2dd5..174c10d4 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsVisitSensitivityController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsVisitSensitivityController.java @@ -37,7 +37,6 @@ public class SettingsVisitSensitivityController { private final ProcessingPipelineTrigger processingPipelineTrigger; private final TripJdbcService tripJdbcService; private final ProcessedVisitJdbcService processedVisitJdbcService; - private final VisitJdbcService visitJdbcService; private final MessageSource messageSource; private final boolean dataManagementEnabled; private final RawLocationPointJdbcService rawLocationPointJdbcService; @@ -48,7 +47,6 @@ public class SettingsVisitSensitivityController { ProcessingPipelineTrigger processingPipelineTrigger, TripJdbcService tripJdbcService, ProcessedVisitJdbcService processedVisitJdbcService, - VisitJdbcService visitJdbcService, MessageSource messageSource, @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, RawLocationPointJdbcService rawLocationPointJdbcService, SignificantPlaceJdbcService significantPlaceJdbcService) { this.configurationService = configurationService; @@ -56,7 +54,6 @@ public class SettingsVisitSensitivityController { this.processingPipelineTrigger = processingPipelineTrigger; this.tripJdbcService = tripJdbcService; this.processedVisitJdbcService = processedVisitJdbcService; - this.visitJdbcService = visitJdbcService; this.messageSource = messageSource; this.dataManagementEnabled = dataManagementEnabled; this.rawLocationPointJdbcService = rawLocationPointJdbcService; @@ -271,7 +268,6 @@ public class SettingsVisitSensitivityController { try { tripJdbcService.deleteAllForUser(user); processedVisitJdbcService.deleteAllForUser(user); - visitJdbcService.deleteAllForUser(user); significantPlaceJdbcService.deleteForUser(user); rawLocationPointJdbcService.markAllAsUnprocessedForUser(user); allConfigurationsForUser.forEach(config -> this.configurationService.updateConfiguration(config.withRecalculationState(RecalculationState.DONE))); diff --git a/src/main/java/com/dedicatedcode/reitti/dto/PlaceInfo.java b/src/main/java/com/dedicatedcode/reitti/dto/PlaceInfo.java index c99983c3..32c760f3 100644 --- a/src/main/java/com/dedicatedcode/reitti/dto/PlaceInfo.java +++ b/src/main/java/com/dedicatedcode/reitti/dto/PlaceInfo.java @@ -1,6 +1,9 @@ package com.dedicatedcode.reitti.dto; +import com.dedicatedcode.reitti.model.geo.GeoPoint; import com.dedicatedcode.reitti.model.geo.SignificantPlace; -public record PlaceInfo(Long id, String name, String address, SignificantPlace.PlaceType type, Double latitude, Double longitude) { +import java.util.List; + +public record PlaceInfo(Long id, String name, String address, String city, String countryCode, Double lat, Double lng, SignificantPlace.PlaceType type, List polygon) { } diff --git a/src/main/java/com/dedicatedcode/reitti/dto/ProcessedVisitResponse.java b/src/main/java/com/dedicatedcode/reitti/dto/ProcessedVisitResponse.java index 3f371b0e..a87382b3 100644 --- a/src/main/java/com/dedicatedcode/reitti/dto/ProcessedVisitResponse.java +++ b/src/main/java/com/dedicatedcode/reitti/dto/ProcessedVisitResponse.java @@ -4,60 +4,6 @@ import java.util.List; public class ProcessedVisitResponse { - public static class PlaceInfo { - private final Long id; - private final String name; - private final String address; - private final String city; - private final String countryCode; - private final Double lat; - private final Double lng; - private final String type; - - public PlaceInfo(Long id, String name, String address, String city, String countryCode, Double lat, Double lng, String type) { - this.id = id; - this.name = name; - this.address = address; - this.city = city; - this.countryCode = countryCode; - this.lat = lat; - this.lng = lng; - this.type = type; - } - - public Long getId() { - return id; - } - - public String getName() { - return name; - } - - public String getAddress() { - return address; - } - - public String getCity() { - return city; - } - - public String getCountryCode() { - return countryCode; - } - - public Double getLat() { - return lat; - } - - public Double getLng() { - return lng; - } - - public String getType() { - return type; - } - } - public static class PlaceVisitSummary { private final PlaceInfo place; private final List visits; @@ -73,8 +19,8 @@ public class ProcessedVisitResponse { this.totalDurationMs = totalDurationMs; this.visitCount = visitCount; this.color = color; - this.lat = place.getLat(); - this.lng = place.getLng(); + this.lat = place.lat(); + this.lng = place.lng(); } public PlaceInfo getPlace() { diff --git a/src/main/java/com/dedicatedcode/reitti/model/AvailableCountry.java b/src/main/java/com/dedicatedcode/reitti/model/AvailableCountry.java new file mode 100644 index 00000000..82432b2b --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/model/AvailableCountry.java @@ -0,0 +1,263 @@ +package com.dedicatedcode.reitti.model; + +public enum AvailableCountry { + AF("country.af.label"), + AL("country.al.label"), + DZ("country.dz.label"), + AS("country.as.label"), + AD("country.ad.label"), + AO("country.ao.label"), + AI("country.ai.label"), + AQ("country.aq.label"), + AG("country.ag.label"), + AR("country.ar.label"), + AM("country.am.label"), + AW("country.aw.label"), + AU("country.au.label"), + AT("country.at.label"), + AZ("country.az.label"), + BS("country.bs.label"), + BH("country.bh.label"), + BD("country.bd.label"), + BB("country.bb.label"), + BY("country.by.label"), + BE("country.be.label"), + BZ("country.bz.label"), + BJ("country.bj.label"), + BM("country.bm.label"), + BT("country.bt.label"), + BO("country.bo.label"), + BA("country.ba.label"), + BW("country.bw.label"), + BV("country.bv.label"), + BR("country.br.label"), + IO("country.io.label"), + BN("country.bn.label"), + BG("country.bg.label"), + BF("country.bf.label"), + BI("country.bi.label"), + KH("country.kh.label"), + CM("country.cm.label"), + CA("country.ca.label"), + CV("country.cv.label"), + KY("country.ky.label"), + CF("country.cf.label"), + TD("country.td.label"), + CL("country.cl.label"), + CN("country.cn.label"), + CX("country.cx.label"), + CC("country.cc.label"), + CO("country.co.label"), + KM("country.km.label"), + CG("country.cg.label"), + CD("country.cd.label"), + CK("country.ck.label"), + CR("country.cr.label"), + CI("country.ci.label"), + HR("country.hr.label"), + CU("country.cu.label"), + CY("country.cy.label"), + CZ("country.cz.label"), + DK("country.dk.label"), + DJ("country.dj.label"), + DM("country.dm.label"), + DO("country.do.label"), + EC("country.ec.label"), + EG("country.eg.label"), + SV("country.sv.label"), + GQ("country.gq.label"), + ER("country.er.label"), + EE("country.ee.label"), + ET("country.et.label"), + FK("country.fk.label"), + FO("country.fo.label"), + FJ("country.fj.label"), + FI("country.fi.label"), + FR("country.fr.label"), + GF("country.gf.label"), + PF("country.pf.label"), + TF("country.tf.label"), + GA("country.ga.label"), + GM("country.gm.label"), + GE("country.ge.label"), + DE("country.de.label"), + GH("country.gh.label"), + GI("country.gi.label"), + GR("country.gr.label"), + GL("country.gl.label"), + GD("country.gd.label"), + GP("country.gp.label"), + GU("country.gu.label"), + GT("country.gt.label"), + GG("country.gg.label"), + GN("country.gn.label"), + GW("country.gw.label"), + GY("country.gy.label"), + HT("country.ht.label"), + HM("country.hm.label"), + VA("country.va.label"), + HN("country.hn.label"), + HK("country.hk.label"), + HU("country.hu.label"), + IS("country.is.label"), + IN("country.in.label"), + ID("country.id.label"), + IR("country.ir.label"), + IQ("country.iq.label"), + IE("country.ie.label"), + IM("country.im.label"), + IL("country.il.label"), + IT("country.it.label"), + JM("country.jm.label"), + JP("country.jp.label"), + JE("country.je.label"), + JO("country.jo.label"), + KZ("country.kz.label"), + KE("country.ke.label"), + KI("country.ki.label"), + KP("country.kp.label"), + KR("country.kr.label"), + KW("country.kw.label"), + KG("country.kg.label"), + LA("country.la.label"), + LV("country.lv.label"), + LB("country.lb.label"), + LS("country.ls.label"), + LR("country.lr.label"), + LY("country.ly.label"), + LI("country.li.label"), + LT("country.lt.label"), + LU("country.lu.label"), + MO("country.mo.label"), + MK("country.mk.label"), + MG("country.mg.label"), + MW("country.mw.label"), + MY("country.my.label"), + MV("country.mv.label"), + ML("country.ml.label"), + MT("country.mt.label"), + MH("country.mh.label"), + MQ("country.mq.label"), + MR("country.mr.label"), + MU("country.mu.label"), + YT("country.yt.label"), + MX("country.mx.label"), + FM("country.fm.label"), + MD("country.md.label"), + MC("country.mc.label"), + MN("country.mn.label"), + ME("country.me.label"), + MS("country.ms.label"), + MA("country.ma.label"), + MZ("country.mz.label"), + MM("country.mm.label"), + NA("country.na.label"), + NR("country.nr.label"), + NP("country.np.label"), + NL("country.nl.label"), + AN("country.an.label"), + NC("country.nc.label"), + NZ("country.nz.label"), + NI("country.ni.label"), + NE("country.ne.label"), + NG("country.ng.label"), + NU("country.nu.label"), + NF("country.nf.label"), + MP("country.mp.label"), + NO("country.no.label"), + OM("country.om.label"), + PK("country.pk.label"), + PW("country.pw.label"), + PS("country.ps.label"), + PA("country.pa.label"), + PG("country.pg.label"), + PY("country.py.label"), + PE("country.pe.label"), + PH("country.ph.label"), + PN("country.pn.label"), + PL("country.pl.label"), + PT("country.pt.label"), + PR("country.pr.label"), + QA("country.qa.label"), + RE("country.re.label"), + RO("country.ro.label"), + RU("country.ru.label"), + RW("country.rw.label"), + BL("country.bl.label"), + SH("country.sh.label"), + KN("country.kn.label"), + LC("country.lc.label"), + MF("country.mf.label"), + PM("country.pm.label"), + VC("country.vc.label"), + WS("country.ws.label"), + SM("country.sm.label"), + ST("country.st.label"), + SA("country.sa.label"), + SN("country.sn.label"), + RS("country.rs.label"), + SC("country.sc.label"), + SL("country.sl.label"), + SG("country.sg.label"), + SK("country.sk.label"), + SI("country.si.label"), + SB("country.sb.label"), + SO("country.so.label"), + ZA("country.za.label"), + GS("country.gs.label"), + ES("country.es.label"), + LK("country.lk.label"), + SD("country.sd.label"), + SR("country.sr.label"), + SJ("country.sj.label"), + SZ("country.sz.label"), + SE("country.se.label"), + CH("country.ch.label"), + SY("country.sy.label"), + TW("country.tw.label"), + TJ("country.tj.label"), + TZ("country.tz.label"), + TH("country.th.label"), + TL("country.tl.label"), + TG("country.tg.label"), + TK("country.tk.label"), + TO("country.to.label"), + TT("country.tt.label"), + TN("country.tn.label"), + TR("country.tr.label"), + TM("country.tm.label"), + TC("country.tc.label"), + TV("country.tv.label"), + UG("country.ug.label"), + UA("country.ua.label"), + AE("country.ae.label"), + GB("country.gb.label"), + US("country.us.label"), + UM("country.um.label"), + UY("country.uy.label"), + UZ("country.uz.label"), + VU("country.vu.label"), + VE("country.ve.label"), + VN("country.vn.label"), + VG("country.vg.label"), + VI("country.vi.label"), + WF("country.wf.label"), + EH("country.eh.label"), + YE("country.ye.label"), + ZM("country.zm.label"), + ZW("country.zw.label"); + + private final String messageKey; + + AvailableCountry(String messageKey) { + this.messageKey = messageKey; + } + + public String getMessageKey() { + return messageKey; + } + + public String getCode() { + return this.name().toLowerCase(); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/model/geo/GeoPoint.java b/src/main/java/com/dedicatedcode/reitti/model/geo/GeoPoint.java index 4e1890cc..9e0d7cc2 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/geo/GeoPoint.java +++ b/src/main/java/com/dedicatedcode/reitti/model/geo/GeoPoint.java @@ -2,7 +2,9 @@ package com.dedicatedcode.reitti.model.geo; import org.locationtech.jts.geom.Point; -public record GeoPoint(double latitude, double longitude) { +import java.io.Serializable; + +public record GeoPoint(double latitude, double longitude) implements Serializable { public static GeoPoint from(Point point) { return new GeoPoint(point.getY(), point.getX()); } diff --git a/src/main/java/com/dedicatedcode/reitti/model/geo/GeoUtils.java b/src/main/java/com/dedicatedcode/reitti/model/geo/GeoUtils.java index d53f6928..a82fc907 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/geo/GeoUtils.java +++ b/src/main/java/com/dedicatedcode/reitti/model/geo/GeoUtils.java @@ -63,8 +63,8 @@ public final class GeoUtils { /** * Converts a distance in meters to degrees of latitude and longitude at a given position. * The conversion varies based on the latitude because longitude degrees get closer together as you move away from the equator. - * - * @param meters The distance in meters to convert + * + * @param meters The distance in meters to convert * @param latitude The latitude at which to calculate the conversion * @return An array where index 0 is the latitude degrees and index 1 is the longitude degrees */ @@ -91,4 +91,86 @@ public final class GeoUtils { return totalDistance; } + + public static GeoPoint calculatePolygonCentroid(List polygon) { + if (polygon == null || polygon.isEmpty()) { + throw new IllegalArgumentException("Polygon cannot be null or empty"); + } + + // Remove duplicate points (especially the closing point that duplicates the first point) + List uniquePoints = removeDuplicates(polygon); + + // Calculate centroid as the arithmetic mean of unique vertices + double avgLat = uniquePoints.stream().mapToDouble(GeoPoint::latitude).average().orElse(0.0); + double avgLng = uniquePoints.stream().mapToDouble(GeoPoint::longitude).average().orElse(0.0); + + return new GeoPoint(avgLat, avgLng); + } + + /** + * Calculates the area of a polygon defined by a list of {@link GeoPoint}s. + *

+ * The calculation uses a planar approximation (Shoelace formula) after converting + * latitude/longitude to meters using an equirectangular projection. This is sufficient + * for relatively small polygons (city‑scale or smaller) where the curvature of the + * Earth can be ignored. + * + * @param polygon list of points defining the polygon. The points may be closed + * (first point repeated as last) – duplicates are ignored. + * @return area in square meters + * @throws IllegalArgumentException if the polygon is {@code null} or has fewer than + * three distinct points + */ + public static double calculatePolygonArea(List polygon) { + if (polygon == null) { + throw new IllegalArgumentException("Polygon cannot be null"); + } + + // Remove duplicate points (including possible closing point) + + List uniquePoints = removeDuplicates(polygon); + + if (uniquePoints.size() < 3) { + throw new IllegalArgumentException("Polygon must contain at least three distinct points"); + } + + // Convert each point to a Cartesian coordinate system in meters. + // Latitude: approx 111,320 meters per degree. + // Longitude: 111,320 * cos(latitude) meters per degree. + double[] xs = new double[uniquePoints.size()]; + double[] ys = new double[uniquePoints.size()]; + + for (int i = 0; i < uniquePoints.size(); i++) { + GeoPoint p = uniquePoints.get(i); + double latMeters = p.latitude() * 111320.0; + double lonMeters = p.longitude() * 111320.0 * Math.cos(Math.toRadians(p.latitude())); + xs[i] = lonMeters; + ys[i] = latMeters; + } + + // Shoelace formula + double sum = 0.0; + int n = uniquePoints.size(); + for (int i = 0; i < n; i++) { + int j = (i + 1) % n; + sum += xs[i] * ys[j] - xs[j] * ys[i]; + } + + return Math.abs(sum) / 2.0; + } + + private static List removeDuplicates(List polygon) { + List uniquePoints = new ArrayList<>(); + for (GeoPoint point : polygon) { + boolean isDuplicate = uniquePoints.stream().anyMatch(existing -> + Math.abs(existing.latitude() - point.latitude()) < 0.000001 && + Math.abs(existing.longitude() - point.longitude()) < 0.000001 + ); + if (!isDuplicate) { + uniquePoints.add(point); + } + } + return uniquePoints; + } + } diff --git a/src/main/java/com/dedicatedcode/reitti/model/geo/SignificantPlace.java b/src/main/java/com/dedicatedcode/reitti/model/geo/SignificantPlace.java index 3ca8ec4a..3597c653 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/geo/SignificantPlace.java +++ b/src/main/java/com/dedicatedcode/reitti/model/geo/SignificantPlace.java @@ -2,6 +2,7 @@ package com.dedicatedcode.reitti.model.geo; import java.io.Serializable; import java.time.ZoneId; +import java.util.List; import java.util.Objects; public class SignificantPlace implements Serializable { @@ -13,13 +14,14 @@ public class SignificantPlace implements Serializable { private final String countryCode; private final Double latitudeCentroid; private final Double longitudeCentroid; + private final List polygon; private final PlaceType type; private final ZoneId timezone; private final boolean geocoded; private final Long version; public static SignificantPlace create(Double latitude, Double longitude) { - return new SignificantPlace(null, null, null, null,null, latitude, longitude, PlaceType.OTHER, ZoneId.systemDefault(), false, 1L); + return new SignificantPlace(null, null, null, null, null, latitude, longitude, null, PlaceType.OTHER, ZoneId.systemDefault(), false, 1L); } public SignificantPlace(Long id, @@ -28,7 +30,7 @@ public class SignificantPlace implements Serializable { String city, String countryCode, Double latitudeCentroid, - Double longitudeCentroid, + Double longitudeCentroid, List polygon, PlaceType type, ZoneId timezone, boolean geocoded, Long version) { @@ -39,6 +41,7 @@ public class SignificantPlace implements Serializable { this.countryCode = countryCode; this.latitudeCentroid = latitudeCentroid; this.longitudeCentroid = longitudeCentroid; + this.polygon = polygon; this.type = type; this.timezone = timezone; this.geocoded = geocoded; @@ -69,6 +72,10 @@ public class SignificantPlace implements Serializable { return longitudeCentroid; } + public List getPolygon() { + return polygon; + } + public PlaceType getType() { return type; } @@ -87,35 +94,47 @@ public class SignificantPlace implements Serializable { // Wither methods public SignificantPlace withGeocoded(boolean geocoded) { - return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, geocoded, this.version); + return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, this.type, timezone, geocoded, this.version); } public SignificantPlace withName(String name) { - return new SignificantPlace(this.id, name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version); + return new SignificantPlace(this.id, name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, this.type, timezone, this.geocoded, this.version); } public SignificantPlace withAddress(String address) { - return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version); + return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, this.type, timezone, this.geocoded, this.version); } public SignificantPlace withCountryCode(String countryCode) { - return new SignificantPlace(this.id, this.name, this.address, city, countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version); + return new SignificantPlace(this.id, this.name, this.address, city, countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, this.type, timezone, this.geocoded, this.version); } public SignificantPlace withType(PlaceType type) { - return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, type, timezone, this.geocoded, this.version); + return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, type, timezone, this.geocoded, this.version); } public SignificantPlace withId(Long id) { - return new SignificantPlace(id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version); + return new SignificantPlace(id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, this.type, timezone, this.geocoded, this.version); } public SignificantPlace withTimezone(ZoneId timezone) { - return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version); + return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, this.type, timezone, this.geocoded, this.version); } public SignificantPlace withCity(String city) { - return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version); + return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, this.type, timezone, this.geocoded, this.version); + } + + public SignificantPlace withPolygon(List polygon) { + return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, polygon, this.type, timezone, this.geocoded, this.version); + } + + public SignificantPlace withLatitudeCentroid(Double latitudeCentroid) { + return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, latitudeCentroid, this.longitudeCentroid, this.polygon, this.type, timezone, this.geocoded, this.version); + } + + public SignificantPlace withLongitudeCentroid(Double longitudeCentroid) { + return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, this.latitudeCentroid, longitudeCentroid, this.polygon, this.type, timezone, this.geocoded, this.version); } @Override diff --git a/src/main/java/com/dedicatedcode/reitti/repository/PointReaderWriter.java b/src/main/java/com/dedicatedcode/reitti/repository/PointReaderWriter.java index a34a46e2..4d2d6f32 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/PointReaderWriter.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/PointReaderWriter.java @@ -8,6 +8,9 @@ import org.locationtech.jts.io.ParseException; import org.locationtech.jts.io.WKTReader; import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.List; + @Component public class PointReaderWriter { @@ -52,4 +55,51 @@ public class PointReaderWriter { public String write(GeoPoint point) { return write(point.longitude(), point.latitude()); } + + public String polygonToWkt(List polygon) { + if (polygon == null || polygon.isEmpty()) { + return null; + } + + StringBuilder wkt = new StringBuilder("POLYGON(("); + for (int i = 0; i < polygon.size(); i++) { + GeoPoint point = polygon.get(i); + wkt.append(point.longitude()).append(" ").append(point.latitude()); + if (i < polygon.size() - 1) { + wkt.append(", "); + } + } + + // Close the polygon by adding the first point again if not already closed + GeoPoint first = polygon.getFirst(); + GeoPoint last = polygon.getLast(); + if (!first.equals(last)) { + wkt.append(", ").append(first.longitude()).append(" ").append(first.latitude()); + } + + wkt.append("))"); + return wkt.toString(); + } + + public List wktToPolygon(String wkt) { + if (wkt == null || wkt.trim().isEmpty()) { + return null; + } + + // Parse WKT format: POLYGON((lon1 lat1, lon2 lat2, ...)) + String coordinates = wkt.substring(wkt.indexOf("((") + 2, wkt.lastIndexOf("))")); + String[] points = coordinates.split(","); + + List polygon = new ArrayList<>(); + for (String point : points) { + String[] coords = point.trim().split("\\s+"); + if (coords.length >= 2) { + double longitude = Double.parseDouble(coords[0]); + double latitude = Double.parseDouble(coords[1]); + polygon.add(GeoPoint.from(latitude, longitude)); + } + } + + return polygon.isEmpty() ? null : polygon; + } } diff --git a/src/main/java/com/dedicatedcode/reitti/repository/PreviewSignificantPlaceJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/PreviewSignificantPlaceJdbcService.java index 1bddf3fd..39522b43 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/PreviewSignificantPlaceJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/PreviewSignificantPlaceJdbcService.java @@ -1,12 +1,8 @@ package com.dedicatedcode.reitti.repository; -import com.dedicatedcode.reitti.model.Page; -import com.dedicatedcode.reitti.model.PageRequest; import com.dedicatedcode.reitti.model.geo.SignificantPlace; import com.dedicatedcode.reitti.model.security.User; import org.locationtech.jts.geom.Point; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Service; @@ -22,72 +18,113 @@ public class PreviewSignificantPlaceJdbcService { private final JdbcTemplate jdbcTemplate; private final PointReaderWriter pointReaderWriter; + private final RowMapper significantPlaceRowMapper; public PreviewSignificantPlaceJdbcService(JdbcTemplate jdbcTemplate, PointReaderWriter pointReaderWriter) { this.jdbcTemplate = jdbcTemplate; this.pointReaderWriter = pointReaderWriter; + significantPlaceRowMapper = (rs, _) -> new SignificantPlace( + rs.getLong("id"), + rs.getString("name"), + rs.getString("address"), + rs.getString("city"), + rs.getString("country_code"), + rs.getDouble("latitude_centroid"), + rs.getDouble("longitude_centroid"), + this.pointReaderWriter.wktToPolygon(rs.getString("polygon")), + SignificantPlace.PlaceType.valueOf(rs.getString("type")), + rs.getString("timezone") != null ? ZoneId.of(rs.getString("timezone")) : null, + rs.getBoolean("geocoded"), + rs.getLong("version")); } - private final RowMapper significantPlaceRowMapper = (rs, _) -> new SignificantPlace( - rs.getLong("id"), - rs.getString("name"), - rs.getString("address"), - rs.getString("city"), - rs.getString("country_code"), - rs.getDouble("latitude_centroid"), - rs.getDouble("longitude_centroid"), - SignificantPlace.PlaceType.valueOf(rs.getString("type")), - rs.getString("timezone") != null ? ZoneId.of(rs.getString("timezone")) : null, - rs.getBoolean("geocoded"), - rs.getLong("version")); public List findNearbyPlaces(Long userId, Point point, double distanceInMeters, String previewId) { - String sql = "SELECT sp.id, sp.address, sp.country_code, sp.city, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " + - "FROM preview_significant_places sp " + - "WHERE sp.user_id = ? AND preview_id = ? " + - "AND ST_DWithin(sp.geom, ST_GeomFromText(?, '4326'), ?)"; + String sql = """ + SELECT sp.id, sp.address, sp.country_code, sp.city, sp.type, + sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, + ST_AsText(sp.geom) as geom, ST_AsText(sp.polygon) as polygon, + sp.timezone, sp.geocoded, sp.version + FROM preview_significant_places sp + WHERE sp.user_id = ? + AND ST_DWithin( + COALESCE(sp.polygon, ST_Buffer(sp.geom, ?)), + ST_GeomFromText(?, '4326'), + 0 + ) + """; + return jdbcTemplate.query(sql, significantPlaceRowMapper, - userId, previewId, point.toString(), distanceInMeters); + userId, distanceInMeters, point.toString()); } public SignificantPlace create(User user, String previewId, SignificantPlace place) { - String sql = "INSERT INTO preview_significant_places (user_id, preview_id, name, latitude_centroid, longitude_centroid, timezone, geom) " + - "VALUES (?, ?, ?, ?, ?, ?, ST_GeomFromText(?, '4326')) RETURNING id"; + String sql = "INSERT INTO preview_significant_places (user_id, preview_id, name, latitude_centroid, longitude_centroid, timezone, geom, polygon) " + + "VALUES (?, ?, ?, ?, ?, ?, ST_GeomFromText(?, '4326'), " + + "CASE WHEN ?::text IS NOT NULL THEN ST_GeomFromText(?, '4326') END) RETURNING id"; + + String polygonWkt = this.pointReaderWriter.polygonToWkt(place.getPolygon()); + Long id = jdbcTemplate.queryForObject(sql, Long.class, - user.getId(), - previewId, - place.getName(), - place.getLatitudeCentroid(), - place.getLongitudeCentroid(), - place.getTimezone().getId(), - this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid()) + user.getId(), + previewId, + place.getName(), + place.getLatitudeCentroid(), + place.getLongitudeCentroid(), + place.getTimezone().getId(), + this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid()), + polygonWkt, + polygonWkt ); return findById(id).orElseThrow(); } public Optional findById(Long id) { - String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " + - "FROM preview_significant_places sp " + - "WHERE sp.id = ?"; + String sql = """ + SELECT sp.id, + sp.address, + sp.city, + sp.country_code, + sp.type, + sp.latitude_centroid, + sp.longitude_centroid, + sp.name, + sp.user_id, + ST_AsText(sp.geom) as geom, + ST_AsText(sp.polygon) as polygon, + sp.timezone, + sp.geocoded, + sp.version + FROM preview_significant_places sp + WHERE sp.id = ? + """; List results = jdbcTemplate.query(sql, significantPlaceRowMapper, id); return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst()); } public SignificantPlace update(SignificantPlace place) { - String sql = "UPDATE preview_significant_places SET name = ?, address = ?, city = ?, country_code = ?, type = ?, latitude_centroid = ?, longitude_centroid = ?, geom = ST_GeomFromText(?, '4326'), timezone = ?, geocoded = ? WHERE id = ?"; + String sql = "UPDATE preview_significant_places SET name = ?, address = ?, city = ?, country_code = ?, type = ?, " + + "latitude_centroid = ?, longitude_centroid = ?, geom = ST_GeomFromText(?, '4326'), " + + "polygon = CASE WHEN ?::text IS NOT NULL THEN ST_GeomFromText(?, '4326') ELSE NULL END, " + + "timezone = ?, geocoded = ? WHERE id = ?"; + + String polygonWkt = this.pointReaderWriter.polygonToWkt(place.getPolygon()); + jdbcTemplate.update(sql, - place.getName(), - place.getAddress(), - place.getCity(), - place.getCountryCode(), - place.getType().name(), - place.getLatitudeCentroid(), - place.getLongitudeCentroid(), - this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid()), - place.getTimezone() != null ? place.getTimezone().getId() : null, - place.isGeocoded(), - place.getId() + place.getName(), + place.getAddress(), + place.getCity(), + place.getCountryCode(), + place.getType().name(), + place.getLatitudeCentroid(), + place.getLongitudeCentroid(), + this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid()), + polygonWkt, + polygonWkt, + place.getTimezone() != null ? place.getTimezone().getId() : null, + place.isGeocoded(), + place.getId() ); return findById(place.getId()).orElseThrow(); } diff --git a/src/main/java/com/dedicatedcode/reitti/repository/PreviewVisitJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/PreviewVisitJdbcService.java deleted file mode 100644 index 718a1a75..00000000 --- a/src/main/java/com/dedicatedcode/reitti/repository/PreviewVisitJdbcService.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.dedicatedcode.reitti.repository; - -import com.dedicatedcode.reitti.model.geo.Visit; -import com.dedicatedcode.reitti.model.security.User; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.sql.Timestamp; -import java.time.Instant; -import java.util.List; - -@Service -@Transactional -public class PreviewVisitJdbcService { - private final JdbcTemplate jdbcTemplate; - - public PreviewVisitJdbcService(JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - private static final RowMapper VISIT_ROW_MAPPER = (rs, _) -> new Visit( - rs.getLong("id"), - rs.getDouble("longitude"), - rs.getDouble("latitude"), - rs.getTimestamp("start_time").toInstant(), - rs.getTimestamp("end_time").toInstant(), - rs.getLong("duration_seconds"), - rs.getBoolean("processed"), - rs.getLong("version") - ); - - public List findByUserAndTimeAfterAndStartTimeBefore(User user, String previewId, Instant windowStart, Instant windowEnd) { - String sql = "SELECT v.* " + - "FROM preview_visits v " + - "WHERE v.user_id = ? AND v.end_time >= ? AND v.start_time <= ? AND preview_id = ? " + - "ORDER BY v.start_time"; - return jdbcTemplate.query(sql, VISIT_ROW_MAPPER, user.getId(), - Timestamp.from(windowStart), Timestamp.from(windowEnd), previewId); - } - - public void delete(List affectedVisits) throws OptimisticLockException { - if (affectedVisits == null || affectedVisits.isEmpty()) { - return; - } - String placeholders = String.join(",", affectedVisits.stream().map(_ -> "?").toList()); - String sql = "DELETE FROM preview_visits WHERE id IN (" + placeholders + ")"; - - Object[] ids = affectedVisits.stream().map(Visit::getId).toArray(); - jdbcTemplate.update(sql, ids); - } -} diff --git a/src/main/java/com/dedicatedcode/reitti/repository/ProcessedVisitJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/ProcessedVisitJdbcService.java index 290a9bb5..533f505d 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/ProcessedVisitJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/ProcessedVisitJdbcService.java @@ -13,6 +13,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; import java.time.Instant; +import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -195,4 +196,37 @@ public class ProcessedVisitJdbcService { public void deleteAllForUser(User user) { jdbcTemplate.update("DELETE FROM processed_visits WHERE user_id = ?", user.getId()); } + + public List getAffectedDays(List places) { + if (places.isEmpty()) { + return Collections.emptyList(); + } + + List placeIds = places.stream() + .map(SignificantPlace::getId) + .toList(); + + String placeholders = String.join(",", placeIds.stream().map(id -> "?").toList()); + String sql = """ + SELECT DISTINCT DATE(pv.start_time) AS affected_day + FROM processed_visits pv + WHERE pv.place_id IN (%s) + UNION + SELECT DISTINCT DATE(pv.end_time) AS affected_day + FROM processed_visits pv + WHERE pv.place_id IN (%s) + ORDER BY affected_day; + """.formatted(placeholders, placeholders); + + List params = new ArrayList<>(); + params.addAll(placeIds); + params.addAll(placeIds); + + return jdbcTemplate.query(sql, (rs, rowNum) -> rs.getDate("affected_day").toLocalDate(), params.toArray()); + } + + public void deleteFor(User user, List placesToRemove) { + Long[] idList = placesToRemove.stream().map(SignificantPlace::getId).toList().toArray(Long[]::new); + this.jdbcTemplate.update("DELETE FROM processed_visits WHERE user_id = ? AND place_id = ANY(?)", user.getId(), idList); + } } diff --git a/src/main/java/com/dedicatedcode/reitti/repository/RawLocationPointJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/RawLocationPointJdbcService.java index b6368eff..6432ef04 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/RawLocationPointJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/RawLocationPointJdbcService.java @@ -14,10 +14,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.sql.Timestamp; -import java.time.Duration; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZonedDateTime; +import java.time.*; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -452,6 +449,12 @@ public class RawLocationPointJdbcService { jdbcTemplate.update(sql, user.getId()); } + public void markAllAsUnprocessedForUser(User user, List affectedDays) { + this.jdbcTemplate.update("UPDATE raw_location_points SET processed = false WHERE user_id = ? AND date_trunc('day', timestamp) = ANY(?)", + user.getId(), + affectedDays.stream().map(d -> Timestamp.valueOf(d.atStartOfDay())).toList().toArray(new Timestamp[0])); + } + public void deleteAllForUser(User user) { String sql = "DELETE FROM raw_location_points WHERE user_id = ?"; jdbcTemplate.update(sql, user.getId()); @@ -513,7 +516,7 @@ public class RawLocationPointJdbcService { return; } - String sql = "UPDATE raw_location_points SET ignored = ? WHERE id = ?"; + String sql = "UPDATE raw_location_points SET ignored = ?, processed = true WHERE id = ?"; List batchArgs = pointIds.stream() .map(pointId -> new Object[]{ignored, pointId}) diff --git a/src/main/java/com/dedicatedcode/reitti/repository/SignificantPlaceJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/SignificantPlaceJdbcService.java index 5978b598..ff65c5d6 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/SignificantPlaceJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/SignificantPlaceJdbcService.java @@ -2,6 +2,7 @@ package com.dedicatedcode.reitti.repository; import com.dedicatedcode.reitti.model.Page; import com.dedicatedcode.reitti.model.PageRequest; +import com.dedicatedcode.reitti.model.geo.GeoPoint; import com.dedicatedcode.reitti.model.geo.SignificantPlace; import com.dedicatedcode.reitti.model.security.User; import org.locationtech.jts.geom.Point; @@ -17,39 +18,60 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +/** + * Service class for managing and accessing significant places using JDBC. + * Provides methods for CRUD operations and queries related to significant places. + * Includes support for handling geographical data and pagination. + */ @Service @Transactional public class SignificantPlaceJdbcService { private final JdbcTemplate jdbcTemplate; private final PointReaderWriter pointReaderWriter; + private final RowMapper significantPlaceRowMapper; public SignificantPlaceJdbcService(JdbcTemplate jdbcTemplate, PointReaderWriter pointReaderWriter) { this.jdbcTemplate = jdbcTemplate; this.pointReaderWriter = pointReaderWriter; + this.significantPlaceRowMapper = (rs, _) -> new SignificantPlace( + rs.getLong("id"), + rs.getString("name"), + rs.getString("address"), + rs.getString("city"), + rs.getString("country_code"), + rs.getDouble("latitude_centroid"), + rs.getDouble("longitude_centroid"), + pointReaderWriter.wktToPolygon(rs.getString("polygon")), + SignificantPlace.PlaceType.valueOf(rs.getString("type")), + rs.getString("timezone") != null ? ZoneId.of(rs.getString("timezone")) : null, + rs.getBoolean("geocoded"), + rs.getLong("version")); } - private final RowMapper significantPlaceRowMapper = (rs, _) -> new SignificantPlace( - rs.getLong("id"), - rs.getString("name"), - rs.getString("address"), - rs.getString("city"), - rs.getString("country_code"), - rs.getDouble("latitude_centroid"), - rs.getDouble("longitude_centroid"), - SignificantPlace.PlaceType.valueOf(rs.getString("type")), - rs.getString("timezone") != null ? ZoneId.of(rs.getString("timezone")) : null, - rs.getBoolean("geocoded"), - rs.getLong("version")); - public Page findByUser(User user, PageRequest pageable) { String countSql = "SELECT COUNT(*) FROM significant_places WHERE user_id = ?"; Integer total = jdbcTemplate.queryForObject(countSql, Integer.class, user.getId()); - String sql = "SELECT sp.id, sp.address, sp.country_code, sp.city, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version" + - " FROM significant_places sp " + - "WHERE sp.user_id = ? ORDER BY sp.id " + - "LIMIT ? OFFSET ? "; + String sql = """ + SELECT sp.id, + sp.address, + sp.country_code, + sp.city, + sp.type, + sp.latitude_centroid, + sp.longitude_centroid, + sp.name, + sp.user_id, + ST_AsText(sp.geom) as geom, + ST_AsText(sp.polygon) as polygon, + sp.timezone, + sp.geocoded, + sp.version + FROM significant_places sp + WHERE sp.user_id = ? ORDER BY sp.id + LIMIT ? OFFSET ? + """; List content = jdbcTemplate.query(sql, significantPlaceRowMapper, user.getId(), pageable.getPageSize(), pageable.getOffset()); @@ -67,7 +89,20 @@ public class SignificantPlaceJdbcService { } Integer total = jdbcTemplate.queryForObject(countSql, Integer.class, countParams.toArray()); - String sql = "SELECT sp.id, sp.address, sp.country_code, sp.city, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version" + + String sql = "SELECT sp.id,\n" + + " sp.address,\n" + + " sp.country_code,\n" + + " sp.city,\n" + + " sp.type,\n" + + " sp.latitude_centroid,\n" + + " sp.longitude_centroid,\n" + + " sp.name,\n" + + " sp.user_id,\n" + + " ST_AsText(sp.geom) as geom,\n" + + " ST_AsText(sp.polygon) as polygon,\n" + + " sp.timezone,\n" + + " sp.geocoded,\n" + + " sp.version" + " FROM significant_places sp " + "WHERE sp.user_id = ? " + searchCondition + " ORDER BY sp.id " + "LIMIT ? OFFSET ? "; @@ -79,53 +114,151 @@ public class SignificantPlaceJdbcService { return new Page<>(content, pageable, total != null ? total : 0); } - public List findNearbyPlaces(Long userId, Point point, double distanceInMeters) { - String sql = "SELECT sp.id, sp.address, sp.country_code, sp.city, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " + - "FROM significant_places sp " + - "WHERE sp.user_id = ? " + - "AND ST_DWithin(sp.geom, ST_GeomFromText(?, '4326'), ?)"; + /** + * Searches for SignificantPlaces that are nearby to this point. This includes places with polygons + * that are within the specified distance range of the given point, as well as places without polygons + * whose center points are within the distance range. + * + * @param userId - the user to load the places for. + * @param point - the point to search near. + * @param distanceInDegrees - distance in degrees to search within. + * @return list of nearby SignificantPlaces. + */ + public List findNearbyPlaces(Long userId, Point point, double distanceInDegrees) { + String sql = """ + SELECT sp.id, + sp.address, + sp.country_code, + sp.city, + sp.type, + sp.latitude_centroid, + sp.longitude_centroid, + sp.name, + sp.user_id, + ST_AsText(sp.geom) as geom, + ST_AsText(sp.polygon) as polygon, + sp.timezone, + sp.geocoded, + sp.version + FROM significant_places sp + WHERE sp.user_id = ? + AND ST_DWithin( + COALESCE(sp.polygon, sp.geom), + ST_GeomFromText(?, '4326'), + ? + ) + """; + return jdbcTemplate.query(sql, significantPlaceRowMapper, - userId, point.toString(), distanceInMeters); + userId, point.toString(), distanceInDegrees); + } + + /** + * Searches for SignificantPlaces which contain this point. Either by having a polygon which contains that point or + * by extending the center point by distanceInDegrees. + * + * @param userId - the user to load the places for. + * @param point - the point to search for. + * @param distanceInDegrees - meters in degrees to extend the search radius for points without a polygon. + * @return list of SignificantPlaces. + */ + public List findEnclosingPlaces(Long userId, Point point, double distanceInDegrees) { + String sql = """ + SELECT sp.id, + sp.address, + sp.country_code, + sp.city, + sp.type, + sp.latitude_centroid, + sp.longitude_centroid, + sp.name, + sp.user_id, + ST_AsText(sp.geom) as geom, + ST_AsText(sp.polygon) as polygon, + sp.timezone, + sp.geocoded, + sp.version + FROM significant_places sp + WHERE sp.user_id = ? + AND ST_DWithin( + COALESCE(sp.polygon, ST_Buffer(sp.geom, ?)), + ST_GeomFromText(?, '4326'), + 0 + ) + """; + + return jdbcTemplate.query(sql, significantPlaceRowMapper, + userId, distanceInDegrees, point.toString()); } public SignificantPlace create(User user, SignificantPlace place) { - String sql = "INSERT INTO significant_places (user_id, name, latitude_centroid, longitude_centroid, timezone, geom) " + - "VALUES (?, ?, ?, ?, ?, ST_GeomFromText(?, '4326')) RETURNING id"; + + String sql = "INSERT INTO significant_places (user_id, name, latitude_centroid, longitude_centroid, timezone, geom, polygon) " + + "VALUES (?, ?, ?, ?, ?, ST_GeomFromText(?, '4326'), " + + "CASE WHEN ?::text IS NOT NULL THEN ST_GeomFromText(?, '4326') END) RETURNING id"; + ; + + String polygonWkt = this.pointReaderWriter.polygonToWkt(place.getPolygon()); + Long id = jdbcTemplate.queryForObject(sql, Long.class, user.getId(), place.getName(), place.getLatitudeCentroid(), place.getLongitudeCentroid(), place.getTimezone().getId(), - this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid()) + this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid()), + polygonWkt, + polygonWkt ); return findById(id).orElseThrow(); } @CacheEvict(cacheNames = "significant-places", key = "#place.id") public SignificantPlace update(SignificantPlace place) { - String sql = "UPDATE significant_places SET name = ?, address = ?, city = ?, country_code = ?, type = ?, latitude_centroid = ?, longitude_centroid = ?, geom = ST_GeomFromText(?, '4326'), timezone = ?, geocoded = ? WHERE id = ?"; + String sql = "UPDATE significant_places SET name = ?, address = ?, city = ?, country_code = ?, type = ?, " + + "latitude_centroid = ?, longitude_centroid = ?, geom = ST_GeomFromText(?, '4326'), " + + "polygon = CASE WHEN ?::text IS NOT NULL THEN ST_GeomFromText(?, '4326') END, " + + "timezone = ?, geocoded = ? WHERE id = ?"; + + String polygonWkt = this.pointReaderWriter.polygonToWkt(place.getPolygon()); + jdbcTemplate.update(sql, - place.getName(), - place.getAddress(), - place.getCity(), - place.getCountryCode(), - place.getType().name(), - place.getLatitudeCentroid(), - place.getLongitudeCentroid(), - this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid()), - place.getTimezone() != null ? place.getTimezone().getId() : null, - place.isGeocoded(), - place.getId() + place.getName(), + place.getAddress(), + place.getCity(), + place.getCountryCode(), + place.getType().name(), + place.getLatitudeCentroid(), + place.getLongitudeCentroid(), + this.pointReaderWriter.write(place.getLongitudeCentroid(), place.getLatitudeCentroid()), + polygonWkt, + polygonWkt, + place.getTimezone() != null ? place.getTimezone().getId() : null, + place.isGeocoded(), + place.getId() ); - return findById(place.getId()).orElseThrow(); - } + return findById(place.getId()).orElseThrow(); } @Cacheable("significant-places") public Optional findById(Long id) { - String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " + - "FROM significant_places sp " + - "WHERE sp.id = ?"; + String sql = """ + SELECT sp.id, + sp.address, + sp.city, + sp.country_code, + sp.type, + sp.latitude_centroid, + sp.longitude_centroid, + sp.name, + sp.user_id, + ST_AsText(sp.geom) as geom, + ST_AsText(sp.polygon) as polygon, + sp.timezone, + sp.geocoded, + sp.version + FROM significant_places sp + WHERE sp.id = ? + """; List results = jdbcTemplate.query(sql, significantPlaceRowMapper, id); return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst()); } @@ -135,26 +268,71 @@ public class SignificantPlaceJdbcService { } public List findNonGeocodedByUser(User user) { - String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " + - "FROM significant_places sp " + - "WHERE sp.user_id = ? AND sp.geocoded = false " + - "ORDER BY sp.id"; + String sql = """ + SELECT sp.id, + sp.address, + sp.city, + sp.country_code, + sp.type, + sp.latitude_centroid, + sp.longitude_centroid, + sp.name, + sp.user_id, + ST_AsText(sp.geom) as geom, + ST_AsText(sp.polygon) as polygon, + sp.timezone, + sp.geocoded, + sp.version + FROM significant_places sp + WHERE sp.user_id = ? AND sp.geocoded = false + ORDER BY sp.id + """; return jdbcTemplate.query(sql, significantPlaceRowMapper, user.getId()); } public List findAllByUser(User user) { - String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " + - "FROM significant_places sp " + - "WHERE sp.user_id = ? " + - "ORDER BY sp.id"; + String sql = """ + SELECT sp.id, + sp.address, + sp.city, + sp.country_code, + sp.type, + sp.latitude_centroid, + sp.longitude_centroid, + sp.name, + sp.user_id, + ST_AsText(sp.geom) as geom, + ST_AsText(sp.polygon) as polygon, + sp.timezone, + sp.geocoded, + sp.version + FROM significant_places sp + WHERE sp.user_id = ? + ORDER BY sp.id + """; return jdbcTemplate.query(sql, significantPlaceRowMapper, user.getId()); } public List findWithMissingTimezone() { - String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " + - "FROM significant_places sp " + - "WHERE sp.timezone IS NULL " + - "ORDER BY sp.id"; + String sql = """ + SELECT sp.id, + sp.address, + sp.city, + sp.country_code, + sp.type, + sp.latitude_centroid, + sp.longitude_centroid, + sp.name, + sp.user_id, + ST_AsText(sp.geom) as geom, + ST_AsText(sp.polygon) as polygon, + sp.timezone, + sp.geocoded, + sp.version + FROM significant_places sp + WHERE sp.timezone IS NULL + ORDER BY sp.id + """; return jdbcTemplate.query(sql, significantPlaceRowMapper); } @@ -163,4 +341,49 @@ public class SignificantPlaceJdbcService { this.jdbcTemplate.update("DELETE FROM geocoding_response WHERE significant_place_id IN (SELECT id FROM significant_places WHERE user_id = ?)", user.getId()); this.jdbcTemplate.update("DELETE FROM significant_places WHERE user_id = ?", user.getId()); } + + public void deleteForUser(User user, List placesToRemove) { + Long[] idList = placesToRemove.stream().map(SignificantPlace::getId).toList().toArray(Long[]::new); + this.jdbcTemplate.update("DELETE FROM geocoding_response WHERE significant_place_id = ANY(?)", (Object) idList); + this.jdbcTemplate.update("DELETE FROM significant_places WHERE user_id = ? AND id = ANY(?)", user.getId(), idList); + } + + public List findPlacesOverlappingWithPolygon(Long userId, Long excludePlaceId, List polygon) { + if (polygon == null || polygon.size() < 3) { + return List.of(); + } + + String sql = """ + SELECT sp.id, + sp.address, + sp.city, + sp.country_code, + sp.type, + sp.latitude_centroid, + sp.longitude_centroid, + sp.name, + sp.user_id, + ST_AsText(sp.geom) as geom, + ST_AsText(sp.polygon) as polygon, + sp.timezone, + sp.geocoded, + sp.version + FROM significant_places sp + WHERE sp.user_id = ? + AND sp.id != ? + AND ( + -- Check if the new polygon overlaps with existing place's polygon + (sp.polygon IS NOT NULL AND ST_Overlaps(sp.polygon, ST_GeomFromText(?, 4326))) + OR + -- Check if the new polygon contains the existing place's centroid + ST_Contains(ST_GeomFromText(?, 4326), sp.geom) + OR + -- Check if existing place's polygon contains any part of the new polygon + (sp.polygon IS NOT NULL AND ST_Overlaps(ST_GeomFromText(?, 4326), sp.polygon)) + ) + """; + + String polygonWkt = this.pointReaderWriter.polygonToWkt(polygon); + return jdbcTemplate.query(sql, significantPlaceRowMapper, userId, excludePlaceId, polygonWkt, polygonWkt, polygonWkt); + } } diff --git a/src/main/java/com/dedicatedcode/reitti/repository/TripJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/TripJdbcService.java index 95a7b8bf..616adecc 100644 --- a/src/main/java/com/dedicatedcode/reitti/repository/TripJdbcService.java +++ b/src/main/java/com/dedicatedcode/reitti/repository/TripJdbcService.java @@ -1,6 +1,7 @@ package com.dedicatedcode.reitti.repository; import com.dedicatedcode.reitti.model.geo.ProcessedVisit; +import com.dedicatedcode.reitti.model.geo.SignificantPlace; import com.dedicatedcode.reitti.model.geo.TransportMode; import com.dedicatedcode.reitti.model.geo.Trip; import com.dedicatedcode.reitti.model.security.User; @@ -214,4 +215,20 @@ public class TripJdbcService { jdbcTemplate.update(sql, ids.toArray()); } + + public void deleteFor(User user, List placesToRemove) { + if (placesToRemove == null || placesToRemove.isEmpty()) { + return; + } + Long[] idList = placesToRemove.stream().map(SignificantPlace::getId).toArray(Long[]::new); + this.jdbcTemplate.update(""" + DELETE FROM trips + WHERE user_id = ? + AND (start_visit_id IN (SELECT id FROM processed_visits WHERE place_id = ANY(?)) + OR end_visit_id IN (SELECT id FROM processed_visits WHERE place_id = ANY(?))) + """, + user.getId(), + idList, + idList); + } } diff --git a/src/main/java/com/dedicatedcode/reitti/repository/VisitJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/VisitJdbcService.java deleted file mode 100644 index e8234ea9..00000000 --- a/src/main/java/com/dedicatedcode/reitti/repository/VisitJdbcService.java +++ /dev/null @@ -1,205 +0,0 @@ -package com.dedicatedcode.reitti.repository; - -import com.dedicatedcode.reitti.model.security.User; -import com.dedicatedcode.reitti.model.geo.Visit; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.sql.Timestamp; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; - -@Service -@Transactional -public class VisitJdbcService { - private final JdbcTemplate jdbcTemplate; - - public VisitJdbcService(JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - private static final RowMapper VISIT_ROW_MAPPER = (rs, _) -> new Visit( - rs.getLong("id"), - rs.getDouble("longitude"), - rs.getDouble("latitude"), - rs.getTimestamp("start_time").toInstant(), - rs.getTimestamp("end_time").toInstant(), - rs.getLong("duration_seconds"), - rs.getBoolean("processed"), - rs.getLong("version") - ); - - public List findByUser(User user) { - String sql = "SELECT v.* " + - "FROM visits v " + - "WHERE v.user_id = ? ORDER BY start_time"; - return jdbcTemplate.query(sql, VISIT_ROW_MAPPER, user.getId()); - } - - public List findByUserAndStartTimeAndEndTime(User user, Instant startTime, Instant endTime) { - String sql = "SELECT v.* " + - "FROM visits v " + - "WHERE v.user_id = ? AND v.start_time = ? AND v.end_time = ?"; - return jdbcTemplate.query(sql, VISIT_ROW_MAPPER, user.getId(), - Timestamp.from(startTime), Timestamp.from(endTime)); - } - - public Visit create(User user, Visit visit) { - String sql = "INSERT INTO visits (user_id, longitude, latitude, start_time, end_time, duration_seconds, processed, version) " + - "VALUES (?, ?, ?, ?, ?, ?, ?,?) RETURNING id"; - Long id = jdbcTemplate.queryForObject(sql, Long.class, - user.getId(), - visit.getLongitude(), - visit.getLatitude(), - Timestamp.from(visit.getStartTime()), - Timestamp.from(visit.getEndTime()), - visit.getDurationSeconds(), - visit.isProcessed(), - visit.getVersion() - ); - return visit.withId(id); - } - - public Visit update(Visit visit) throws OptimisticLockException { - String sql = "UPDATE visits SET longitude = ?, latitude = ?, start_time = ?, end_time = ?, duration_seconds = ?, processed = ?, version = version + 1 WHERE id = ? AND version = ?"; - int rowsUpdated = jdbcTemplate.update(sql, - visit.getLongitude(), - visit.getLatitude(), - Timestamp.from(visit.getStartTime()), - Timestamp.from(visit.getEndTime()), - visit.getDurationSeconds(), - visit.isProcessed(), - visit.getId(), - visit.getVersion() - ); - - if (rowsUpdated == 0) { - throw new OptimisticLockException("Visit with id " + visit.getId() + " was modified by another transaction or does not exist"); - } - - return visit.withVersion(visit.getVersion() + 1); - } - - public Optional findById(Long id) { - String sql = "SELECT v.* " + - "FROM visits v " + - "WHERE v.id = ?"; - List results = jdbcTemplate.query(sql, VISIT_ROW_MAPPER, id); - return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst()); - } - - public List findAllByIds(List visitIds) { - if (visitIds == null || visitIds.isEmpty()) { - return List.of(); - } - - String placeholders = String.join(",", visitIds.stream().map(_ -> "?").toList()); - String sql = "SELECT v.* FROM visits v WHERE v.id IN (" + placeholders + ")"; - - return jdbcTemplate.query(sql, VISIT_ROW_MAPPER, visitIds.toArray()); - } - - @SuppressWarnings("SqlWithoutWhere") - public void deleteAll() { - String sql = "DELETE FROM visits"; - jdbcTemplate.update(sql); - } - - public void deleteAllForUser(User user) { - String sql = "DELETE FROM visits WHERE user_id = ?"; - jdbcTemplate.update(sql, user.getId()); - } - - public void deleteAllForUserBetween(User user, Instant start, Instant end) { - jdbcTemplate.update("DELETE FROM visits WHERE user_id = ? AND start_time <= ? AND end_time >= ?", user.getId(), Timestamp.from(end), Timestamp.from(start)); - } - - public List findByUserAndTimeAfterAndStartTimeBefore(User user, Instant windowStart, Instant windowEnd) { - String sql = "SELECT v.* " + - "FROM visits v " + - "WHERE v.user_id = ? AND v.end_time >= ? AND v.start_time <= ? " + - "ORDER BY v.start_time"; - return jdbcTemplate.query(sql, VISIT_ROW_MAPPER, user.getId(), - Timestamp.from(windowStart), Timestamp.from(windowEnd)); - } - - public List bulkInsert(User user, List visitsToInsert) { - if (visitsToInsert.isEmpty()) { - return new ArrayList<>(); - } - - List createdVisits = new ArrayList<>(); - String sql = """ - INSERT INTO visits (user_id, latitude, longitude, start_time, end_time, duration_seconds, processed, version) - VALUES (?, ?, ?, ?, ?, ?, false, 1) ON CONFLICT (user_id, start_time, end_time) DO UPDATE SET - user_id = EXCLUDED.user_id, - latitude = EXCLUDED.latitude, - longitude = EXCLUDED.longitude, - start_time = EXCLUDED.start_time, - end_time = EXCLUDED.end_time, - duration_seconds = EXCLUDED.duration_seconds, - processed = EXCLUDED.processed, - id = visits.id, - version = visits.version + 1; - """; - - List batchArgs = visitsToInsert.stream() - .map(visit -> new Object[]{ - user.getId(), - visit.getLatitude(), - visit.getLongitude(), - Timestamp.from(visit.getStartTime()), - Timestamp.from(visit.getEndTime()), - visit.getDurationSeconds() - }) - .collect(Collectors.toList()); - - int[] updateCounts = jdbcTemplate.batchUpdate(sql, batchArgs); - for (int i = 0; i < updateCounts.length; i++) { - int updateCount = updateCounts[i]; - if (updateCount > 0) { - createdVisits.addAll(this.findByUserAndStartTimeAndEndTime(user, visitsToInsert.get(i).getStartTime(), visitsToInsert.get(i).getEndTime())); - } - } - return createdVisits; - } - - public void delete(List affectedVisits) throws OptimisticLockException { - if (affectedVisits == null || affectedVisits.isEmpty()) { - return; - } - - // Check versions for all visits before deleting any - for (Visit visit : affectedVisits) { - String checkSql = "SELECT version FROM visits WHERE id = ?"; - List versions = jdbcTemplate.queryForList(checkSql, Long.class, visit.getId()); - - if (versions.isEmpty()) { - throw new OptimisticLockException("Visit with id " + visit.getId() + " does not exist"); - } - - if (!versions.getFirst().equals(visit.getVersion())) { - throw new OptimisticLockException("Visit with id " + visit.getId() + " was modified by another transaction"); - } - } - - String placeholders = String.join(",", affectedVisits.stream().map(_ -> "?").toList()); - String sql = "DELETE FROM visits WHERE id IN (" + placeholders + ")"; - - Object[] ids = affectedVisits.stream().map(Visit::getId).toArray(); - jdbcTemplate.update(sql, ids); - } - - public void deleteAllForUserAfter(User user, Instant start) { - jdbcTemplate.update("DELETE FROM visits WHERE user_id = ? AND end_time >= ?", user.getId(), Timestamp.from(start)); - } - - public long count() { - return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM visits", Long.class); - } -} diff --git a/src/main/java/com/dedicatedcode/reitti/service/DataCleanupService.java b/src/main/java/com/dedicatedcode/reitti/service/DataCleanupService.java new file mode 100644 index 00000000..10b18707 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/DataCleanupService.java @@ -0,0 +1,55 @@ +package com.dedicatedcode.reitti.service; + +import com.dedicatedcode.reitti.model.geo.SignificantPlace; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService; +import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; +import com.dedicatedcode.reitti.repository.SignificantPlaceJdbcService; +import com.dedicatedcode.reitti.repository.TripJdbcService; +import com.dedicatedcode.reitti.service.processing.ProcessingPipelineTrigger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.List; + +@Service +public class DataCleanupService { + private static final Logger log = LoggerFactory.getLogger(DataCleanupService.class); + private final TripJdbcService tripJdbcService; + private final ProcessedVisitJdbcService processedVisitJdbcService; + private final SignificantPlaceJdbcService significantPlaceJdbcService; + private final RawLocationPointJdbcService rawLocationPointJdbcService; + private final DefaultImportProcessor defaultImportProcessor; + + public DataCleanupService(TripJdbcService tripJdbcService, + ProcessedVisitJdbcService processedVisitJdbcService, + SignificantPlaceJdbcService significantPlaceJdbcService, + RawLocationPointJdbcService rawLocationPointJdbcService, + DefaultImportProcessor defaultImportProcessor) { + this.tripJdbcService = tripJdbcService; + this.processedVisitJdbcService = processedVisitJdbcService; + this.significantPlaceJdbcService = significantPlaceJdbcService; + this.rawLocationPointJdbcService = rawLocationPointJdbcService; + this.defaultImportProcessor = defaultImportProcessor; + } + + public void cleanupForGeometryChange(User user, List placesToRemove, List affectedDays) { + long start = System.nanoTime(); + log.info("Cleanup for geometry change. Removing [{}] places and starting recalculation for days [{}]", placesToRemove.size(), affectedDays); + log.debug("Removing affected trips for places [{}]", placesToRemove); + this.tripJdbcService.deleteFor(user, placesToRemove); + log.debug("Removing affected visits for places [{}]", placesToRemove); + this.processedVisitJdbcService.deleteFor(user, placesToRemove); + log.debug("Removing places [{}]", placesToRemove); + this.significantPlaceJdbcService.deleteForUser(user, placesToRemove); + log.info("Cleanup for geometry change completed in {}ms", (System.nanoTime() - start) / 1000000); + + start = System.nanoTime(); + this.rawLocationPointJdbcService.markAllAsUnprocessedForUser(user, affectedDays); + log.info("clearing processed points for days [{}] completed in {}ms", affectedDays, (System.nanoTime() - start) / 1000000); + this.defaultImportProcessor.scheduleProcessingTrigger(user.getUsername()); + + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/PlaceChangeDetectionService.java b/src/main/java/com/dedicatedcode/reitti/service/PlaceChangeDetectionService.java new file mode 100644 index 00000000..fd79e58d --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/PlaceChangeDetectionService.java @@ -0,0 +1,179 @@ +package com.dedicatedcode.reitti.service; + +import com.dedicatedcode.reitti.model.geo.GeoPoint; +import com.dedicatedcode.reitti.model.geo.GeoUtils; +import com.dedicatedcode.reitti.model.geo.SignificantPlace; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService; +import com.dedicatedcode.reitti.repository.SignificantPlaceJdbcService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Service +public class PlaceChangeDetectionService { + + private static final Logger log = LoggerFactory.getLogger(PlaceChangeDetectionService.class); + private final ProcessedVisitJdbcService processedVisitJdbcService; + private final SignificantPlaceJdbcService placeJdbcService; + private final I18nService i18nService; + private final ObjectMapper objectMapper; + + public PlaceChangeDetectionService(ProcessedVisitJdbcService processedVisitJdbcService, + SignificantPlaceJdbcService placeJdbcService, + I18nService i18nService, + ObjectMapper objectMapper) { + this.processedVisitJdbcService = processedVisitJdbcService; + this.placeJdbcService = placeJdbcService; + this.i18nService = i18nService; + this.objectMapper = objectMapper; + } + + public PlaceChangeAnalysis analyzeChanges(User user, Long placeId, String polygonData) { + try { + SignificantPlace currentPlace = placeJdbcService.findById(placeId).orElseThrow(); + List warnings = new ArrayList<>(); + + // Analyze polygon changes + boolean changed = analyzePolygonChanges(currentPlace, polygonData, warnings); + + // Check for overlapping places + changed = changed | analyzeOverlappingPlaces(user, placeId, polygonData, warnings); + + //check for affected days only when places got merged or the polygon changed significantly + if (changed) { + calculateAffectedDays(user, currentPlace, polygonData, warnings); + } + + return new PlaceChangeAnalysis(warnings.isEmpty(), warnings); + + } catch (Exception e) { + return new PlaceChangeAnalysis(false, List.of(i18nService.translate("places.warning.general_error", e.getMessage()))); + } + } + + private void calculateAffectedDays(User user, SignificantPlace currentPlace, String polygonData, List warnings) throws Exception { + List overlappingPlaces; + if (polygonData != null) { + overlappingPlaces = new ArrayList<>(checkForOverlappingPlaces(user, currentPlace.getId(), Collections.emptyList())); + overlappingPlaces.add(currentPlace); + } else { + overlappingPlaces = List.of(currentPlace); + } + int affectedDays = this.processedVisitJdbcService.getAffectedDays(overlappingPlaces).size(); + if (affectedDays > 0) { + warnings.add(i18nService.translate("places.warning.overlapping.recalculation_hint", affectedDays)); + } + } + + private boolean analyzePolygonChanges(SignificantPlace currentPlace, String polygonData, List warnings) { + boolean changed = false; + boolean hadPolygon = currentPlace.getPolygon() != null && !currentPlace.getPolygon().isEmpty(); + boolean willHavePolygon = polygonData != null && !polygonData.trim().isEmpty(); + + if (hadPolygon && !willHavePolygon) { + warnings.add(i18nService.translate("places.warning.polygon.removal")); + changed = true; + } + + if (!hadPolygon && willHavePolygon) { + warnings.add(i18nService.translate("places.warning.polygon.addition")); + changed = true; + } + + // Check if polygon is being significantly changed + if (hadPolygon && willHavePolygon) { + try { + List newPolygon = parsePolygonData(polygonData); + GeoPoint newCentroid = GeoUtils.calculatePolygonCentroid(newPolygon); + GeoPoint currentCentroid = new GeoPoint(currentPlace.getLatitudeCentroid(), currentPlace.getLongitudeCentroid()); + + double currentArea = GeoUtils.calculatePolygonArea(currentPlace.getPolygon()); + double newArea = GeoUtils.calculatePolygonArea(newPolygon); + // Calculate distance between centroids (rough approximation) + double latDiff = Math.abs(newCentroid.latitude() - currentCentroid.latitude()); + double lngDiff = Math.abs(newCentroid.longitude() - currentCentroid.longitude()); + + // If centroid moved significantly (more than ~10m at typical latitudes) + if (latDiff > 0.0001 || lngDiff > 0.0001 || Math.abs(newArea - currentArea) > 1) { + warnings.add(i18nService.translate("places.warning.polygon.significant_change")); + changed = true; + } + } catch (Exception e) { + // If polygon parsing fails, we'll catch it in the actual update + } + } + return changed; + } + + private boolean analyzeOverlappingPlaces(User user, Long placeId, String polygonData, List warnings) { + boolean willHavePolygon = polygonData != null && !polygonData.trim().isEmpty(); + + if (willHavePolygon) { + try { + List newPolygon = parsePolygonData(polygonData); + int overlappingPlaces = checkForOverlappingPlaces(user, placeId, newPolygon).size(); + if (overlappingPlaces > 0) { + warnings.add(i18nService.translate("places.warning.overlapping.visits", overlappingPlaces)); + return true; + } + } catch (Exception e) { + throw new IllegalStateException("Failed to parse polygon data: " + e.getMessage(), e); + } + } + return false; + } + + private List parsePolygonData(String polygonData) throws Exception { + JsonNode jsonNode = objectMapper.readTree(polygonData); + List geoPoints = new ArrayList<>(); + + if (jsonNode.isArray()) { + for (JsonNode pointNode : jsonNode) { + if (pointNode.has("lat") && pointNode.has("lng")) { + double lat = pointNode.get("lat").asDouble(); + double lng = pointNode.get("lng").asDouble(); + geoPoints.add(new GeoPoint(lat, lng)); + } else { + throw new IllegalArgumentException("Each point must have 'lat' and 'lng' properties"); + } + } + } else { + throw new IllegalArgumentException("Polygon data must be an array of coordinate objects"); + } + + if (geoPoints.size() < 3) { + throw new IllegalArgumentException("Polygon must have at least 3 points"); + } + + return geoPoints; + } + + private List checkForOverlappingPlaces(User user, Long placeId, List newPolygon) { + return placeJdbcService.findPlacesOverlappingWithPolygon(user.getId(), placeId, newPolygon); + } + + public static class PlaceChangeAnalysis { + private final boolean canProceed; + private final List warnings; + + public PlaceChangeAnalysis(boolean canProceed, List warnings) { + this.canProceed = canProceed; + this.warnings = warnings; + } + + public boolean isCanProceed() { + return canProceed; + } + + public List getWarnings() { + return warnings; + } + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/UserService.java b/src/main/java/com/dedicatedcode/reitti/service/UserService.java index 6993263d..dad731e6 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/UserService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/UserService.java @@ -27,7 +27,6 @@ public class UserService { private final VisitDetectionParametersJdbcService visitDetectionParametersJdbcService; private final TransportModeJdbcService transportModeJdbcService; private final RawLocationPointJdbcService rawLocationPointJdbcService; - private final VisitJdbcService visitJdbcService; private final SignificantPlaceJdbcService significantPlaceJdbcService; private final ProcessedVisitJdbcService processedVisitJdbcService; private final GeocodingResponseJdbcService geocodingResponseJdbcService; @@ -39,7 +38,6 @@ public class UserService { VisitDetectionParametersJdbcService visitDetectionParametersJdbcService, TransportModeJdbcService transportModeJdbcService, RawLocationPointJdbcService rawLocationPointJdbcService, - VisitJdbcService visitJdbcService, SignificantPlaceJdbcService significantPlaceJdbcService, ProcessedVisitJdbcService processedVisitJdbcService, GeocodingResponseJdbcService geocodingResponseJdbcService, @@ -50,7 +48,6 @@ public class UserService { this.visitDetectionParametersJdbcService = visitDetectionParametersJdbcService; this.transportModeJdbcService = transportModeJdbcService; this.rawLocationPointJdbcService = rawLocationPointJdbcService; - this.visitJdbcService = visitJdbcService; this.significantPlaceJdbcService = significantPlaceJdbcService; this.processedVisitJdbcService = processedVisitJdbcService; this.geocodingResponseJdbcService = geocodingResponseJdbcService; @@ -138,7 +135,6 @@ public class UserService { this.processedVisitJdbcService.deleteAllForUser(user); this.significantPlaceJdbcService.deleteForUser(user); this.significantPlaceJdbcService.deleteForUser(user); - this.visitJdbcService.deleteAllForUser(user); this.rawLocationPointJdbcService.deleteAllForUser(user); this.rawLocationPointJdbcService.deleteAllForUser(user); this.apiTokenJdbcService.deleteForUser(user); diff --git a/src/main/java/com/dedicatedcode/reitti/service/integration/ReittiIntegrationService.java b/src/main/java/com/dedicatedcode/reitti/service/integration/ReittiIntegrationService.java index 5f5d98e3..15a3b77e 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/integration/ReittiIntegrationService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/integration/ReittiIntegrationService.java @@ -1,6 +1,8 @@ package com.dedicatedcode.reitti.service.integration; import com.dedicatedcode.reitti.dto.*; +import com.dedicatedcode.reitti.model.geo.GeoPoint; +import com.dedicatedcode.reitti.model.geo.SignificantPlace; import com.dedicatedcode.reitti.model.integration.ReittiIntegration; import com.dedicatedcode.reitti.model.security.RemoteUser; import com.dedicatedcode.reitti.model.security.User; @@ -492,7 +494,9 @@ public class ReittiIntegrationService { private ProcessedVisitResponse.PlaceVisitSummary parsePlaceVisitSummary(Map placeData) { // Parse place info Map placeInfo = (Map) placeData.get("place"); - ProcessedVisitResponse.PlaceInfo place = new ProcessedVisitResponse.PlaceInfo( + List polygon = mapToPolygon(placeInfo.get("polygon")); + + PlaceInfo place = new PlaceInfo( getLongValue(placeInfo, "id"), (String) placeInfo.get("name"), (String) placeInfo.get("address"), @@ -500,7 +504,8 @@ public class ReittiIntegrationService { (String) placeInfo.get("countryCode"), getDoubleValue(placeInfo, "lat"), getDoubleValue(placeInfo, "lng"), - (String) placeInfo.get("type") + SignificantPlace.PlaceType.valueOf(placeInfo.get("type").toString()), + polygon ); // Parse visits @@ -521,6 +526,23 @@ public class ReittiIntegrationService { return new ProcessedVisitResponse.PlaceVisitSummary(place, visits, totalDurationMs, visitCount, color); } + + private List mapToPolygon(Object polygonObj) { + if (polygonObj == null) { + return null; + } + // The remote JSON is deserialized by RestTemplate into a List of LinkedHashMap + List> rawList = (List>) polygonObj; + List polygon = new ArrayList<>(rawList.size()); + for (Map pointMap : rawList) { + Double lat = getDoubleValue(pointMap, "latitude"); + Double lng = getDoubleValue(pointMap, "longitude"); + if (lat != null && lng != null) { + polygon.add(GeoPoint.from(lat, lng)); + } + } + return polygon; + } private Long getLongValue(Map map, String key) { Object value = map.get(key); diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/UnifiedLocationProcessingService.java b/src/main/java/com/dedicatedcode/reitti/service/processing/UnifiedLocationProcessingService.java index 4a4ff8ec..3326fae5 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/processing/UnifiedLocationProcessingService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/UnifiedLocationProcessingService.java @@ -40,8 +40,6 @@ public class UnifiedLocationProcessingService { private final UserJdbcService userJdbcService; private final RawLocationPointJdbcService rawLocationPointJdbcService; private final PreviewRawLocationPointJdbcService previewRawLocationPointJdbcService; - private final VisitJdbcService visitJdbcService; - private final PreviewVisitJdbcService previewVisitJdbcService; private final ProcessedVisitJdbcService processedVisitJdbcService; private final PreviewProcessedVisitJdbcService previewProcessedVisitJdbcService; private final TripJdbcService tripJdbcService; @@ -61,8 +59,6 @@ public class UnifiedLocationProcessingService { UserJdbcService userJdbcService, RawLocationPointJdbcService rawLocationPointJdbcService, PreviewRawLocationPointJdbcService previewRawLocationPointJdbcService, - VisitJdbcService visitJdbcService, - PreviewVisitJdbcService previewVisitJdbcService, ProcessedVisitJdbcService processedVisitJdbcService, PreviewProcessedVisitJdbcService previewProcessedVisitJdbcService, TripJdbcService tripJdbcService, @@ -70,7 +66,8 @@ public class UnifiedLocationProcessingService { SignificantPlaceJdbcService significantPlaceJdbcService, PreviewSignificantPlaceJdbcService previewSignificantPlaceJdbcService, SignificantPlaceOverrideJdbcService significantPlaceOverrideJdbcService, - VisitDetectionParametersService visitDetectionParametersService, PreviewVisitDetectionParametersJdbcService previewVisitDetectionParametersJdbcService, + VisitDetectionParametersService visitDetectionParametersService, + PreviewVisitDetectionParametersJdbcService previewVisitDetectionParametersJdbcService, TransportModeService transportModeService, UserNotificationService userNotificationService, GeoLocationTimezoneService timezoneService, @@ -79,8 +76,6 @@ public class UnifiedLocationProcessingService { this.userJdbcService = userJdbcService; this.rawLocationPointJdbcService = rawLocationPointJdbcService; this.previewRawLocationPointJdbcService = previewRawLocationPointJdbcService; - this.visitJdbcService = visitJdbcService; - this.previewVisitJdbcService = previewVisitJdbcService; this.processedVisitJdbcService = processedVisitJdbcService; this.previewProcessedVisitJdbcService = previewProcessedVisitJdbcService; this.tripJdbcService = tripJdbcService; @@ -238,28 +233,27 @@ public class UnifiedLocationProcessingService { } detectionParams = currentConfiguration.getVisitDetection(); - // Find and delete affected visits - List affectedVisits; + List existingProcessedVisits; if (previewId == null) { - affectedVisits = visitJdbcService.findByUserAndTimeAfterAndStartTimeBefore(user, windowStart, windowEnd); - visitJdbcService.delete(affectedVisits); + existingProcessedVisits = processedVisitJdbcService + .findByUserAndStartTimeBeforeEqualAndEndTimeAfterEqual(user, windowEnd, windowStart); } else { - affectedVisits = previewVisitJdbcService.findByUserAndTimeAfterAndStartTimeBefore(user, previewId, windowStart, windowEnd); - previewVisitJdbcService.delete(affectedVisits); + existingProcessedVisits = previewProcessedVisitJdbcService + .findByUserAndStartTimeBeforeEqualAndEndTimeAfterEqual(user, previewId, windowEnd, windowStart); } - // Expand the window based on deleted visits - if (!affectedVisits.isEmpty()) { - if (affectedVisits.getFirst().getStartTime().isBefore(windowStart)) { - windowStart = affectedVisits.getFirst().getStartTime(); + // Expand window based on deleted processed visits + if (!existingProcessedVisits.isEmpty()) { + if (existingProcessedVisits.getFirst().getStartTime().isBefore(windowStart)) { + windowStart = existingProcessedVisits.getFirst().getStartTime(); } - if (affectedVisits.getLast().getEndTime().isAfter(windowEnd)) { - windowEnd = affectedVisits.getLast().getEndTime(); + if (existingProcessedVisits.getLast().getEndTime().isAfter(windowEnd)) { + windowEnd = existingProcessedVisits.getLast().getEndTime(); } } // Get clustered points - double baseLatitude = affectedVisits.isEmpty() ? 50 : affectedVisits.getFirst().getLatitude(); + double baseLatitude = existingProcessedVisits.isEmpty() ? 50 : existingProcessedVisits.getFirst().getPlace().getLatitudeCentroid(); double metersAsDegrees = GeoUtils.metersToDegreesAtPosition((double) currentConfiguration.getVisitMerging().getMinDistanceBetweenVisits() / 2, baseLatitude); List clusteredPoints; @@ -786,7 +780,7 @@ public class UnifiedLocationProcessingService { Point point = geometryFactory.createPoint(new Coordinate(longitude, latitude)); // Find places within the merge distance if (previewId == null) { - return significantPlaceJdbcService.findNearbyPlaces(user.getId(), point, GeoUtils.metersToDegreesAtPosition((double) mergeConfiguration.getMinDistanceBetweenVisits() / 2, latitude)); + return significantPlaceJdbcService.findEnclosingPlaces(user.getId(), point, GeoUtils.metersToDegreesAtPosition((double) mergeConfiguration.getMinDistanceBetweenVisits() / 2, latitude)); } else { return previewSignificantPlaceJdbcService.findNearbyPlaces(user.getId(), point, GeoUtils.metersToDegreesAtPosition((double) mergeConfiguration.getMinDistanceBetweenVisits() /2, latitude), previewId); } diff --git a/src/main/resources/db/migration/V71__add_polygon_to_significant_places.sql b/src/main/resources/db/migration/V71__add_polygon_to_significant_places.sql new file mode 100644 index 00000000..319b41a3 --- /dev/null +++ b/src/main/resources/db/migration/V71__add_polygon_to_significant_places.sql @@ -0,0 +1,6 @@ +ALTER TABLE significant_places ADD COLUMN polygon GEOMETRY(POLYGON, 4326); +ALTER TABLE preview_significant_places ADD COLUMN polygon GEOMETRY(POLYGON, 4326); + +CREATE INDEX idx_significant_places_polygon ON significant_places USING GIST (polygon); +CREATE INDEX idx_preview_significant_places_polygon ON preview_significant_places USING GIST (polygon); + diff --git a/src/main/resources/db/migration/V72__drop_visits_table.sql b/src/main/resources/db/migration/V72__drop_visits_table.sql new file mode 100644 index 00000000..edfe0b19 --- /dev/null +++ b/src/main/resources/db/migration/V72__drop_visits_table.sql @@ -0,0 +1,2 @@ +DROP TABLE visits; +DROP TABLE preview_visits; \ No newline at end of file diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 59943b99..3f6a2a50 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -1,7 +1,7 @@ # Page titles page.title=Reitti - Your Location Timeline statistics.page.title=Statistics - Reitti - +edit-place.page.title=Edit Place - Reitti # Navigation nav.timeline=Timeline nav.statistics=Statistics @@ -461,10 +461,12 @@ units.imperial.description=(mi, ft) # Places places.title=Significant Places -places.no.places=No significant places found. +places.no.places=No significant places were found. places.page.info=Page {0} of {1} places.name.label=Name places.address.label=Address +places.city.label=City +places.country.label=Country places.category.label=Category places.coordinates.label=Coordinates places.address.not.available=Not available @@ -474,9 +476,9 @@ places.geocode.confirm=Are you sure you want to re-geocode this place? This will places.geocode.success=Place scheduled for geocoding places.geocode.error=Error scheduling place for geocoding: {0} places.address.placeholder=Enter address -places.geocoding.response.button=View Geocoding +places.geocoding.response.button=View Geocoding Response places.geocoding.response.title=Geocoding Response for {0} -places.geocoding.response.no.data=No geocoding response available for this place +places.geocoding.response.no.data=No geocoding response is available for this place places.geocoding.response.back=Back to Places places.geocoding.response.provider=Provider places.geocoding.response.status=Status @@ -491,6 +493,16 @@ places.edit.visit.stats.title=Visit Statistics places.edit.visit.summary=You visited {0} {1} times. places.edit.visit.complete=You visited {0} {1} times. Your first visit was on {2} and your most recent visit was on {3}. places.edit.no.visits=No visits recorded for this place yet. +places.polygon.remove=Remove Polygon +places.polygon.editor.subtitle=Edit Place boundaries +places.polygon.editor.instructions=Click and drag to draw a polygon around the place. Click an existing point to remove it. +places.warning.polygon.removal=The polygon boundary will be removed from this place, this may affect visit detection. +places.warning.polygon.addition=The polygon boundary will be added to this place, this may affect visit detection. +places.warning.polygon.significant_change=The polygon boundary will be significantly changed, which may affect visit detection. +places.warning.overlapping.visits=The new boundary will overlap with {0,choice,1#1 existing place|1<{0,number,integer} existing places}, which may cause visits to be reassigned between places and affect trip calculations +places.warning.overlapping.recalculation_hint=The new boundary will trigger recalulation of {0,choice,1#1 day|1<{0,number,integer} days} of data, which may take a few minutes to complete. +places.warning.general_error=An error occurred while checking the update: {0} +places.update.confirmation.message=The following changes will be made:\n\n{0}\n\nDo you want to continue? place.type.train_station=Train Station place.type.gas_station=Gas Station place.type.restaurant=Restaurant @@ -526,6 +538,7 @@ place.unknown.label=Unknown Place form.create=Create form.update=Update form.delete=Delete +form.select.placeholder=Select... form.cancel=Cancel form.save.changes=Save Changes form.save=Save diff --git a/src/main/resources/static/css/inline-edit.css b/src/main/resources/static/css/inline-edit.css index 31a8a4c5..0915580c 100644 --- a/src/main/resources/static/css/inline-edit.css +++ b/src/main/resources/static/css/inline-edit.css @@ -16,6 +16,7 @@ border: 1px solid wheat; color: wheat; border-radius: 4px; + text-decoration: none; } .timeline-entry.trip.active:hover .edit-icon, diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index 5c2d02e5..2945ca4f 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -178,10 +178,21 @@ header { font-family: var(--sans-font); } +a.btn-default, +.btn-default { + border: 1px solid var(--color-highlight); +} + +.btn-block + .btn-block { + margin-top: 10px; +} + .btn.btn-block { width: 100%; display: inline-block; - box-sizing: border-box;} + box-sizing: border-box; + text-align: center; +} .btn.btn-danger { background: #995353; @@ -353,6 +364,11 @@ tr:hover { .form-group { margin-bottom: 15px; } + +.form-group label { + color: var(--color-text-white); +} + .queue-status { display: flex; gap: 20px; @@ -468,9 +484,6 @@ tr:hover { color: var(--color-text-white); } -.place-details { - padding: 15px; -} .place-info { margin: 15px 0; @@ -948,6 +961,19 @@ body.auto-update-mode .today-fab { padding: 1rem; text-align: center; transition: transform 0.3s ease, box-shadow 0.3s ease; + display: flex; + flex-direction: column; +} + +.place-stats-card .place-details { + padding: 15px; + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.place-stats-card .place-info { + flex-grow: 1; } .place-stats-card:hover { @@ -1813,7 +1839,6 @@ button:disabled { border-collapse: collapse; } - .export-data-table-container table thead tr { position: sticky; top: 0; diff --git a/src/main/resources/static/js/canvas-visit-renderer.js b/src/main/resources/static/js/canvas-visit-renderer.js index eb88ff31..d94d7afd 100644 --- a/src/main/resources/static/js/canvas-visit-renderer.js +++ b/src/main/resources/static/js/canvas-visit-renderer.js @@ -26,6 +26,11 @@ class CanvasVisitRenderer { this.map.on('zoomend', () => { this.updateVisibleVisits(); }); + + // Listen for zoom changes to re-render markers (for polygon/circle switching) + this.map.on('zoomend', () => { + this.renderVisibleVisits(); + }); } setVisits(visits) { @@ -51,11 +56,10 @@ class CanvasVisitRenderer { updateVisibleVisits() { const zoom = this.map.getZoom(); - - // Filter visits based on zoom level and duration - let minDurationMs; + + let minDurationMs = 0; if (zoom >= 15) { - minDurationMs = 5 * 60 * 1000; // 5 minutes at high zoom + minDurationMs = 60 * 1000; // 1 minute at high zoom } else if (zoom >= 12) { minDurationMs = 30 * 60 * 1000; // 30 minutes at medium zoom } else if (zoom >= 10) { @@ -63,11 +67,11 @@ class CanvasVisitRenderer { } else { minDurationMs = 6 * 60 * 60 * 1000; // 6+ hours at very low zoom } - - this.visibleVisits = this.allVisits.filter(visit => + + this.visibleVisits = this.allVisits.filter(visit => visit.totalDurationMs >= minDurationMs ); - + this.renderVisibleVisits(); } @@ -84,13 +88,17 @@ class CanvasVisitRenderer { }); } - createVisitMarkers() { - this.visibleVisits.forEach(visit => { - this.createVisitMarker(visit); - }); + createVisitMarker(visit) { + const zoom = this.map.getZoom(); + const showPolygons = zoom >= 18; // Show polygons at zoom level 16 and above + if (showPolygons && visit.place.polygon) { + this.createPolygonMarker(visit); + } else { + this.createCircleMarker(visit); + } } - createVisitMarker(visit) { + createCircleMarker(visit) { // Calculate radius using logarithmic scale const durationHours = visit.totalDurationMs / (1000 * 60 * 60); const baseRadius = 15; @@ -102,7 +110,7 @@ class CanvasVisitRenderer { // Create outer circle (visit area) const outerCircle = L.circle([visit.lat, visit.lng], { - radius: radius * 5, // Convert to meters (approximate) + radius: radius, // Convert to meters (approximate) fillColor: this.lightenHexColor(visit.color, 20), fillOpacity: 0.1, color: visit.color, @@ -145,6 +153,87 @@ class CanvasVisitRenderer { this.visitMarkers.push(outerCircle, innerMarker); } + createPolygonMarker(visit) { + // Parse polygon coordinates from array format (same as polygon-editor.js) + const polygonCoords = this.parsePolygonData(visit.place.polygon); + + if (!polygonCoords || polygonCoords.length === 0) { + // Fallback to circle marker if polygon parsing fails + this.createCircleMarker(visit); + return; + } + + // Create polygon + const polygon = L.polygon(polygonCoords, { + fillColor: this.lightenHexColor(visit.color, 20), + fillOpacity: 0.3, + color: visit.color, + weight: 2, + renderer: this.canvasRenderer, + interactive: true + }); + + const centerMarker = L.circleMarker([visit.lat, visit.lng], { + radius: 5, + fillOpacity: 1, + fillColor: this.lightenHexColor(visit.color, 80), + color: '#000', + weight: 1, + renderer: this.canvasRenderer, + interactive: true + }); + + // Create tooltip content + const totalDurationText = this.humanizeDuration(visit.totalDurationMs); + const visitCount = visit.visits.length; + const visitText = visitCount === 1 ? 'visit' : 'visits'; + + let tooltip = L.tooltip({ + content: `
${visit.place.name}
+
+ ${visitCount} ${visitText} - Total: ${totalDurationText} +
`, + className: 'visit-popup', + permanent: false + }); + + polygon.bindTooltip(tooltip); + centerMarker.bindTooltip(tooltip); + + this.map.addLayer(polygon); + this.map.addLayer(centerMarker); + + // Store references for cleanup + this.visitMarkers.push(polygon, centerMarker); + } + + parsePolygonData(polygonData) { + if (!polygonData) return null; + + try { + // Handle both array format and JSON string format + let coords; + if (typeof polygonData === 'string') { + coords = JSON.parse(polygonData); + } else if (Array.isArray(polygonData)) { + coords = polygonData; + } else { + return null; + } + + // Convert to Leaflet format [lat, lng] + return coords.map(point => { + // Handle both GeoPoint format (latitude/longitude) and simple lat/lng format + const lat = point.latitude || point.lat; + const lng = point.longitude || point.lng; + return [lat, lng]; + }); + } catch (error) { + console.warn('Failed to parse polygon data:', polygonData, error); + return null; + } + } + lightenHexColor(hex, percent) { // Remove # if present hex = hex.replace('#', ''); diff --git a/src/main/resources/static/js/polygon-editor.js b/src/main/resources/static/js/polygon-editor.js new file mode 100644 index 00000000..4f6f901b --- /dev/null +++ b/src/main/resources/static/js/polygon-editor.js @@ -0,0 +1,252 @@ +/** + * Polygon Editor for SignificantPlaces + */ +class PolygonEditor { + constructor(map, centerLat, centerLng, placeName) { + this.map = map; + this.centerLat = centerLat; + this.centerLng = centerLng; + this.placeName = placeName; + + this.polygonPoints = []; + this.polygonMarkers = []; + this.polygonLayer = null; + this.previewLine = null; + this.isDragging = false; + + this.init(); + } + + init() { + // Add center marker for the place + this.centerMarker = L.marker([this.centerLat, this.centerLng], { + icon: L.divIcon({ + className: 'center-marker', + html: '
', + iconSize: [16, 16], + iconAnchor: [8, 8] + }) + }).addTo(this.map); + + this.centerMarker.bindTooltip(this.placeName + ' (center)', { + permanent: false, + direction: 'top' + }); + + // Add click handler for adding polygon points + this.map.on('click', (e) => { + // Don't add point if we're dragging + if (!this.isDragging) { + this.addPolygonPoint(e.latlng); + } + }); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.clearPolygon(); + } else if (e.key === 'Enter' && e.ctrlKey) { + this.savePolygon(); + } else if (e.key === 'z' && e.ctrlKey) { + e.preventDefault(); + this.undoLastPoint(); + } + }); + } + + addPolygonPoint(latlng) { + this.polygonPoints.push(latlng); + + // Add marker for the point + const marker = L.marker(latlng, { + draggable: true, + icon: L.divIcon({ + className: 'polygon-point-marker', + html: '
', + iconSize: [12, 12], + iconAnchor: [6, 6] + }) + }).addTo(this.map); + + marker.bindTooltip(`Point ${this.polygonPoints.length}`, { + permanent: false, + direction: 'top' + }); + + // Add click handler to remove point + marker.on('click', (e) => { + L.DomEvent.stopPropagation(e); + const index = this.polygonMarkers.indexOf(marker); + this.removePolygonPoint(index); + }); + + // Add drag handlers to update polygon when point is moved + marker.on('dragstart', (e) => { + this.isDragging = true; + }); + + marker.on('drag', (e) => { + const index = this.polygonMarkers.indexOf(marker); + if (index >= 0) { + this.polygonPoints[index] = e.target.getLatLng(); + this.updatePolygonDisplay(); + } + }); + + marker.on('dragend', (e) => { + this.isDragging = false; + const index = this.polygonMarkers.indexOf(marker); + if (index >= 0) { + this.polygonPoints[index] = e.target.getLatLng(); + this.updatePolygonDisplay(); + } + }); + + this.polygonMarkers.push(marker); + this.updatePolygonDisplay(); + } + + removePolygonPoint(index) { + if (index >= 0 && index < this.polygonPoints.length) { + this.polygonPoints.splice(index, 1); + this.map.removeLayer(this.polygonMarkers[index]); + this.polygonMarkers.splice(index, 1); + + // Update tooltips + this.polygonMarkers.forEach((marker, i) => { + marker.setTooltipContent(`Point ${i + 1}`); + }); + + this.updatePolygonDisplay(); + } + } + + undoLastPoint() { + if (this.polygonPoints.length > 0) { + this.removePolygonPoint(this.polygonPoints.length - 1); + } + } + + updatePolygonDisplay() { + // Remove existing polygon + if (this.polygonLayer) { + this.map.removeLayer(this.polygonLayer); + this.polygonLayer = null; + } + + // Remove preview line + if (this.previewLine) { + this.map.removeLayer(this.previewLine); + this.previewLine = null; + } + + if (this.polygonPoints.length >= 3) { + // Create polygon + this.polygonLayer = L.polygon(this.polygonPoints, { + color: 'var(--color-highlight)', + weight: 2, + fillColor: 'var(--color-highlight)', + fillOpacity: 0.3 + }).addTo(this.map); + } else if (this.polygonPoints.length === 2) { + // Show preview line to first point + const previewPoints = [...this.polygonPoints, this.polygonPoints[0]]; + this.previewLine = L.polyline(previewPoints, { + color: '#6b7280', + weight: 2, + dashArray: '5, 5' + }).addTo(this.map); + } + + this.updateSaveButton(); + } + + updateSaveButton() { + const saveBtn = document.getElementById('save-btn'); + const polygonDataInput = document.getElementById('polygonData'); + const saveStatusElement = document.getElementById('save-status'); + + if (this.polygonPoints.length === 0) { + // No polygon - this is valid, allow saving + saveBtn.disabled = false; + polygonDataInput.value = ''; + if (saveStatusElement) { + saveStatusElement.textContent = ''; + saveStatusElement.style.display = 'none'; + } + } else if (this.polygonPoints.length >= 3) { + // Valid polygon - allow saving + saveBtn.disabled = false; + const polygonData = this.polygonPoints.map(point => ({ + lat: point.lat, + lng: point.lng + })); + polygonDataInput.value = JSON.stringify(polygonData); + if (saveStatusElement) { + saveStatusElement.textContent = ''; + saveStatusElement.style.display = 'none'; + } + } else { + // Invalid polygon (1-2 points) - disable saving with explanation + saveBtn.disabled = true; + polygonDataInput.value = ''; + if (saveStatusElement) { + saveStatusElement.textContent = `Polygon needs at least 3 points (currently ${this.polygonPoints.length})`; + saveStatusElement.style.display = 'block'; + } + } + } + + clearPolygon() { + this.polygonPoints = []; + this.polygonMarkers.forEach(marker => this.map.removeLayer(marker)); + this.polygonMarkers = []; + + if (this.polygonLayer) { + this.map.removeLayer(this.polygonLayer); + this.polygonLayer = null; + } + + if (this.previewLine) { + this.map.removeLayer(this.previewLine); + this.previewLine = null; + } + + this.updateSaveButton(); + } + + savePolygon() { + if (!document.getElementById('save-btn').disabled) { + document.getElementById('polygon-form').submit(); + } + } + + loadExistingPolygon(polygonData) { + if (polygonData && polygonData.length >= 3) { + polygonData.forEach(point => { + const lat = point.latitude || point.lat; + const lng = point.longitude || point.lng; + this.addPolygonPoint(L.latLng(lat, lng)); + }); + } + } + + loadNearbyPlaces(nearbyPlaces) { + nearbyPlaces.forEach(place => { + if (place.id !== this.placeId) { + const marker = L.circleMarker([place.lat, place.lng], { + radius: 6, + fillColor: '#ffcccb', + color: '#ff6b6b', + weight: 1, + fillOpacity: 0.7 + }).addTo(this.map); + + marker.bindTooltip(place.name, { + permanent: false, + direction: 'top' + }); + } + }); + } +} diff --git a/src/main/resources/static/js/raw-location-loader.js b/src/main/resources/static/js/raw-location-loader.js index be68ddbf..c7d9b18f 100644 --- a/src/main/resources/static/js/raw-location-loader.js +++ b/src/main/resources/static/js/raw-location-loader.js @@ -234,12 +234,12 @@ class RawLocationLoader { const rawPointsCoords = segment.points.map(point => [point.latitude, point.longitude]); - // Use polyline for segments with many points (>100), geodesic for fewer points + const weight = this.map.getZoom() >= 18 ? 2 : 6; let rawPointsPath; if (segment.points.length > 100) { rawPointsPath = L.polyline(rawPointsCoords, { color: color == null ? '#f1ba63' : color, - weight: 6, + weight: weight, opacity: 0.9, lineJoin: 'round', lineCap: 'round', @@ -248,7 +248,7 @@ class RawLocationLoader { } else { rawPointsPath = L.geodesic(rawPointsCoords, { color: color == null ? '#f1ba63' : color, - weight: 6, + weight: weight, opacity: 0.9, lineJoin: 'round', lineCap: 'round', diff --git a/src/main/resources/templates/fragments/place-edit.html b/src/main/resources/templates/fragments/place-edit.html deleted file mode 100644 index 7ef5bc4b..00000000 --- a/src/main/resources/templates/fragments/place-edit.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - -
-
- - -
-
- - -
-
- - -
-
-
- - - - Place Name - - - - - diff --git a/src/main/resources/templates/fragments/places.html b/src/main/resources/templates/fragments/places.html index d4341ea6..9abd7e2d 100644 --- a/src/main/resources/templates/fragments/places.html +++ b/src/main/resources/templates/fragments/places.html @@ -2,22 +2,30 @@ -
-

Geocoding Response for Place

- -
- +
+

Geocoding Response for Place

+
+ +
+

Geocoding Response for Place

+ +
+ +
+
+ +

Place Name

Address: Address

-

Coordinates: Coordinates

+

Coordinates: Coordinates

@@ -50,38 +58,12 @@
-

No geocoding response available for this place

+

No geocoding response is available for this place

+
- -
-

Edit Place

- -
- -
- -
- Success message -
-
- Error message -
- - -
-
-
- - +

Visit Statistics

@@ -101,50 +83,6 @@
- -
-

Place Details

-
-
- - -
-
- - -
-
- - -
-
-
Coordinates:
-
-
- - - -
-
-
diff --git a/src/main/resources/templates/fragments/timeline.html b/src/main/resources/templates/fragments/timeline.html index fe8456e8..0db2969b 100644 --- a/src/main/resources/templates/fragments/timeline.html +++ b/src/main/resources/templates/fragments/timeline.html @@ -54,11 +54,9 @@
Place Name - +
diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 46743973..5d88fe94 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -31,6 +31,7 @@ + @@ -411,6 +412,9 @@ } window.timelineScrollIndicator = new TimelineScrollIndicator(); window.timelineScrollIndicator.init(); + + // Update edit place links with current URL + updateEditPlaceLinks(); } }); // Function to update map markers from processed visits API @@ -483,7 +487,8 @@ visits: placeSummary.visits.map(v => ({ id: v.id })), place: { name: place.name || 'Unknown Place', - address: place.address || '' + address: place.address || '', + polygon: place.polygon || null }, color: color }; @@ -1020,6 +1025,18 @@ window.horizontalDatePicker.setSelectedRange(getCurrentLocalDate(), null); } } + + function updateEditPlaceLinks() { + const currentUrl = window.location.href; + const editLinks = document.querySelectorAll('.timeline-container .edit-icon[href*="/settings/places/"]'); + + editLinks.forEach(link => { + const href = link.getAttribute('href'); + const url = new URL(href, window.location.origin); + url.searchParams.set('returnUrl', currentUrl); + link.setAttribute('href', url.toString()); + }); + } diff --git a/src/main/resources/templates/settings/edit-place.html b/src/main/resources/templates/settings/edit-place.html new file mode 100644 index 00000000..52142f56 --- /dev/null +++ b/src/main/resources/templates/settings/edit-place.html @@ -0,0 +1,483 @@ + + + + + + Edit Place - Reitti + + + + + + + + + + + +
+ + + + + + + + diff --git a/src/main/resources/templates/settings/places.html b/src/main/resources/templates/settings/places.html index 5a164dd5..d8729481 100644 --- a/src/main/resources/templates/settings/places.html +++ b/src/main/resources/templates/settings/places.html @@ -39,27 +39,24 @@
-
+
Name:
Address:
Category:
-
Coordinates:
+
Coordinates:
- + Edit
-

No significant places found.

+

No significant places were found.

Page 1 of 1 diff --git a/src/test/java/com/dedicatedcode/reitti/IntegrationTest.java b/src/test/java/com/dedicatedcode/reitti/IntegrationTest.java index a78bffbf..2058efde 100644 --- a/src/test/java/com/dedicatedcode/reitti/IntegrationTest.java +++ b/src/test/java/com/dedicatedcode/reitti/IntegrationTest.java @@ -3,7 +3,6 @@ package com.dedicatedcode.reitti; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; -import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import org.testcontainers.junit.jupiter.Testcontainers; @@ -15,7 +14,6 @@ import java.lang.annotation.RetentionPolicy; @ActiveProfiles("test") @Import({TestContainerConfiguration.class, TestConfiguration.class}) @Retention(RetentionPolicy.RUNTIME) -//@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) @AutoConfigureMockMvc public @interface IntegrationTest { } diff --git a/src/test/java/com/dedicatedcode/reitti/TestingService.java b/src/test/java/com/dedicatedcode/reitti/TestingService.java index b2883f8b..7c5b1440 100644 --- a/src/test/java/com/dedicatedcode/reitti/TestingService.java +++ b/src/test/java/com/dedicatedcode/reitti/TestingService.java @@ -48,8 +48,6 @@ public class TestingService { @Autowired private ProcessedVisitJdbcService processedVisitRepository; @Autowired - private VisitJdbcService visitRepository; - @Autowired private ProcessingPipelineTrigger trigger; @Autowired private UserService userService; @@ -134,16 +132,13 @@ public class TestingService { // Check if all counts are stable long currentRawCount = rawLocationPointRepository.count(); - long currentVisitCount = visitRepository.count(); long currentTripCount = tripRepository.count(); boolean countsStable = currentRawCount == lastRawCount.get() && - currentVisitCount == lastVisitCount.get() && currentTripCount == lastTripCount.get(); lastRawCount.set(currentRawCount); - lastVisitCount.set(currentVisitCount); lastTripCount.set(currentTripCount); if (countsStable && this.trigger.isIdle() && importBatchProcessor.isIdle()) { @@ -166,7 +161,6 @@ public class TestingService { //now clear the database this.tripRepository.deleteAll(); this.processedVisitRepository.deleteAll(); - this.visitRepository.deleteAll(); this.rawLocationPointRepository.deleteAll(); } diff --git a/src/test/java/com/dedicatedcode/reitti/repository/ProcessedVisitJdbcServiceTest.java b/src/test/java/com/dedicatedcode/reitti/repository/ProcessedVisitJdbcServiceTest.java new file mode 100644 index 00000000..a60cbc17 --- /dev/null +++ b/src/test/java/com/dedicatedcode/reitti/repository/ProcessedVisitJdbcServiceTest.java @@ -0,0 +1,359 @@ +package com.dedicatedcode.reitti.repository; + +import com.dedicatedcode.reitti.IntegrationTest; +import com.dedicatedcode.reitti.TestingService; +import com.dedicatedcode.reitti.model.geo.ProcessedVisit; +import com.dedicatedcode.reitti.model.geo.SignificantPlace; +import com.dedicatedcode.reitti.model.security.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@IntegrationTest +class ProcessedVisitJdbcServiceTest { + + @Autowired + private ProcessedVisitJdbcService processedVisitJdbcService; + + @Autowired + private SignificantPlaceJdbcService placeJdbcService; + + @Autowired + private TestingService testingService; + + private User testUser; + private SignificantPlace testPlace; + private SignificantPlace anotherPlace; + + @BeforeEach + void setUp() { + testUser = testingService.randomUser(); + testPlace = createTestPlace("Home", 53.863149, 10.700927); + anotherPlace = createTestPlace("Work", 53.864149, 10.701927); + } + + @Test + void create_ShouldCreateProcessedVisit() { + // Given + Instant startTime = Instant.now().minus(2, ChronoUnit.HOURS); + Instant endTime = Instant.now().minus(1, ChronoUnit.HOURS); + ProcessedVisit visit = new ProcessedVisit(testPlace, startTime, endTime, 3600L); + + // When + ProcessedVisit created = processedVisitJdbcService.create(testUser, visit); + + // Then + assertNotNull(created.getId()); + assertEquals(1L, created.getVersion()); + assertEquals(testPlace.getId(), created.getPlace().getId()); + assertEquals(startTime, created.getStartTime()); + assertEquals(endTime, created.getEndTime()); + assertEquals(3600L, created.getDurationSeconds()); + } + + @Test + void findById_WithExistingId_ShouldReturnVisit() { + // Given + ProcessedVisit visit = createTestVisit(testPlace, Instant.now().minus(1, ChronoUnit.HOURS), Instant.now(), 3600L); + + // When + Optional found = processedVisitJdbcService.findById(visit.getId()); + + // Then + assertTrue(found.isPresent()); + assertEquals(visit.getId(), found.get().getId()); + assertEquals(testPlace.getId(), found.get().getPlace().getId()); + } + + @Test + void findById_WithNonExistingId_ShouldReturnEmpty() { + // When + Optional found = processedVisitJdbcService.findById(999L); + + // Then + assertTrue(found.isEmpty()); + } + + @Test + void findByUser_ShouldReturnAllUserVisits() { + // Given + User anotherUser = testingService.randomUser(); + createTestVisit(testPlace, Instant.now().minus(3, ChronoUnit.HOURS), Instant.now().minus(2, ChronoUnit.HOURS), 3600L); + createTestVisit(anotherPlace, Instant.now().minus(1, ChronoUnit.HOURS), Instant.now(), 3600L); + + // Create visit for another user (should not be returned) + SignificantPlace anotherUserPlace = createTestPlaceForUser(anotherUser, "Other Place", 53.865149, 10.702927); + processedVisitJdbcService.create(anotherUser, new ProcessedVisit(anotherUserPlace, Instant.now().minus(30, ChronoUnit.MINUTES), Instant.now(), 1800L)); + + // When + List visits = processedVisitJdbcService.findByUser(testUser); + + // Then + assertEquals(2, visits.size()); + assertTrue(visits.stream().allMatch(v -> v.getPlace().getId().equals(testPlace.getId()) || v.getPlace().getId().equals(anotherPlace.getId()))); + } + + @Test + void findByUserAndTimeOverlap_ShouldReturnOverlappingVisits() { + // Given + Instant baseTime = Instant.now().minus(4, ChronoUnit.HOURS); + + // Visit 1: 4-3 hours ago (should overlap) + createTestVisit(testPlace, baseTime, baseTime.plus(1, ChronoUnit.HOURS), 3600L); + + // Visit 2: 2-1 hours ago (should overlap) + createTestVisit(anotherPlace, baseTime.plus(2, ChronoUnit.HOURS), baseTime.plus(3, ChronoUnit.HOURS), 3600L); + + // Visit 3: 6-5 hours ago (should not overlap) + createTestVisit(testPlace, baseTime.minus(2, ChronoUnit.HOURS), baseTime.minus(1, ChronoUnit.HOURS), 3600L); + + // When - query for overlap with 3.5-1.5 hours ago + List visits = processedVisitJdbcService.findByUserAndTimeOverlap( + testUser, + baseTime.plus(30, ChronoUnit.MINUTES), + baseTime.plus(2, ChronoUnit.HOURS).plus(30, ChronoUnit.MINUTES) + ); + + // Then + assertEquals(2, visits.size()); + } + + @Test + void findByUserAndId_WithExistingVisit_ShouldReturnVisit() { + // Given + ProcessedVisit visit = createTestVisit(testPlace, Instant.now().minus(1, ChronoUnit.HOURS), Instant.now(), 3600L); + + // When + Optional found = processedVisitJdbcService.findByUserAndId(testUser, visit.getId()); + + // Then + assertTrue(found.isPresent()); + assertEquals(visit.getId(), found.get().getId()); + } + + @Test + void findByUserAndId_WithDifferentUser_ShouldReturnEmpty() { + // Given + User anotherUser = testingService.randomUser(); + ProcessedVisit visit = createTestVisit(testPlace, Instant.now().minus(1, ChronoUnit.HOURS), Instant.now(), 3600L); + + // When + Optional found = processedVisitJdbcService.findByUserAndId(anotherUser, visit.getId()); + + // Then + assertTrue(found.isEmpty()); + } + + @Test + void findTopPlacesByStayTimeWithLimit_ShouldReturnTopPlaces() { + // Given + // Create multiple visits to testPlace (total 7200 seconds) + createTestVisit(testPlace, Instant.now().minus(4, ChronoUnit.HOURS), Instant.now().minus(3, ChronoUnit.HOURS), 3600L); + createTestVisit(testPlace, Instant.now().minus(2, ChronoUnit.HOURS), Instant.now().minus(1, ChronoUnit.HOURS), 3600L); + + // Create one visit to anotherPlace (total 1800 seconds) + createTestVisit(anotherPlace, Instant.now().minus(30, ChronoUnit.MINUTES), Instant.now(), 1800L); + + // When + List topPlaces = processedVisitJdbcService.findTopPlacesByStayTimeWithLimit(testUser, 10); + + // Then + assertEquals(2, topPlaces.size()); + + // First place should be testPlace with more stay time + Object[] firstPlace = topPlaces.get(0); + assertEquals("Home", firstPlace[0]); + assertEquals(7200L, firstPlace[1]); + assertEquals(2L, firstPlace[2]); + + // Second place should be anotherPlace + Object[] secondPlace = topPlaces.get(1); + assertEquals("Work", secondPlace[0]); + assertEquals(1800L, secondPlace[1]); + assertEquals(1L, secondPlace[2]); + } + + @Test + void findTopPlacesByStayTimeWithLimit_WithTimeRange_ShouldReturnFilteredPlaces() { + // Given + Instant baseTime = Instant.now().minus(4, ChronoUnit.HOURS); + + // Visit within range + createTestVisit(testPlace, baseTime, baseTime.plus(1, ChronoUnit.HOURS), 3600L); + + // Visit outside range + createTestVisit(anotherPlace, baseTime.minus(2, ChronoUnit.HOURS), baseTime.minus(1, ChronoUnit.HOURS), 3600L); + + // When + List topPlaces = processedVisitJdbcService.findTopPlacesByStayTimeWithLimit( + testUser, + baseTime.minus(30, ChronoUnit.MINUTES), + baseTime.plus(2, ChronoUnit.HOURS), + 10 + ); + + // Then + assertEquals(1, topPlaces.size()); + assertEquals("Home", topPlaces.get(0)[0]); + } + + @Test + void update_ShouldUpdateVisit() { + // Given + ProcessedVisit visit = createTestVisit(testPlace, Instant.now().minus(2, ChronoUnit.HOURS), Instant.now().minus(1, ChronoUnit.HOURS), 3600L); + + Instant newStartTime = Instant.now().minus(3, ChronoUnit.HOURS); + Instant newEndTime = Instant.now().minus(30, ChronoUnit.MINUTES); + ProcessedVisit updatedVisit = new ProcessedVisit( + visit.getId(), + anotherPlace, + newStartTime, + newEndTime, + 5400L, + visit.getVersion() + ); + + // When + ProcessedVisit result = processedVisitJdbcService.update(updatedVisit); + + // Then + assertEquals(anotherPlace.getId(), result.getPlace().getId()); + assertEquals(newStartTime, result.getStartTime()); + assertEquals(newEndTime, result.getEndTime()); + assertEquals(5400L, result.getDurationSeconds()); + } + + @Test + void deleteAll_WithVisitList_ShouldDeleteVisits() { + // Given + ProcessedVisit visit1 = createTestVisit(testPlace, Instant.now().minus(2, ChronoUnit.HOURS), Instant.now().minus(1, ChronoUnit.HOURS), 3600L); + ProcessedVisit visit2 = createTestVisit(anotherPlace, Instant.now().minus(1, ChronoUnit.HOURS), Instant.now(), 3600L); + ProcessedVisit visit3 = createTestVisit(testPlace, Instant.now().minus(30, ChronoUnit.MINUTES), Instant.now(), 1800L); + + // When + processedVisitJdbcService.deleteAll(List.of(visit1, visit2)); + + // Then + assertTrue(processedVisitJdbcService.findById(visit1.getId()).isEmpty()); + assertTrue(processedVisitJdbcService.findById(visit2.getId()).isEmpty()); + assertTrue(processedVisitJdbcService.findById(visit3.getId()).isPresent()); + } + + @Test + void deleteAll_WithEmptyList_ShouldNotThrow() { + // When/Then + assertDoesNotThrow(() -> processedVisitJdbcService.deleteAll(List.of())); + assertDoesNotThrow(() -> processedVisitJdbcService.deleteAll(null)); + } + + @Test + void bulkInsert_ShouldInsertMultipleVisits() { + // Given + Instant baseTime = Instant.now().minus(4, ChronoUnit.HOURS); + List visitsToInsert = List.of( + new ProcessedVisit(testPlace, baseTime, baseTime.plus(1, ChronoUnit.HOURS), 3600L), + new ProcessedVisit(anotherPlace, baseTime.plus(2, ChronoUnit.HOURS), baseTime.plus(3, ChronoUnit.HOURS), 3600L) + ); + + // When + List inserted = processedVisitJdbcService.bulkInsert(testUser, visitsToInsert); + + // Then + assertEquals(2, inserted.size()); + assertTrue(inserted.stream().allMatch(v -> v.getId() != null)); + + List allVisits = processedVisitJdbcService.findByUser(testUser); + assertEquals(2, allVisits.size()); + } + + @Test + void bulkInsert_WithEmptyList_ShouldReturnEmptyList() { + // When + List result = processedVisitJdbcService.bulkInsert(testUser, List.of()); + + // Then + assertTrue(result.isEmpty()); + } + + @Test + void deleteAllForUser_ShouldDeleteOnlyUserVisits() { + // Given + User anotherUser = testingService.randomUser(); + SignificantPlace anotherUserPlace = createTestPlaceForUser(anotherUser, "Other Place", 53.865149, 10.702927); + + createTestVisit(testPlace, Instant.now().minus(1, ChronoUnit.HOURS), Instant.now(), 3600L); + processedVisitJdbcService.create(anotherUser, new ProcessedVisit(anotherUserPlace, Instant.now().minus(1, ChronoUnit.HOURS), Instant.now(), 3600L)); + + // When + processedVisitJdbcService.deleteAllForUser(testUser); + + // Then + assertTrue(processedVisitJdbcService.findByUser(testUser).isEmpty()); + assertEquals(1, processedVisitJdbcService.findByUser(anotherUser).size()); + } + + @Test + void getAffectedDays_ShouldReturnUniqueDates() { + // Given + Instant day1Start = Instant.parse("2023-12-01T10:00:00Z"); + Instant day1End = Instant.parse("2023-12-01T15:00:00Z"); + Instant day2Start = Instant.parse("2023-12-02T09:00:00Z"); + Instant day2End = Instant.parse("2023-12-02T17:00:00Z"); + + createTestVisit(testPlace, day1Start, day1End, 18000L); + createTestVisit(testPlace, day2Start, day2End, 28800L); + createTestVisit(anotherPlace, day1Start, day1End, 18000L); + + // When + List affectedDays = processedVisitJdbcService.getAffectedDays(List.of(testPlace, anotherPlace)); + + // Then + assertEquals(2, affectedDays.size()); + assertTrue(affectedDays.contains(LocalDate.of(2023, 12, 1))); + assertTrue(affectedDays.contains(LocalDate.of(2023, 12, 2))); + } + + @Test + void getAffectedDays_WithEmptyPlaceList_ShouldReturnEmptyList() { + // When + List affectedDays = processedVisitJdbcService.getAffectedDays(List.of()); + + // Then + assertTrue(affectedDays.isEmpty()); + } + + private ProcessedVisit createTestVisit(SignificantPlace place, Instant startTime, Instant endTime, Long duration) { + ProcessedVisit visit = new ProcessedVisit(place, startTime, endTime, duration); + return processedVisitJdbcService.create(testUser, visit); + } + + private SignificantPlace createTestPlace(String name, double latitude, double longitude) { + return createTestPlaceForUser(testUser, name, latitude, longitude); + } + + private SignificantPlace createTestPlaceForUser(User user, String name, double latitude, double longitude) { + return placeJdbcService.create(user, new SignificantPlace( + null, + name, + null, + null, + null, + latitude, + longitude, + null, + SignificantPlace.PlaceType.OTHER, + ZoneId.systemDefault(), + false, + 0L + )); + } +} diff --git a/src/test/java/com/dedicatedcode/reitti/repository/RawLocationPointJdbcServiceTest.java b/src/test/java/com/dedicatedcode/reitti/repository/RawLocationPointJdbcServiceTest.java new file mode 100644 index 00000000..ca9e325f --- /dev/null +++ b/src/test/java/com/dedicatedcode/reitti/repository/RawLocationPointJdbcServiceTest.java @@ -0,0 +1,220 @@ +package com.dedicatedcode.reitti.repository; + +import com.dedicatedcode.reitti.IntegrationTest; +import com.dedicatedcode.reitti.TestingService; +import com.dedicatedcode.reitti.model.geo.GeoPoint; +import com.dedicatedcode.reitti.model.geo.RawLocationPoint; +import com.dedicatedcode.reitti.model.security.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@IntegrationTest +class RawLocationPointJdbcServiceTest { + + @Autowired + private RawLocationPointJdbcService rawLocationPointJdbcService; + + @Autowired + private TestingService testingService; + + private User testUser; + private User anotherUser; + + @BeforeEach + void setUp() { + testingService.clearData(); + testUser = testingService.randomUser(); + anotherUser = testingService.randomUser(); + } + + @Test + void markAllAsUnprocessedForUser_WithSpecificDates_ShouldOnlyMarkPointsOnThoseDays() { + // Given + Instant day1 = LocalDate.of(2023, 12, 1).atStartOfDay().toInstant(ZoneOffset.UTC); + Instant day2 = LocalDate.of(2023, 12, 2).atStartOfDay().toInstant(ZoneOffset.UTC); + Instant day3 = LocalDate.of(2023, 12, 3).atStartOfDay().toInstant(ZoneOffset.UTC); + + // Create points on different days, all initially processed + RawLocationPoint point1Day1 = createProcessedPoint(testUser, day1.plus(10, ChronoUnit.HOURS)); + RawLocationPoint point2Day1 = createProcessedPoint(testUser, day1.plus(14, ChronoUnit.HOURS)); + RawLocationPoint point1Day2 = createProcessedPoint(testUser, day2.plus(9, ChronoUnit.HOURS)); + RawLocationPoint point1Day3 = createProcessedPoint(testUser, day3.plus(11, ChronoUnit.HOURS)); + + // Create point for another user (should not be affected) + RawLocationPoint anotherUserPoint = createProcessedPoint(anotherUser, day1.plus(12, ChronoUnit.HOURS)); + + // Verify all points are initially processed + assertTrue(findPointById(point1Day1.getId()).isProcessed()); + assertTrue(findPointById(point2Day1.getId()).isProcessed()); + assertTrue(findPointById(point1Day2.getId()).isProcessed()); + assertTrue(findPointById(point1Day3.getId()).isProcessed()); + assertTrue(findPointById(anotherUserPoint.getId()).isProcessed()); + + // When - mark only day 1 and day 2 as unprocessed + List affectedDays = List.of( + LocalDate.of(2023, 12, 1), + LocalDate.of(2023, 12, 2) + ); + rawLocationPointJdbcService.markAllAsUnprocessedForUser(testUser, affectedDays); + + // Then + // Points on day 1 and day 2 should be unprocessed + assertFalse(findPointById(point1Day1.getId()).isProcessed()); + assertFalse(findPointById(point2Day1.getId()).isProcessed()); + assertFalse(findPointById(point1Day2.getId()).isProcessed()); + + // Point on day 3 should still be processed + assertTrue(findPointById(point1Day3.getId()).isProcessed()); + + // Another user's point should not be affected + assertTrue(findPointById(anotherUserPoint.getId()).isProcessed()); + } + + @Test + void markAllAsUnprocessedForUser_WithEmptyDateList_ShouldNotMarkAnyPoints() { + // Given + Instant day1 = LocalDate.of(2023, 12, 1).atStartOfDay().toInstant(ZoneOffset.UTC); + RawLocationPoint point = createProcessedPoint(testUser, day1.plus(10, ChronoUnit.HOURS)); + + assertTrue(findPointById(point.getId()).isProcessed()); + + // When + rawLocationPointJdbcService.markAllAsUnprocessedForUser(testUser, List.of()); + + // Then + assertTrue(findPointById(point.getId()).isProcessed()); + } + + @Test + void markAllAsUnprocessedForUser_WithNonExistentDates_ShouldNotMarkAnyPoints() { + // Given + Instant day1 = LocalDate.of(2023, 12, 1).atStartOfDay().toInstant(ZoneOffset.UTC); + RawLocationPoint point = createProcessedPoint(testUser, day1.plus(10, ChronoUnit.HOURS)); + + assertTrue(findPointById(point.getId()).isProcessed()); + + // When - mark a different date + List affectedDays = List.of(LocalDate.of(2023, 12, 15)); + rawLocationPointJdbcService.markAllAsUnprocessedForUser(testUser, affectedDays); + + // Then + assertTrue(findPointById(point.getId()).isProcessed()); + } + + @Test + void markAllAsUnprocessedForUser_WithPointsAlreadyUnprocessed_ShouldRemainUnprocessed() { + // Given + Instant day1 = LocalDate.of(2023, 12, 1).atStartOfDay().toInstant(ZoneOffset.UTC); + RawLocationPoint unprocessedPoint = createUnprocessedPoint(testUser, day1.plus(10, ChronoUnit.HOURS)); + RawLocationPoint processedPoint = createProcessedPoint(testUser, day1.plus(14, ChronoUnit.HOURS)); + + assertFalse(findPointById(unprocessedPoint.getId()).isProcessed()); + assertTrue(findPointById(processedPoint.getId()).isProcessed()); + + // When + List affectedDays = List.of(LocalDate.of(2023, 12, 1)); + rawLocationPointJdbcService.markAllAsUnprocessedForUser(testUser, affectedDays); + + // Then + assertFalse(findPointById(unprocessedPoint.getId()).isProcessed()); + assertFalse(findPointById(processedPoint.getId()).isProcessed()); + } + + @Test + void markAllAsUnprocessedForUser_WithMultipleDaysAndUsers_ShouldOnlyAffectCorrectUserAndDays() { + // Given + Instant day1 = LocalDate.of(2023, 12, 1).atStartOfDay().toInstant(ZoneOffset.UTC); + Instant day2 = LocalDate.of(2023, 12, 2).atStartOfDay().toInstant(ZoneOffset.UTC); + Instant day3 = LocalDate.of(2023, 12, 3).atStartOfDay().toInstant(ZoneOffset.UTC); + + // Test user points + RawLocationPoint testUserDay1 = createProcessedPoint(testUser, day1.plus(10, ChronoUnit.HOURS)); + RawLocationPoint testUserDay2 = createProcessedPoint(testUser, day2.plus(10, ChronoUnit.HOURS)); + RawLocationPoint testUserDay3 = createProcessedPoint(testUser, day3.plus(10, ChronoUnit.HOURS)); + + // Another user points + RawLocationPoint anotherUserDay1 = createProcessedPoint(anotherUser, day1.plus(10, ChronoUnit.HOURS)); + RawLocationPoint anotherUserDay2 = createProcessedPoint(anotherUser, day2.plus(10, ChronoUnit.HOURS)); + + // When - mark only day 1 and day 2 for test user + List affectedDays = List.of( + LocalDate.of(2023, 12, 1), + LocalDate.of(2023, 12, 2) + ); + rawLocationPointJdbcService.markAllAsUnprocessedForUser(testUser, affectedDays); + + // Then + // Test user's points on affected days should be unprocessed + assertFalse(findPointById(testUserDay1.getId()).isProcessed()); + assertFalse(findPointById(testUserDay2.getId()).isProcessed()); + + // Test user's point on unaffected day should remain processed + assertTrue(findPointById(testUserDay3.getId()).isProcessed()); + + // Another user's points should not be affected + assertTrue(findPointById(anotherUserDay1.getId()).isProcessed()); + assertTrue(findPointById(anotherUserDay2.getId()).isProcessed()); + } + + private RawLocationPoint createProcessedPoint(User user, Instant timestamp) { + RawLocationPoint point = new RawLocationPoint( + null, + timestamp, + new GeoPoint(53.863149, 10.700927), + 10.0, + null, + false, // will be set to true after creation + false, + false, + 1L + ); + + RawLocationPoint created = rawLocationPointJdbcService.create(user, point); + + // Mark as processed + RawLocationPoint processed = new RawLocationPoint( + created.getId(), + created.getTimestamp(), + created.getGeom(), + created.getAccuracyMeters(), + created.getElevationMeters(), + true, // processed = true + created.isSynthetic(), + created.isIgnored(), + created.getVersion() + ); + + return rawLocationPointJdbcService.update(processed); + } + + private RawLocationPoint createUnprocessedPoint(User user, Instant timestamp) { + RawLocationPoint point = new RawLocationPoint( + null, + timestamp, + new GeoPoint(53.863149, 10.700927), + 10.0, + null, + false, // processed = false + false, + false, + 1L + ); + + return rawLocationPointJdbcService.create(user, point); + } + + private RawLocationPoint findPointById(Long id) { + return rawLocationPointJdbcService.findById(id) + .orElseThrow(() -> new RuntimeException("Point not found: " + id)); + } +} diff --git a/src/test/java/com/dedicatedcode/reitti/repository/SignificantPlaceJdbcServiceTest.java b/src/test/java/com/dedicatedcode/reitti/repository/SignificantPlaceJdbcServiceTest.java index 878466f1..bd2ee9e4 100644 --- a/src/test/java/com/dedicatedcode/reitti/repository/SignificantPlaceJdbcServiceTest.java +++ b/src/test/java/com/dedicatedcode/reitti/repository/SignificantPlaceJdbcServiceTest.java @@ -4,6 +4,7 @@ import com.dedicatedcode.reitti.IntegrationTest; import com.dedicatedcode.reitti.model.Page; import com.dedicatedcode.reitti.model.PageRequest; import com.dedicatedcode.reitti.model.Role; +import com.dedicatedcode.reitti.model.geo.GeoPoint; import com.dedicatedcode.reitti.model.geo.SignificantPlace; import com.dedicatedcode.reitti.model.security.User; import org.junit.jupiter.api.BeforeEach; @@ -83,7 +84,7 @@ class SignificantPlaceJdbcServiceTest { } @Test - void findNearbyPlaces_shouldReturnPlacesWithinDistance() { + void findEnclosingPlaces_shouldReturnPlacesWithinDistance() { // Given Point centerPoint = geometryFactory.createPoint(new Coordinate(10.700927, 53.863149)); SignificantPlace nearPlace = createTestPlace("Near Place", 53.863200, 10.701000); // ~50m away @@ -93,7 +94,7 @@ class SignificantPlaceJdbcServiceTest { significantPlaceJdbcService.create(testUser, farPlace); // When - List nearbyPlaces = significantPlaceJdbcService.findNearbyPlaces( + List nearbyPlaces = significantPlaceJdbcService.findEnclosingPlaces( testUser.getId(), centerPoint, 0.003); // Then @@ -130,6 +131,7 @@ class SignificantPlaceJdbcServiceTest { "DE", 53.863149, 10.700927, + null, SignificantPlace.PlaceType.RESTAURANT, ZoneId.systemDefault(), true, @@ -215,6 +217,7 @@ class SignificantPlaceJdbcServiceTest { "DE", created1.getLatitudeCentroid(), created1.getLongitudeCentroid(), + null, SignificantPlace.PlaceType.HOME, ZoneId.systemDefault(), true, // geocoded = true @@ -252,6 +255,291 @@ class SignificantPlaceJdbcServiceTest { .containsExactly("Place 1", "Place 2"); } + @Test + void create_shouldPersistNewPlaceWithPolygon() { + // Given + List polygon = List.of( + GeoPoint.from(53.863149, 10.700927), + GeoPoint.from(53.863200, 10.701000), + GeoPoint.from(53.863100, 10.701100), + GeoPoint.from(53.863050, 10.700950), + GeoPoint.from(53.863149, 10.700927) // Close the polygon + ); + + SignificantPlace newPlace = new SignificantPlace( + null, + "Place with Polygon", + null, + null, + null, + 53.863149, + 10.700927, + polygon, + SignificantPlace.PlaceType.PARK, + ZoneId.systemDefault(), + false, + 0L + ); + + // When + SignificantPlace created = significantPlaceJdbcService.create(testUser, newPlace); + + // Then + assertThat(created.getId()).isNotNull(); + assertThat(created.getName()).isEqualTo("Place with Polygon"); + assertThat(created.getPolygon()).isNotNull(); + assertThat(created.getPolygon()).hasSize(5); + assertThat(created.getPolygon().get(0).latitude()).isEqualTo(53.863149); + assertThat(created.getPolygon().get(0).longitude()).isEqualTo(10.700927); + } + + @Test + void create_shouldPersistNewPlaceWithoutPolygon() { + // Given + SignificantPlace newPlace = new SignificantPlace( + null, + "Place without Polygon", + null, + null, + null, + 53.863149, + 10.700927, + null, // No polygon + SignificantPlace.PlaceType.OTHER, + ZoneId.systemDefault(), + false, + 0L + ); + + // When + SignificantPlace created = significantPlaceJdbcService.create(testUser, newPlace); + + // Then + assertThat(created.getId()).isNotNull(); + assertThat(created.getName()).isEqualTo("Place without Polygon"); + assertThat(created.getPolygon()).isNull(); + } + + @Test + void update_shouldAddPolygonToExistingPlace() { + // Given - Create place without polygon + SignificantPlace originalPlace = createTestPlace("Original Place", 53.863149, 10.700927); + SignificantPlace created = significantPlaceJdbcService.create(testUser, originalPlace); + + // Create polygon to add + List polygon = List.of( + GeoPoint.from(53.863149, 10.700927), + GeoPoint.from(53.863200, 10.701000), + GeoPoint.from(53.863100, 10.701100), + GeoPoint.from(53.863149, 10.700927) // Close the polygon + ); + + SignificantPlace updatedPlace = new SignificantPlace( + created.getId(), + created.getName(), + "Updated Address", + "Berlin", + "DE", + created.getLatitudeCentroid(), + created.getLongitudeCentroid(), + polygon, // Add polygon + SignificantPlace.PlaceType.PARK, + ZoneId.systemDefault(), + true, + created.getVersion() + ); + + // When + SignificantPlace result = significantPlaceJdbcService.update(updatedPlace); + + // Then + assertThat(result.getPolygon()).isNotNull(); + assertThat(result.getPolygon()).hasSize(4); + assertThat(result.getPolygon().get(0).latitude()).isEqualTo(53.863149); + assertThat(result.getPolygon().get(0).longitude()).isEqualTo(10.700927); + assertThat(result.getAddress()).isEqualTo("Updated Address"); + assertThat(result.getType()).isEqualTo(SignificantPlace.PlaceType.PARK); + } + + @Test + void update_shouldRemovePolygonFromExistingPlace() { + // Given - Create place with polygon + List originalPolygon = List.of( + GeoPoint.from(53.863149, 10.700927), + GeoPoint.from(53.863200, 10.701000), + GeoPoint.from(53.863100, 10.701100), + GeoPoint.from(53.863149, 10.700927) + ); + + SignificantPlace originalPlace = new SignificantPlace( + null, + "Place with Polygon", + null, + null, + null, + 53.863149, + 10.700927, + originalPolygon, + SignificantPlace.PlaceType.PARK, + ZoneId.systemDefault(), + false, + 0L + ); + + SignificantPlace created = significantPlaceJdbcService.create(testUser, originalPlace); + + // Update to remove polygon + SignificantPlace updatedPlace = new SignificantPlace( + created.getId(), + created.getName(), + "Updated Address", + "Berlin", + "DE", + created.getLatitudeCentroid(), + created.getLongitudeCentroid(), + null, // Remove polygon + SignificantPlace.PlaceType.RESTAURANT, + ZoneId.systemDefault(), + true, + created.getVersion() + ); + + // When + SignificantPlace result = significantPlaceJdbcService.update(updatedPlace); + + // Then + assertThat(result.getPolygon()).isNull(); + assertThat(result.getAddress()).isEqualTo("Updated Address"); + assertThat(result.getType()).isEqualTo(SignificantPlace.PlaceType.RESTAURANT); + } + + @Test + void update_shouldModifyExistingPolygon() { + // Given - Create place with polygon + List originalPolygon = List.of( + GeoPoint.from(53.863149, 10.700927), + GeoPoint.from(53.863200, 10.701000), + GeoPoint.from(53.863100, 10.701100), + GeoPoint.from(53.863149, 10.700927) + ); + + SignificantPlace originalPlace = new SignificantPlace( + null, + "Place with Polygon", + null, + null, + null, + 53.863149, + 10.700927, + originalPolygon, + SignificantPlace.PlaceType.PARK, + ZoneId.systemDefault(), + false, + 0L + ); + + SignificantPlace created = significantPlaceJdbcService.create(testUser, originalPlace); + + // Create modified polygon + List modifiedPolygon = List.of( + GeoPoint.from(53.863149, 10.700927), + GeoPoint.from(53.863250, 10.701050), // Different coordinates + GeoPoint.from(53.863150, 10.701150), // Different coordinates + GeoPoint.from(53.863080, 10.700980), // Different coordinates + GeoPoint.from(53.863149, 10.700927) + ); + + SignificantPlace updatedPlace = new SignificantPlace( + created.getId(), + created.getName(), + created.getAddress(), + created.getCity(), + created.getCountryCode(), + created.getLatitudeCentroid(), + created.getLongitudeCentroid(), + modifiedPolygon, // Modified polygon + created.getType(), + created.getTimezone(), + created.isGeocoded(), + created.getVersion() + ); + + // When + SignificantPlace result = significantPlaceJdbcService.update(updatedPlace); + + // Then + assertThat(result.getPolygon()).isNotNull(); + assertThat(result.getPolygon()).hasSize(5); + assertThat(result.getPolygon().get(1).latitude()).isEqualTo(53.863250); + assertThat(result.getPolygon().get(1).longitude()).isEqualTo(10.701050); + assertThat(result.getPolygon().get(2).latitude()).isEqualTo(53.863150); + assertThat(result.getPolygon().get(2).longitude()).isEqualTo(10.701150); + } + + @Test + void findNearbyPlaces_shouldFindPlacesWithPolygons() { + // Given + Point searchPoint = geometryFactory.createPoint(new Coordinate(10.700950, 53.863120)); + + // Create a place with polygon that contains the search point + List polygon = List.of( + GeoPoint.from(53.863100, 10.700900), + GeoPoint.from(53.863200, 10.701000), + GeoPoint.from(53.863100, 10.701100), + GeoPoint.from(53.863000, 10.701000), + GeoPoint.from(53.863100, 10.700900) // Close the polygon + ); + + SignificantPlace placeWithPolygon = new SignificantPlace( + null, + "Place with Polygon", + null, + null, + null, + 53.863100, + 10.701000, + polygon, + SignificantPlace.PlaceType.PARK, + ZoneId.systemDefault(), + false, + 0L + ); + + // Create a place without polygon that's far away + SignificantPlace farPlace = createTestPlace("Far Place", 53.870000, 10.720000); + + significantPlaceJdbcService.create(testUser, placeWithPolygon); + significantPlaceJdbcService.create(testUser, farPlace); + + // When + List nearbyPlaces = significantPlaceJdbcService.findEnclosingPlaces( + testUser.getId(), searchPoint, 0.001); // Small buffer for places without polygons + + // Then + assertThat(nearbyPlaces).hasSize(1); + assertThat(nearbyPlaces.get(0).getName()).isEqualTo("Place with Polygon"); + assertThat(nearbyPlaces.get(0).getPolygon()).isNotNull(); + assertThat(nearbyPlaces.get(0).getPolygon()).hasSize(5); + } + + // Helper method to create test place with polygon + private SignificantPlace createTestPlaceWithPolygon(String name, double latitude, double longitude, List polygon) { + return new SignificantPlace( + null, + name, + null, + null, + null, + latitude, + longitude, + polygon, + SignificantPlace.PlaceType.OTHER, + ZoneId.systemDefault(), + false, + 0L + ); + } + private User createTestUser(String username, String displayName) { Long userId = jdbcTemplate.queryForObject( "INSERT INTO users (username, password, display_name, role) VALUES (?, ?, ?, ?) RETURNING id", @@ -270,6 +558,7 @@ class SignificantPlaceJdbcServiceTest { null, latitude, longitude, + null, SignificantPlace.PlaceType.OTHER, ZoneId.systemDefault() , false, @@ -286,6 +575,7 @@ class SignificantPlaceJdbcServiceTest { null, latitude, longitude, + null, SignificantPlace.PlaceType.OTHER, ZoneId.systemDefault(), false, diff --git a/src/test/java/com/dedicatedcode/reitti/repository/SignificantPlaceOverrideJdbcServiceTest.java b/src/test/java/com/dedicatedcode/reitti/repository/SignificantPlaceOverrideJdbcServiceTest.java index d764eda9..c067eaeb 100644 --- a/src/test/java/com/dedicatedcode/reitti/repository/SignificantPlaceOverrideJdbcServiceTest.java +++ b/src/test/java/com/dedicatedcode/reitti/repository/SignificantPlaceOverrideJdbcServiceTest.java @@ -33,7 +33,7 @@ class SignificantPlaceOverrideJdbcServiceTest { GeoPoint point = new GeoPoint(40.7128, -74.0060); // Example: New York coordinates // Create a SignificantPlace with the override details - SignificantPlace place = new SignificantPlace(1L, "Home Override", "123 Main St", "New York", "US", 40.7128, -74.0060, PlaceType.HOME, ZoneId.of("America/New_York"), false, 1L); + SignificantPlace place = new SignificantPlace(1L, "Home Override", "123 Main St", "New York", "US", 40.7128, -74.0060, null, PlaceType.HOME, ZoneId.of("America/New_York"), false, 1L); // Insert the override using the service significantPlaceOverrideJdbcService.insertOverride(user, place); @@ -67,7 +67,7 @@ class SignificantPlaceOverrideJdbcServiceTest { User user = testingService.randomUser(); // Create a SignificantPlace - SignificantPlace place = new SignificantPlace(1L, "Test Place", "123 Test St", "Test City", "US", 40.7128, -74.0060, PlaceType.HOME, ZoneId.of("America/New_York"), false, 1L); + SignificantPlace place = new SignificantPlace(1L, "Test Place", "123 Test St", "Test City", "US", 40.7128, -74.0060, null, PlaceType.HOME, ZoneId.of("America/New_York"), false, 1L); // Insert the override significantPlaceOverrideJdbcService.insertOverride(user, place); @@ -88,7 +88,7 @@ class SignificantPlaceOverrideJdbcServiceTest { User user = testingService.randomUser(); // Create a SignificantPlace - SignificantPlace place = new SignificantPlace(1L, "Test Place", "123 Test St", "Test City", "US", 40.7128, -74.0060, PlaceType.HOME, ZoneId.of("America/New_York"), false, 1L); + SignificantPlace place = new SignificantPlace(1L, "Test Place", "123 Test St", "Test City", "US", 40.7128, -74.0060, null, PlaceType.HOME, ZoneId.of("America/New_York"), false, 1L); // Insert the override significantPlaceOverrideJdbcService.insertOverride(user, place); @@ -112,7 +112,7 @@ class SignificantPlaceOverrideJdbcServiceTest { User user = testingService.randomUser(); // Create a SignificantPlace at a specific location - SignificantPlace place = new SignificantPlace(1L, "Nearby Override", "456 Nearby St", "Nearby City", "US", 40.7128, -74.0060, PlaceType.WORK, ZoneId.of("America/New_York"), false, 1L); + SignificantPlace place = new SignificantPlace(1L, "Nearby Override", "456 Nearby St", "Nearby City", "US", 40.7128, -74.0060, null, PlaceType.WORK, ZoneId.of("America/New_York"), false, 1L); significantPlaceOverrideJdbcService.insertOverride(user, place); // Create a GeoPoint very close (within 5m) to the place @@ -130,7 +130,7 @@ class SignificantPlaceOverrideJdbcServiceTest { User user = testingService.randomUser(); // Insert first override - SignificantPlace place1 = new SignificantPlace(1L, "First Override", "123 First St", "First City", "US", 40.7128, -74.0060, PlaceType.HOME, ZoneId.of("America/New_York"), false, 1L); + SignificantPlace place1 = new SignificantPlace(1L, "First Override", "123 First St", "First City", "US", 40.7128, -74.0060, null, PlaceType.HOME, ZoneId.of("America/New_York"), false, 1L); significantPlaceOverrideJdbcService.insertOverride(user, place1); // Verify first override exists @@ -139,7 +139,7 @@ class SignificantPlaceOverrideJdbcServiceTest { assertTrue(result1.isPresent()); // Insert second override very close (within 5m) - SignificantPlace place2 = new SignificantPlace(2L, "Second Override", "456 Second St", "Second City", "US", 40.7128442, -74.0060, PlaceType.WORK, ZoneId.of("America/New_York"), false, 1L); + SignificantPlace place2 = new SignificantPlace(2L, "Second Override", "456 Second St", "Second City", "US", 40.7128442, -74.0060, null, PlaceType.WORK, ZoneId.of("America/New_York"), false, 1L); significantPlaceOverrideJdbcService.insertOverride(user, place2); // Verify second override exists diff --git a/src/test/java/com/dedicatedcode/reitti/service/DataCleanupServiceTest.java b/src/test/java/com/dedicatedcode/reitti/service/DataCleanupServiceTest.java new file mode 100644 index 00000000..b589fb5b --- /dev/null +++ b/src/test/java/com/dedicatedcode/reitti/service/DataCleanupServiceTest.java @@ -0,0 +1,332 @@ +package com.dedicatedcode.reitti.service; + +import com.dedicatedcode.reitti.IntegrationTest; +import com.dedicatedcode.reitti.TestingService; +import com.dedicatedcode.reitti.model.geo.*; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService; +import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; +import com.dedicatedcode.reitti.repository.SignificantPlaceJdbcService; +import com.dedicatedcode.reitti.repository.TripJdbcService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@IntegrationTest +class DataCleanupServiceTest { + + @Autowired + private DataCleanupService dataCleanupService; + + @Autowired + private TripJdbcService tripJdbcService; + + @Autowired + private ProcessedVisitJdbcService processedVisitJdbcService; + + @Autowired + private SignificantPlaceJdbcService placeJdbcService; + + @Autowired + private RawLocationPointJdbcService rawLocationPointJdbcService; + + @Autowired + private TestingService testingService; + + private User testUser; + private User anotherUser; + private SignificantPlace placeToRemove1; + private SignificantPlace placeToRemove2; + private SignificantPlace placeToKeep; + + @BeforeEach + void setUp() { + testingService.clearData(); + testUser = testingService.randomUser(); + anotherUser = testingService.randomUser(); + + // Create test places + placeToRemove1 = createTestPlace(testUser, "Place to Remove 1", 53.863149, 10.700927); + placeToRemove2 = createTestPlace(testUser, "Place to Remove 2", 53.864149, 10.701927); + placeToKeep = createTestPlace(testUser, "Place to Keep", 53.865149, 10.702927); + } + + @Test + void cleanupForGeometryChange_ShouldRemoveTripsForSpecifiedPlaces() { + // Given + Instant baseTime = Instant.now().minus(4, ChronoUnit.HOURS); + + // Create visits for places + ProcessedVisit visitToRemove1 = createTestVisit(placeToRemove1, baseTime, baseTime.plus(1, ChronoUnit.HOURS)); + ProcessedVisit visitToRemove2 = createTestVisit(placeToRemove2, baseTime.plus(2, ChronoUnit.HOURS), baseTime.plus(3, ChronoUnit.HOURS)); + ProcessedVisit visitToKeep = createTestVisit(placeToKeep, baseTime.plus(4, ChronoUnit.HOURS), baseTime.plus(5, ChronoUnit.HOURS)); + + // Create trips between places + Trip tripToRemove1 = createTestTrip(visitToRemove1, visitToRemove2); + Trip tripToRemove2 = createTestTrip(visitToRemove2, visitToKeep); + Trip tripToKeep = createTestTrip(visitToKeep, visitToKeep); // Self-trip or different scenario + + // When + List placesToRemove = List.of(placeToRemove1, placeToRemove2); + List affectedDays = List.of(LocalDate.now()); + dataCleanupService.cleanupForGeometryChange(testUser, placesToRemove, affectedDays); + + // Then + // Trips involving removed places should be deleted + assertTrue(tripJdbcService.findById(tripToRemove1.getId()).isEmpty()); + assertTrue(tripJdbcService.findById(tripToRemove2.getId()).isEmpty()); + + // Trips not involving removed places should remain + assertTrue(tripJdbcService.findById(tripToKeep.getId()).isPresent()); + } + + @Test + void cleanupForGeometryChange_ShouldRemoveVisitsForSpecifiedPlaces() { + // Given + Instant baseTime = Instant.now().minus(4, ChronoUnit.HOURS); + + ProcessedVisit visitToRemove1 = createTestVisit(placeToRemove1, baseTime, baseTime.plus(1, ChronoUnit.HOURS)); + ProcessedVisit visitToRemove2 = createTestVisit(placeToRemove2, baseTime.plus(2, ChronoUnit.HOURS), baseTime.plus(3, ChronoUnit.HOURS)); + ProcessedVisit visitToKeep = createTestVisit(placeToKeep, baseTime.plus(4, ChronoUnit.HOURS), baseTime.plus(5, ChronoUnit.HOURS)); + + // When + List placesToRemove = List.of(placeToRemove1, placeToRemove2); + List affectedDays = List.of(LocalDate.now()); + dataCleanupService.cleanupForGeometryChange(testUser, placesToRemove, affectedDays); + + // Then + // Visits for removed places should be deleted + assertTrue(processedVisitJdbcService.findById(visitToRemove1.getId()).isEmpty()); + assertTrue(processedVisitJdbcService.findById(visitToRemove2.getId()).isEmpty()); + + // Visits for kept places should remain + assertTrue(processedVisitJdbcService.findById(visitToKeep.getId()).isPresent()); + } + + @Test + void cleanupForGeometryChange_ShouldNotRemoveVisitsForPlacesNotInRemovalList() { + // Given + Instant baseTime = Instant.now().minus(4, ChronoUnit.HOURS); + + // Create visits for all places + ProcessedVisit visitToRemove = createTestVisit(placeToRemove1, baseTime, baseTime.plus(1, ChronoUnit.HOURS)); + ProcessedVisit visitToKeep1 = createTestVisit(placeToKeep, baseTime.plus(2, ChronoUnit.HOURS), baseTime.plus(3, ChronoUnit.HOURS)); + ProcessedVisit visitToKeep2 = createTestVisit(placeToKeep, baseTime.plus(4, ChronoUnit.HOURS), baseTime.plus(5, ChronoUnit.HOURS)); + + // Create visit for another user (should not be affected) + SignificantPlace anotherUserPlace = createTestPlace(anotherUser, "Another User Place", 53.866149, 10.703927); + ProcessedVisit anotherUserVisit = createTestVisit(anotherUserPlace, baseTime, baseTime.plus(1, ChronoUnit.HOURS)); + + // When - only remove placeToRemove1 + List placesToRemove = List.of(placeToRemove1); + List affectedDays = List.of(LocalDate.now()); + dataCleanupService.cleanupForGeometryChange(testUser, placesToRemove, affectedDays); + + // Then + // Only visit for removed place should be deleted + assertTrue(processedVisitJdbcService.findById(visitToRemove.getId()).isEmpty()); + + // Visits for places not in removal list should remain + assertTrue(processedVisitJdbcService.findById(visitToKeep1.getId()).isPresent()); + assertTrue(processedVisitJdbcService.findById(visitToKeep2.getId()).isPresent()); + + // Another user's visits should not be affected + assertTrue(processedVisitJdbcService.findById(anotherUserVisit.getId()).isPresent()); + } + + @Test + void cleanupForGeometryChange_ShouldRemoveSpecifiedPlaces() { + // Given + SignificantPlace anotherUserPlace = createTestPlace(anotherUser, "Another User Place", 53.866149, 10.703927); + + // When + List placesToRemove = List.of(placeToRemove1, placeToRemove2); + List affectedDays = List.of(LocalDate.now()); + dataCleanupService.cleanupForGeometryChange(testUser, placesToRemove, affectedDays); + + // Then + // Specified places should be deleted + assertTrue(placeJdbcService.findById(placeToRemove1.getId()).isEmpty()); + assertTrue(placeJdbcService.findById(placeToRemove2.getId()).isEmpty()); + + // Places not in removal list should remain + assertTrue(placeJdbcService.findById(placeToKeep.getId()).isPresent()); + + // Another user's places should not be affected + assertTrue(placeJdbcService.findById(anotherUserPlace.getId()).isPresent()); + } + + @Test + void cleanupForGeometryChange_ShouldMarkRawLocationPointsAsUnprocessedForAffectedDays() { + // Given + LocalDate day1 = LocalDate.of(2023, 12, 1); + LocalDate day2 = LocalDate.of(2023, 12, 2); + LocalDate day3 = LocalDate.of(2023, 12, 3); + + Instant day1Time = day1.atStartOfDay().toInstant(ZoneOffset.UTC).plus(10, ChronoUnit.HOURS); + Instant day2Time = day2.atStartOfDay().toInstant(ZoneOffset.UTC).plus(10, ChronoUnit.HOURS); + Instant day3Time = day3.atStartOfDay().toInstant(ZoneOffset.UTC).plus(10, ChronoUnit.HOURS); + + // Create processed points on different days + RawLocationPoint pointDay1 = createProcessedPoint(testUser, day1Time); + RawLocationPoint pointDay2 = createProcessedPoint(testUser, day2Time); + RawLocationPoint pointDay3 = createProcessedPoint(testUser, day3Time); + + // Create point for another user (should not be affected) + RawLocationPoint anotherUserPoint = createProcessedPoint(anotherUser, day1Time); + + // Verify all points are initially processed + assertTrue(findPointById(pointDay1.getId()).isProcessed()); + assertTrue(findPointById(pointDay2.getId()).isProcessed()); + assertTrue(findPointById(pointDay3.getId()).isProcessed()); + assertTrue(findPointById(anotherUserPoint.getId()).isProcessed()); + + // When - cleanup with day1 and day2 as affected days + List placesToRemove = List.of(placeToRemove1); + List affectedDays = List.of(day1, day2); + dataCleanupService.cleanupForGeometryChange(testUser, placesToRemove, affectedDays); + + // Then + // Points on affected days should be marked as unprocessed + assertFalse(findPointById(pointDay1.getId()).isProcessed()); + assertFalse(findPointById(pointDay2.getId()).isProcessed()); + + // Points on unaffected days should remain processed + assertTrue(findPointById(pointDay3.getId()).isProcessed()); + + // Another user's points should not be affected + assertTrue(findPointById(anotherUserPoint.getId()).isProcessed()); + } + + @Test + void cleanupForGeometryChange_WithEmptyPlacesList_ShouldOnlyMarkPointsAsUnprocessed() { + // Given + Instant baseTime = Instant.now().minus(4, ChronoUnit.HOURS); + ProcessedVisit visit = createTestVisit(placeToKeep, baseTime, baseTime.plus(1, ChronoUnit.HOURS)); + RawLocationPoint point = createProcessedPoint(testUser, baseTime); + + // When + List placesToRemove = List.of(); // Empty list + List affectedDays = List.of(LocalDate.now()); + dataCleanupService.cleanupForGeometryChange(testUser, placesToRemove, affectedDays); + + // Then + // Visit should remain (no places to remove) + assertTrue(processedVisitJdbcService.findById(visit.getId()).isPresent()); + + // Place should remain + assertTrue(placeJdbcService.findById(placeToKeep.getId()).isPresent()); + + // Point should be marked as unprocessed + assertFalse(findPointById(point.getId()).isProcessed()); + } + + @Test + void cleanupForGeometryChange_WithEmptyAffectedDays_ShouldRemovePlacesButNotMarkPoints() { + // Given + Instant baseTime = Instant.now().minus(4, ChronoUnit.HOURS); + ProcessedVisit visitToRemove = createTestVisit(placeToRemove1, baseTime, baseTime.plus(1, ChronoUnit.HOURS)); + RawLocationPoint point = createProcessedPoint(testUser, baseTime); + + // When + List placesToRemove = List.of(placeToRemove1); + List affectedDays = List.of(); // Empty list + dataCleanupService.cleanupForGeometryChange(testUser, placesToRemove, affectedDays); + + // Then + // Visit should be removed + assertTrue(processedVisitJdbcService.findById(visitToRemove.getId()).isEmpty()); + + // Place should be removed + assertTrue(placeJdbcService.findById(placeToRemove1.getId()).isEmpty()); + + // Point should remain processed (no affected days) + assertTrue(findPointById(point.getId()).isProcessed()); + } + + private SignificantPlace createTestPlace(User user, String name, double latitude, double longitude) { + return placeJdbcService.create(user, new SignificantPlace( + null, + name, + null, + null, + null, + latitude, + longitude, + List.of(), + SignificantPlace.PlaceType.OTHER, + ZoneId.systemDefault(), + true, + 0L + )); + } + + private ProcessedVisit createTestVisit(SignificantPlace place, Instant startTime, Instant endTime) { + long duration = ChronoUnit.SECONDS.between(startTime, endTime); + ProcessedVisit visit = new ProcessedVisit(place, startTime, endTime, duration); + return processedVisitJdbcService.create(testUser, visit); + } + + private Trip createTestTrip(ProcessedVisit startVisit, ProcessedVisit endVisit) { + long duration = ChronoUnit.SECONDS.between(startVisit.getEndTime(), endVisit.getStartTime()); + Trip trip = new Trip( + null, + startVisit.getEndTime(), + endVisit.getStartTime(), + duration, + 1000.0, + 1200.0, + TransportMode.WALKING, + startVisit, + endVisit, + 1L + ); + return tripJdbcService.create(testUser, trip); + } + + private RawLocationPoint createProcessedPoint(User user, Instant timestamp) { + RawLocationPoint point = new RawLocationPoint( + null, + timestamp, + new GeoPoint(53.863149, 10.700927), + 10.0, + null, + false, + false, + false, + 1L + ); + + RawLocationPoint created = rawLocationPointJdbcService.create(user, point); + + // Mark as processed + RawLocationPoint processed = new RawLocationPoint( + created.getId(), + created.getTimestamp(), + created.getGeom(), + created.getAccuracyMeters(), + created.getElevationMeters(), + true, + created.isSynthetic(), + created.isIgnored(), + created.getVersion() + ); + + return rawLocationPointJdbcService.update(processed); + } + + private RawLocationPoint findPointById(Long id) { + return rawLocationPointJdbcService.findById(id) + .orElseThrow(() -> new RuntimeException("Point not found: " + id)); + } +} diff --git a/src/test/java/com/dedicatedcode/reitti/service/HomeDetectionServiceTest.java b/src/test/java/com/dedicatedcode/reitti/service/HomeDetectionServiceTest.java index 44f358c2..bb46e1f5 100644 --- a/src/test/java/com/dedicatedcode/reitti/service/HomeDetectionServiceTest.java +++ b/src/test/java/com/dedicatedcode/reitti/service/HomeDetectionServiceTest.java @@ -23,7 +23,7 @@ class HomeDetectionServiceTest { @Test void testFindAccommodation_SingleCandidate() { // Create a significant place - SignificantPlace place = new SignificantPlace(1L, "Home", "123 Main St", "City", "US", 40.0, -74.0, HOME, ZoneId.of("America/New_York"), true, 1L); + SignificantPlace place = new SignificantPlace(1L, "Home", "123 Main St", "City", "US", 40.0, -74.0, null, HOME, ZoneId.of("America/New_York"), true, 1L); // Create a visit that spans sleeping hours Instant start = Instant.parse("2023-10-01T20:00:00Z"); @@ -42,8 +42,8 @@ class HomeDetectionServiceTest { @Test void testFindAccommodation_MultipleCandidates_SameDuration_PickClosestToEnd() { // Create two places - SignificantPlace place1 = new SignificantPlace(1L, "Home1", "123 Main St", "City", "US", 40.0, -74.0, HOME, ZoneId.of("America/New_York"), true, 1L); - SignificantPlace place2 = new SignificantPlace(2L, "Home2", "456 Elm St", "City", "US", 40.1, -74.1, HOME, ZoneId.of("America/New_York"), true, 1L); + SignificantPlace place1 = new SignificantPlace(1L, "Home1", "123 Main St", "City", "US", 40.0, -74.0, null, HOME, ZoneId.of("America/New_York"), true, 1L); + SignificantPlace place2 = new SignificantPlace(2L, "Home2", "456 Elm St", "City", "US", 40.1, -74.1, null, HOME, ZoneId.of("America/New_York"), true, 1L); // Create visits with same duration (8 hours sleeping), but different end times // Visit1 ends closer to memoryEnd @@ -68,8 +68,8 @@ class HomeDetectionServiceTest { @Test void testFindAccommodation_MultipleCandidates_DifferentDurations() { // Similar setup, but place1 has higher duration - SignificantPlace place1 = new SignificantPlace(1L, "Home1", "123 Main St", "City", "US", 40.0, -74.0, HOME, ZoneId.of("America/New_York"), true, 1L); - SignificantPlace place2 = new SignificantPlace(2L, "Home2", "456 Elm St", "City", "US", 40.1, -74.1, HOME, ZoneId.of("America/New_York"), true, 1L); + SignificantPlace place1 = new SignificantPlace(1L, "Home1", "123 Main St", "City", "US", 40.0, -74.0, null, HOME, ZoneId.of("America/New_York"), true, 1L); + SignificantPlace place2 = new SignificantPlace(2L, "Home2", "456 Elm St", "City", "US", 40.1, -74.1, null, HOME, ZoneId.of("America/New_York"), true, 1L); // Visit1: higher duration Instant start1 = Instant.parse("2023-10-01T20:00:00Z"); @@ -102,7 +102,7 @@ class HomeDetectionServiceTest { @Test void testFindAccommodation_VisitsOutsideMemoryRange() { - SignificantPlace place = new SignificantPlace(1L, "Home", "123 Main St", "City", "US", 40.0, -74.0, HOME, ZoneId.of("America/New_York"), true, 1L); + SignificantPlace place = new SignificantPlace(1L, "Home", "123 Main St", "City", "US", 40.0, -74.0, null, HOME, ZoneId.of("America/New_York"), true, 1L); // Visit completely outside memory range Instant start = Instant.parse("2023-09-30T20:00:00Z"); diff --git a/src/test/java/com/dedicatedcode/reitti/service/PlaceChangeDetectionServiceTest.java b/src/test/java/com/dedicatedcode/reitti/service/PlaceChangeDetectionServiceTest.java new file mode 100644 index 00000000..dbf8cb69 --- /dev/null +++ b/src/test/java/com/dedicatedcode/reitti/service/PlaceChangeDetectionServiceTest.java @@ -0,0 +1,256 @@ +package com.dedicatedcode.reitti.service; + +import com.dedicatedcode.reitti.IntegrationTest; +import com.dedicatedcode.reitti.TestingService; +import com.dedicatedcode.reitti.model.geo.GeoPoint; +import com.dedicatedcode.reitti.model.geo.SignificantPlace; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.SignificantPlaceJdbcService; +import com.dedicatedcode.reitti.service.PlaceChangeDetectionService.PlaceChangeAnalysis; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.ZoneId; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@IntegrationTest +class PlaceChangeDetectionServiceTest { + + @Autowired + private PlaceChangeDetectionService placeChangeDetectionService; + + @Autowired + private SignificantPlaceJdbcService placeJdbcService; + + @Autowired + private TestingService testingService; + + private User testUser; + + @BeforeEach + void setUp() { + testingService.clearData(); + testUser = testingService.randomUser(); + } + + @Test + void analyzeChanges_WithValidPolygonAddition_ShouldReturnWarning() { + // Given + SignificantPlace place = createTestPlace("Test Place", 53.863149, 10.700927, null); + String polygonData = """ + [ + {"lat": 53.863100, "lng": 10.700900}, + {"lat": 53.863200, "lng": 10.700900}, + {"lat": 53.863200, "lng": 10.701000}, + {"lat": 53.863100, "lng": 10.701000} + ] + """; + + // When + PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, place.getId(), polygonData); + + // Then + assertFalse(result.isCanProceed()); + assertFalse(result.getWarnings().isEmpty()); + assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("The polygon boundary will be added to this place, this may affect visit detection."))); + } + + @Test + void analyzeChanges_WithPolygonRemoval_ShouldReturnWarning() { + // Given + List existingPolygon = List.of( + new GeoPoint(53.863100, 10.700900), + new GeoPoint(53.863200, 10.700900), + new GeoPoint(53.863200, 10.701000), + new GeoPoint(53.863100, 10.701000) + ); + SignificantPlace place = createTestPlace("Test Place", 53.863149, 10.700927, existingPolygon); + + // When + PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, place.getId(), null); + + // Then + assertFalse(result.isCanProceed()); + assertFalse(result.getWarnings().isEmpty()); + assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("The polygon boundary will be removed from this place, this may affect visit detection."))); + } + + @Test + void analyzeChanges_WithSignificantPolygonChange_ShouldReturnWarning() { + // Given + List existingPolygon = List.of( + new GeoPoint(53.863100, 10.700900), + new GeoPoint(53.863200, 10.700900), + new GeoPoint(53.863200, 10.701000), + new GeoPoint(53.863100, 10.701000) + ); + SignificantPlace place = createTestPlace("Test Place", 53.863149, 10.700927, existingPolygon); + + // Polygon moved significantly (more than 10m) + String newPolygonData = """ + [ + {"lat": 53.864100, "lng": 10.701900}, + {"lat": 53.864200, "lng": 10.701900}, + {"lat": 53.864200, "lng": 10.702000}, + {"lat": 53.864100, "lng": 10.702000} + ] + """; + + // When + PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, place.getId(), newPolygonData); + + // Then + assertFalse(result.isCanProceed()); + assertFalse(result.getWarnings().isEmpty()); + assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("The polygon boundary will be significantly changed, which may affect visit detection."))); + } + + @Test + void analyzeChanges_WithNoPolygonChange_ShouldNotReturnWarning() { + // Given + List existingPolygon = List.of( + new GeoPoint(53.863100, 10.700900), + new GeoPoint(53.863200, 10.700900), + new GeoPoint(53.863200, 10.701000), + new GeoPoint(53.863100, 10.701000) + ); + SignificantPlace place = createTestPlace("Test Place", 53.863149, 10.700927, existingPolygon); + + // Very minor change (less than 10m) + String newPolygonData = """ + [ + {"lat": 53.863100, "lng": 10.700900}, + {"lat": 53.863200, "lng": 10.700900}, + {"lat": 53.863200, "lng": 10.701000}, + {"lat": 53.863100, "lng": 10.701000} + ] + """; + + // When + PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, place.getId(), newPolygonData); + + // Then + assertTrue(result.isCanProceed()); + assertTrue(result.getWarnings().isEmpty()); + } + + @Test + void analyzeChanges_WithOverlappingPlaces_ShouldReturnWarning() { + // Given + List existingPolygon = List.of( + new GeoPoint(53.863100, 10.700900), + new GeoPoint(53.863200, 10.700900), + new GeoPoint(53.863200, 10.701000), + new GeoPoint(53.863100, 10.701000) + ); + createTestPlace("Existing Place", 53.863149, 10.700927, existingPolygon); + SignificantPlace newPlace = createTestPlace("New Place", 53.863149, 10.700927, null); + + // Overlapping polygon + String overlappingPolygonData = """ + [ + {"lat": 53.863150, "lng": 10.700950}, + {"lat": 53.863250, "lng": 10.700950}, + {"lat": 53.863250, "lng": 10.701050}, + {"lat": 53.863150, "lng": 10.701050} + ] + """; + + // When + PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, newPlace.getId(), overlappingPolygonData); + + // Then + assertFalse(result.isCanProceed()); + assertFalse(result.getWarnings().isEmpty()); + assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("The new boundary will overlap with 1 existing place, which may cause visits to be reassigned between places and affect trip calculations"))); + } + + @Test + void analyzeChanges_WithInvalidPolygonData_ShouldReturnError() { + // Given + SignificantPlace place = createTestPlace("Test Place", 53.863149, 10.700927, null); + String invalidPolygonData = "invalid json"; + + // When + PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, place.getId(), invalidPolygonData); + + // Then + assertFalse(result.isCanProceed()); + assertFalse(result.getWarnings().isEmpty()); + assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("An error occurred while checking the update"))); + } + + @Test + void analyzeChanges_WithInsufficientPolygonPoints_ShouldReturnError() { + // Given + SignificantPlace place = createTestPlace("Test Place", 53.863149, 10.700927, null); + String insufficientPolygonData = """ + [ + {"lat": 53.863100, "lng": 10.700900}, + {"lat": 53.863200, "lng": 10.700900} + ] + """; + + // When + PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, place.getId(), insufficientPolygonData); + + // Then + assertFalse(result.isCanProceed()); + assertFalse(result.getWarnings().isEmpty()); + assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("An error occurred while checking the update"))); + } + + @Test + void analyzeChanges_WithMissingLatLngProperties_ShouldReturnError() { + // Given + SignificantPlace place = createTestPlace("Test Place", 53.863149, 10.700927, null); + String invalidPolygonData = """ + [ + {"latitude": 53.863100, "longitude": 10.700900}, + {"lat": 53.863200, "lng": 10.700900}, + {"lat": 53.863200, "lng": 10.701000} + ] + """; + + // When + PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, place.getId(), invalidPolygonData); + + // Then + assertFalse(result.isCanProceed()); + assertFalse(result.getWarnings().isEmpty()); + assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("An error occurred while checking the update"))); + } + + @Test + void analyzeChanges_WithNoChanges_ShouldReturnNoWarnings() { + // Given + SignificantPlace place = createTestPlace("Test Place", 53.863149, 10.700927, null); + + // When - no polygon data provided for place that has no polygon + PlaceChangeAnalysis result = placeChangeDetectionService.analyzeChanges(testUser, place.getId(), null); + + // Then + assertTrue(result.isCanProceed()); + assertTrue(result.getWarnings().isEmpty()); + } + + private SignificantPlace createTestPlace(String name, double latitude, double longitude, List polygon) { + return placeJdbcService.create(testUser, new SignificantPlace( + null, + name, + null, + null, + null, + latitude, + longitude, + polygon, + SignificantPlace.PlaceType.HOME, + ZoneId.systemDefault(), + true, + 0L + )); + } +} diff --git a/src/test/java/com/dedicatedcode/reitti/service/processing/ProcessingPipelineTest.java b/src/test/java/com/dedicatedcode/reitti/service/processing/ProcessingPipelineTest.java index 13735129..b89a138e 100644 --- a/src/test/java/com/dedicatedcode/reitti/service/processing/ProcessingPipelineTest.java +++ b/src/test/java/com/dedicatedcode/reitti/service/processing/ProcessingPipelineTest.java @@ -63,8 +63,8 @@ public class ProcessingPipelineTest { assertEquals(10, processedVisits.size()); //should not touch visits before the new data - assertVisit(processedVisits.get(0), "2025-06-16T22:00:09.154Z", "2025-06-17T05:40:26Z" , MOLTKESTR); - assertVisit(processedVisits.get(1), "2025-06-17T05:41:00Z" , "2025-06-17T05:57:07.729Z" , ST_THOMAS); + assertVisit(processedVisits.get(0), "2025-06-16T22:00:09.154Z", "2025-06-17T05:41:00Z" , MOLTKESTR); + assertVisit(processedVisits.get(1), "2025-06-17T05:41:30.989Z", "2025-06-17T05:57:07.729Z" , ST_THOMAS); assertVisit(processedVisits.get(2), "2025-06-17T05:57:41Z" , "2025-06-17T13:09:29Z" , MOLTKESTR); assertVisit(processedVisits.get(3), "2025-06-17T13:09:51.476Z", "2025-06-17T13:20:24.494Z" , ST_THOMAS); @@ -75,7 +75,7 @@ public class ProcessingPipelineTest { assertVisit(processedVisits.get(5), "2025-06-18T05:47:13.682Z" ,"2025-06-18T06:04:02.435Z" , ST_THOMAS); assertVisit(processedVisits.get(6), "2025-06-18T06:04:36Z" ,"2025-06-18T13:01:57Z" , MOLTKESTR); assertVisit(processedVisits.get(7), "2025-06-18T13:02:27.656Z" ,"2025-06-18T13:14:19.417Z" , ST_THOMAS); - assertVisit(processedVisits.get(8), "2025-06-18T13:33:35.626Z" ,"2025-06-18T15:50:40Z" , GARTEN); + assertVisit(processedVisits.get(8), "2025-06-18T13:33:05Z" ,"2025-06-18T15:50:40Z" , GARTEN); assertVisit(processedVisits.get(9), "2025-06-18T16:02:38Z" ,"2025-06-18T21:59:29.055Z" , MOLTKESTR); } @@ -100,8 +100,8 @@ public class ProcessingPipelineTest { assertEquals(10, processedVisits.size()); //new visits - assertVisit(processedVisits.get(0), "2025-06-16T22:00:09.154Z", "2025-06-17T05:40:26Z" , MOLTKESTR); - assertVisit(processedVisits.get(1), "2025-06-17T05:41:00Z" , "2025-06-17T05:57:07.729Z" , ST_THOMAS); + assertVisit(processedVisits.get(0), "2025-06-16T22:00:09.154Z", "2025-06-17T05:41:00Z" , MOLTKESTR); + assertVisit(processedVisits.get(1), "2025-06-17T05:41:30.989Z", "2025-06-17T05:57:07.729Z" , ST_THOMAS); assertVisit(processedVisits.get(2), "2025-06-17T05:57:41Z" , "2025-06-17T13:09:29Z" , MOLTKESTR); assertVisit(processedVisits.get(3), "2025-06-17T13:09:51.476Z", "2025-06-17T13:20:24.494Z" , ST_THOMAS); @@ -112,7 +112,7 @@ public class ProcessingPipelineTest { assertVisit(processedVisits.get(5), "2025-06-18T05:47:13.682Z" ,"2025-06-18T06:04:02.435Z" , ST_THOMAS); assertVisit(processedVisits.get(6), "2025-06-18T06:04:36Z" ,"2025-06-18T13:01:57Z" , MOLTKESTR); assertVisit(processedVisits.get(7), "2025-06-18T13:02:27.656Z" ,"2025-06-18T13:14:19.417Z" , ST_THOMAS); - assertVisit(processedVisits.get(8), "2025-06-18T13:33:35.626Z" ,"2025-06-18T15:50:40Z" , GARTEN); + assertVisit(processedVisits.get(8), "2025-06-18T13:33:05Z" ,"2025-06-18T15:50:40Z" , GARTEN); assertVisit(processedVisits.get(9), "2025-06-18T16:02:38Z" ,"2025-06-18T21:59:29.055Z" , MOLTKESTR); }