Added Heatmaps
This commit is contained in:
parent
c8b37f4720
commit
f391028061
22 changed files with 1696 additions and 9 deletions
|
|
@ -2,7 +2,187 @@
|
|||
|
||||
This guide explains how to test the instance-to-instance federation functionality by running two FitPub instances locally.
|
||||
|
||||
## Prerequisites
|
||||
## Docker Compose Setup (Recommended)
|
||||
|
||||
The easiest way to test federation is using Docker Compose, which automatically sets up two complete FitPub instances with separate databases and proper networking.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Docker Network (fitpub-federation) │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌──────────────────────┐ │
|
||||
│ │ Instance 1 │ │ Instance 2 │ │
|
||||
│ │ (instance1.local) │◄─────►│ (instance2.local) │ │
|
||||
│ │ Port: 8080 │ │ Port: 8081 │ │
|
||||
│ └──────────────────────┘ └──────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ PostgreSQL 1 │ │ PostgreSQL 2 │ │
|
||||
│ │ (postgres1) │ │ (postgres2) │ │
|
||||
│ │ Port: 5432 │ │ Port: 5433 │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▲ ▲
|
||||
│ │
|
||||
localhost:8080 localhost:8081
|
||||
```
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Start both instances**:
|
||||
```bash
|
||||
docker-compose -f docker-compose.federation-test.yml up -d
|
||||
```
|
||||
|
||||
2. **Check status**:
|
||||
```bash
|
||||
docker-compose -f docker-compose.federation-test.yml ps
|
||||
```
|
||||
|
||||
3. **Access the instances**:
|
||||
- Instance 1: http://localhost:8080
|
||||
- Instance 2: http://localhost:8081
|
||||
|
||||
4. **Follow the [Test Scenarios](#test-scenarios) below** to verify federation functionality
|
||||
|
||||
5. **View logs** (in separate terminals):
|
||||
```bash
|
||||
# Instance 1 logs
|
||||
docker-compose -f docker-compose.federation-test.yml logs -f fitpub1
|
||||
|
||||
# Instance 2 logs
|
||||
docker-compose -f docker-compose.federation-test.yml logs -f fitpub2
|
||||
```
|
||||
|
||||
6. **Stop and clean up**:
|
||||
```bash
|
||||
# Stop containers
|
||||
docker-compose -f docker-compose.federation-test.yml down
|
||||
|
||||
# Stop and remove volumes (complete cleanup)
|
||||
docker-compose -f docker-compose.federation-test.yml down -v
|
||||
```
|
||||
|
||||
### Service Overview
|
||||
|
||||
The Docker Compose setup includes:
|
||||
|
||||
- **postgres1**: PostgreSQL 16 with PostGIS 3.4 for Instance 1
|
||||
- Database: `fitpub1`
|
||||
- Port: 5432 (internal), 5434 (on host)
|
||||
|
||||
- **postgres2**: PostgreSQL 16 with PostGIS 3.4 for Instance 2
|
||||
- Database: `fitpub2`
|
||||
- Port: 5432 (internal), 5433 (on host)
|
||||
|
||||
- **fitpub1**: FitPub Instance 1
|
||||
- Domain: `instance1.local:8080`
|
||||
- Port: 8080
|
||||
- Network alias: `instance1.local`
|
||||
|
||||
- **fitpub2**: FitPub Instance 2
|
||||
- Domain: `instance2.local:8081`
|
||||
- Port: 8081
|
||||
- Network alias: `instance2.local`
|
||||
|
||||
### Docker-Specific Commands
|
||||
|
||||
**Access database directly**:
|
||||
```bash
|
||||
# Instance 1 database
|
||||
docker exec -it fitpub-postgres1 psql -U fitpub -d fitpub1
|
||||
|
||||
# Instance 2 database
|
||||
docker exec -it fitpub-postgres2 psql -U fitpub -d fitpub2
|
||||
```
|
||||
|
||||
**Inspect network**:
|
||||
```bash
|
||||
docker network inspect fitpub-federation
|
||||
```
|
||||
|
||||
**View container details**:
|
||||
```bash
|
||||
docker inspect fitpub-instance1
|
||||
docker inspect fitpub-instance2
|
||||
```
|
||||
|
||||
**Restart a single service**:
|
||||
```bash
|
||||
docker-compose -f docker-compose.federation-test.yml restart fitpub1
|
||||
docker-compose -f docker-compose.federation-test.yml restart fitpub2
|
||||
```
|
||||
|
||||
**Rebuild images** (after code changes):
|
||||
```bash
|
||||
docker-compose -f docker-compose.federation-test.yml build
|
||||
docker-compose -f docker-compose.federation-test.yml up -d
|
||||
```
|
||||
|
||||
### Docker Troubleshooting
|
||||
|
||||
**Container won't start**:
|
||||
```bash
|
||||
# Check logs for errors
|
||||
docker-compose -f docker-compose.federation-test.yml logs fitpub1
|
||||
docker-compose -f docker-compose.federation-test.yml logs fitpub2
|
||||
|
||||
# Check health status
|
||||
docker ps -a | grep fitpub
|
||||
```
|
||||
|
||||
**Database connection issues**:
|
||||
```bash
|
||||
# Verify database is healthy
|
||||
docker-compose -f docker-compose.federation-test.yml ps postgres1
|
||||
docker-compose -f docker-compose.federation-test.yml ps postgres2
|
||||
|
||||
# Check database logs
|
||||
docker-compose -f docker-compose.federation-test.yml logs postgres1
|
||||
```
|
||||
|
||||
**Network connectivity issues**:
|
||||
```bash
|
||||
# Test DNS resolution from inside container
|
||||
docker exec -it fitpub-instance1 ping instance2.local
|
||||
docker exec -it fitpub-instance2 ping instance1.local
|
||||
|
||||
# Test HTTP connectivity
|
||||
docker exec -it fitpub-instance1 curl http://instance2.local:8081/.well-known/webfinger
|
||||
```
|
||||
|
||||
**Port already in use**:
|
||||
```bash
|
||||
# Find process using port 8080
|
||||
lsof -ti:8080 | xargs kill -9
|
||||
|
||||
# Or use different external ports in docker-compose.yml
|
||||
```
|
||||
|
||||
**Volume permission issues**:
|
||||
```bash
|
||||
# Remove all volumes and start fresh
|
||||
docker-compose -f docker-compose.federation-test.yml down -v
|
||||
docker-compose -f docker-compose.federation-test.yml up -d
|
||||
```
|
||||
|
||||
**Platform warning on Apple Silicon (M1/M2/M3 Macs)**:
|
||||
```
|
||||
The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8)
|
||||
```
|
||||
This is expected and safe to ignore. Docker will use emulation (Rosetta 2) to run the amd64 images. Performance may be slightly slower than native ARM images, but fully functional for testing.
|
||||
|
||||
---
|
||||
|
||||
## Manual Setup (Alternative)
|
||||
|
||||
If you prefer to run the instances directly without Docker, follow these instructions:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Java 17+
|
||||
- Maven 3.8+
|
||||
|
|
|
|||
193
docker-compose.federation-test.yml
Normal file
193
docker-compose.federation-test.yml
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
# Docker Compose for Federation Testing
|
||||
# Starts two FitPub instances with separate databases for testing federation between instances
|
||||
#
|
||||
# Usage:
|
||||
# Start: docker-compose -f docker-compose.federation-test.yml up -d
|
||||
# Logs: docker-compose -f docker-compose.federation-test.yml logs -f
|
||||
# Stop: docker-compose -f docker-compose.federation-test.yml down
|
||||
# Cleanup: docker-compose -f docker-compose.federation-test.yml down -v
|
||||
#
|
||||
# Access:
|
||||
# Instance 1: http://localhost:8080
|
||||
# Instance 2: http://localhost:8081
|
||||
|
||||
services:
|
||||
# PostgreSQL Database for Instance 1
|
||||
postgres1:
|
||||
image: postgis/postgis:16-3.4
|
||||
container_name: fitpub-postgres1
|
||||
platform: linux/amd64 # Explicit platform for consistency
|
||||
environment:
|
||||
POSTGRES_DB: fitpub1
|
||||
POSTGRES_USER: fitpub
|
||||
POSTGRES_PASSWORD: fitpub_test_password
|
||||
ports:
|
||||
- "5434:5432" # External port 5434 to avoid conflict with existing postgres
|
||||
volumes:
|
||||
- postgres1_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- fitpub-federation
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U fitpub -d fitpub1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
# PostgreSQL Database for Instance 2
|
||||
postgres2:
|
||||
image: postgis/postgis:16-3.4
|
||||
container_name: fitpub-postgres2
|
||||
platform: linux/amd64 # Explicit platform for consistency
|
||||
environment:
|
||||
POSTGRES_DB: fitpub2
|
||||
POSTGRES_USER: fitpub
|
||||
POSTGRES_PASSWORD: fitpub_test_password
|
||||
ports:
|
||||
- "5433:5432" # External port 5433 for debugging
|
||||
volumes:
|
||||
- postgres2_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- fitpub-federation
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U fitpub -d fitpub2"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
# FitPub Instance 1
|
||||
fitpub1:
|
||||
build: .
|
||||
container_name: fitpub-instance1
|
||||
depends_on:
|
||||
postgres1:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
# Spring Profile
|
||||
SPRING_PROFILES_ACTIVE: dev
|
||||
|
||||
# Federation Configuration
|
||||
FITPUB_DOMAIN: instance1.local:8080
|
||||
FITPUB_BASE_URL: http://instance1.local:8080
|
||||
|
||||
# Database Configuration
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres1:5432/fitpub1
|
||||
SPRING_DATASOURCE_USERNAME: fitpub
|
||||
SPRING_DATASOURCE_PASSWORD: fitpub_test_password
|
||||
|
||||
# Security
|
||||
JWT_SECRET: test_jwt_secret_minimum_32_characters_required_instance1
|
||||
|
||||
# Feature Flags
|
||||
REGISTRATION_ENABLED: "true"
|
||||
ACTIVITYPUB_ENABLED: "true"
|
||||
WEATHER_ENABLED: "false"
|
||||
OSM_TILES_ENABLED: "true"
|
||||
|
||||
# Federation Testing (ONLY for local testing - DO NOT use in production!)
|
||||
FITPUB_ALLOW_PRIVATE_IPS: "true"
|
||||
FITPUB_FEDERATION_PROTOCOL: "http"
|
||||
|
||||
# File Upload
|
||||
FILE_UPLOAD_MAX_SIZE: 50MB
|
||||
FILE_UPLOAD_DIR: /app/uploads
|
||||
|
||||
# Logging
|
||||
LOGGING_LEVEL_ORG_OPERATON_FITPUB: DEBUG
|
||||
volumes:
|
||||
- instance1_uploads:/app/uploads
|
||||
- instance1_logs:/var/log/fitpub
|
||||
networks:
|
||||
fitpub-federation:
|
||||
aliases:
|
||||
- instance1.local
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
|
||||
# FitPub Instance 2
|
||||
fitpub2:
|
||||
build: .
|
||||
container_name: fitpub-instance2
|
||||
depends_on:
|
||||
postgres2:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8081:8080" # External port 8081, internal 8080
|
||||
environment:
|
||||
# Spring Profile
|
||||
SPRING_PROFILES_ACTIVE: dev
|
||||
|
||||
# Federation Configuration
|
||||
FITPUB_DOMAIN: instance2.local:8081
|
||||
FITPUB_BASE_URL: http://instance2.local:8081
|
||||
|
||||
# Database Configuration
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres2:5432/fitpub2
|
||||
SPRING_DATASOURCE_USERNAME: fitpub
|
||||
SPRING_DATASOURCE_PASSWORD: fitpub_test_password
|
||||
|
||||
# Security
|
||||
JWT_SECRET: test_jwt_secret_minimum_32_characters_required_instance2
|
||||
|
||||
# Feature Flags
|
||||
REGISTRATION_ENABLED: "true"
|
||||
ACTIVITYPUB_ENABLED: "true"
|
||||
WEATHER_ENABLED: "false"
|
||||
OSM_TILES_ENABLED: "true"
|
||||
|
||||
# Federation Testing (ONLY for local testing - DO NOT use in production!)
|
||||
FITPUB_ALLOW_PRIVATE_IPS: "true"
|
||||
FITPUB_FEDERATION_PROTOCOL: "http"
|
||||
|
||||
# File Upload
|
||||
FILE_UPLOAD_MAX_SIZE: 50MB
|
||||
FILE_UPLOAD_DIR: /app/uploads
|
||||
|
||||
# Logging
|
||||
LOGGING_LEVEL_ORG_OPERATON_FITPUB: DEBUG
|
||||
volumes:
|
||||
- instance2_uploads:/app/uploads
|
||||
- instance2_logs:/var/log/fitpub
|
||||
networks:
|
||||
fitpub-federation:
|
||||
aliases:
|
||||
- instance2.local
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
|
||||
# Custom network for federation testing
|
||||
networks:
|
||||
fitpub-federation:
|
||||
driver: bridge
|
||||
driver_opts:
|
||||
com.docker.network.bridge.name: fitpub-fed
|
||||
|
||||
# Persistent volumes
|
||||
volumes:
|
||||
postgres1_data:
|
||||
name: fitpub_postgres1_data
|
||||
postgres2_data:
|
||||
name: fitpub_postgres2_data
|
||||
instance1_uploads:
|
||||
name: fitpub_instance1_uploads
|
||||
instance1_logs:
|
||||
name: fitpub_instance1_logs
|
||||
instance2_uploads:
|
||||
name: fitpub_instance2_uploads
|
||||
instance2_logs:
|
||||
name: fitpub_instance2_logs
|
||||
|
|
@ -10,6 +10,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
|
|
@ -19,6 +20,7 @@ import org.springframework.web.client.RestTemplate;
|
|||
*/
|
||||
@SpringBootApplication
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
@Slf4j
|
||||
public class FitPubApplication {
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ public class SecurityConfig {
|
|||
.requestMatchers("/discover").permitAll() // User discovery page
|
||||
.requestMatchers("/notifications").permitAll() // Auth checked client-side
|
||||
.requestMatchers("/analytics", "/analytics/**").permitAll() // Auth checked client-side
|
||||
.requestMatchers("/heatmap").permitAll() // Auth checked client-side
|
||||
|
||||
// Public endpoints - ActivityPub federation
|
||||
.requestMatchers("/.well-known/**").permitAll()
|
||||
|
|
@ -105,6 +106,10 @@ public class SecurityConfig {
|
|||
// Protected endpoints - Analytics API
|
||||
.requestMatchers("/api/analytics/**").authenticated()
|
||||
|
||||
// Protected endpoints - Heatmap API
|
||||
.requestMatchers("/api/heatmap/me").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/heatmap/user/*").permitAll()
|
||||
|
||||
// Protected endpoints - Activities API (upload, edit, delete)
|
||||
.requestMatchers(HttpMethod.POST, "/api/activities/upload").authenticated()
|
||||
.requestMatchers(HttpMethod.PUT, "/api/activities/*").authenticated()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
package org.operaton.fitpub.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.operaton.fitpub.model.dto.HeatmapDataDTO;
|
||||
import org.operaton.fitpub.model.entity.User;
|
||||
import org.operaton.fitpub.model.entity.UserHeatmapGrid;
|
||||
import org.operaton.fitpub.repository.UserRepository;
|
||||
import org.operaton.fitpub.service.HeatmapGridService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* REST controller for user activity heatmap data.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/heatmap")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class HeatmapController {
|
||||
|
||||
private final HeatmapGridService heatmapGridService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* Get heatmap data for the authenticated user.
|
||||
* Optionally filtered by bounding box (viewport).
|
||||
*
|
||||
* @param userDetails authenticated user
|
||||
* @param minLon minimum longitude (optional)
|
||||
* @param minLat minimum latitude (optional)
|
||||
* @param maxLon maximum longitude (optional)
|
||||
* @param maxLat maximum latitude (optional)
|
||||
* @return heatmap data in GeoJSON format
|
||||
*/
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<HeatmapDataDTO> getMyHeatmap(
|
||||
@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam(required = false) Double minLon,
|
||||
@RequestParam(required = false) Double minLat,
|
||||
@RequestParam(required = false) Double maxLon,
|
||||
@RequestParam(required = false) Double maxLat) {
|
||||
|
||||
log.debug("User {} requesting heatmap data", userDetails.getUsername());
|
||||
|
||||
User user = userRepository.findByUsername(userDetails.getUsername())
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
|
||||
List<UserHeatmapGrid> gridCells = heatmapGridService.getUserHeatmapData(
|
||||
user.getId(), minLon, minLat, maxLon, maxLat);
|
||||
|
||||
Integer maxIntensity = heatmapGridService.getMaxPointCount(user.getId());
|
||||
|
||||
HeatmapDataDTO heatmapData = HeatmapDataDTO.fromGridCells(gridCells, maxIntensity);
|
||||
|
||||
log.debug("Returning {} grid cells for user {}", gridCells.size(), userDetails.getUsername());
|
||||
|
||||
return ResponseEntity.ok(heatmapData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get heatmap data for a specific user by username.
|
||||
* Only returns data for public activities.
|
||||
*
|
||||
* @param username the username
|
||||
* @param minLon minimum longitude (optional)
|
||||
* @param minLat minimum latitude (optional)
|
||||
* @param maxLon maximum longitude (optional)
|
||||
* @param maxLat maximum latitude (optional)
|
||||
* @return heatmap data in GeoJSON format
|
||||
*/
|
||||
@GetMapping("/user/{username}")
|
||||
public ResponseEntity<HeatmapDataDTO> getUserHeatmap(
|
||||
@PathVariable String username,
|
||||
@RequestParam(required = false) Double minLon,
|
||||
@RequestParam(required = false) Double minLat,
|
||||
@RequestParam(required = false) Double maxLon,
|
||||
@RequestParam(required = false) Double maxLat) {
|
||||
|
||||
log.debug("Requesting heatmap data for user {}", username);
|
||||
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
||||
|
||||
List<UserHeatmapGrid> gridCells = heatmapGridService.getUserHeatmapData(
|
||||
user.getId(), minLon, minLat, maxLon, maxLat);
|
||||
|
||||
Integer maxIntensity = heatmapGridService.getMaxPointCount(user.getId());
|
||||
|
||||
HeatmapDataDTO heatmapData = HeatmapDataDTO.fromGridCells(gridCells, maxIntensity);
|
||||
|
||||
log.debug("Returning {} grid cells for user {}", gridCells.size(), username);
|
||||
|
||||
return ResponseEntity.ok(heatmapData);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,4 +13,9 @@ public class HomeController {
|
|||
public String home() {
|
||||
return "redirect:/timeline";
|
||||
}
|
||||
|
||||
@GetMapping("/heatmap")
|
||||
public String heatmap() {
|
||||
return "heatmap";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
package org.operaton.fitpub.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.operaton.fitpub.model.entity.UserHeatmapGrid;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DTO for heatmap data in GeoJSON-compatible format.
|
||||
* Used by frontend to render heatmap with Leaflet.heat.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class HeatmapDataDTO {
|
||||
|
||||
@Builder.Default
|
||||
private String type = "FeatureCollection";
|
||||
private List<Feature> features;
|
||||
private Integer maxIntensity;
|
||||
|
||||
/**
|
||||
* GeoJSON Feature representing a heatmap grid cell.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Feature {
|
||||
@Builder.Default
|
||||
private String type = "Feature";
|
||||
private Geometry geometry;
|
||||
private Properties properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeoJSON Point geometry.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Geometry {
|
||||
@Builder.Default
|
||||
private String type = "Point";
|
||||
private double[] coordinates; // [lon, lat]
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature properties containing intensity.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Properties {
|
||||
private Integer intensity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert list of UserHeatmapGrid entities to HeatmapDataDTO.
|
||||
*
|
||||
* @param gridCells list of grid cells
|
||||
* @param maxIntensity maximum intensity for normalization
|
||||
* @return HeatmapDataDTO
|
||||
*/
|
||||
public static HeatmapDataDTO fromGridCells(List<UserHeatmapGrid> gridCells, Integer maxIntensity) {
|
||||
List<Feature> features = new ArrayList<>();
|
||||
|
||||
for (UserHeatmapGrid cell : gridCells) {
|
||||
double lon = cell.getGridCell().getX();
|
||||
double lat = cell.getGridCell().getY();
|
||||
|
||||
Feature feature = Feature.builder()
|
||||
.type("Feature")
|
||||
.geometry(Geometry.builder()
|
||||
.type("Point")
|
||||
.coordinates(new double[]{lon, lat})
|
||||
.build())
|
||||
.properties(Properties.builder()
|
||||
.intensity(cell.getPointCount())
|
||||
.build())
|
||||
.build();
|
||||
|
||||
features.add(feature);
|
||||
}
|
||||
|
||||
return HeatmapDataDTO.builder()
|
||||
.type("FeatureCollection")
|
||||
.features(features)
|
||||
.maxIntensity(maxIntensity)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package org.operaton.fitpub.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
import org.locationtech.jts.geom.Point;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* User heatmap grid entity representing aggregated track point density.
|
||||
* Each row represents a grid cell with the count of track points that fall within it.
|
||||
* Used to efficiently render user activity heatmaps without processing all activities.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "user_heatmap_grid",
|
||||
uniqueConstraints = @UniqueConstraint(name = "unique_user_grid_cell", columnNames = {"user_id", "grid_cell"}),
|
||||
indexes = {
|
||||
@Index(name = "idx_user_heatmap_grid_user", columnList = "user_id"),
|
||||
@Index(name = "idx_user_heatmap_grid_updated", columnList = "last_updated")
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class UserHeatmapGrid {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", insertable = false, updatable = false)
|
||||
private User user;
|
||||
|
||||
/**
|
||||
* Center point of the grid cell.
|
||||
* Grid cells are ~100m x 100m (0.001 degrees).
|
||||
*/
|
||||
@Column(name = "grid_cell", nullable = false, columnDefinition = "geometry(Point,4326)")
|
||||
private Point gridCell;
|
||||
|
||||
/**
|
||||
* Number of track points that fall within this grid cell.
|
||||
* Higher counts indicate more frequently visited areas.
|
||||
*/
|
||||
@Column(name = "point_count", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer pointCount = 0;
|
||||
|
||||
@Column(name = "last_updated", nullable = false)
|
||||
@UpdateTimestamp
|
||||
private LocalDateTime lastUpdated;
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package org.operaton.fitpub.repository;
|
||||
|
||||
import org.locationtech.jts.geom.Point;
|
||||
import org.operaton.fitpub.model.entity.UserHeatmapGrid;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository for UserHeatmapGrid entities.
|
||||
*/
|
||||
@Repository
|
||||
public interface UserHeatmapGridRepository extends JpaRepository<UserHeatmapGrid, Long> {
|
||||
|
||||
/**
|
||||
* Find all grid cells for a user.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return list of grid cells
|
||||
*/
|
||||
List<UserHeatmapGrid> findByUserId(UUID userId);
|
||||
|
||||
/**
|
||||
* Find grid cells for a user within a bounding box.
|
||||
* Uses PostGIS ST_MakeEnvelope to create a bounding box and ST_Intersects for spatial filtering.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @param minLon minimum longitude
|
||||
* @param minLat minimum latitude
|
||||
* @param maxLon maximum longitude
|
||||
* @param maxLat maximum latitude
|
||||
* @return list of grid cells within the bounding box
|
||||
*/
|
||||
@Query(value = "SELECT * FROM user_heatmap_grid " +
|
||||
"WHERE user_id = :userId " +
|
||||
"AND ST_Intersects(grid_cell, ST_MakeEnvelope(:minLon, :minLat, :maxLon, :maxLat, 4326))",
|
||||
nativeQuery = true)
|
||||
List<UserHeatmapGrid> findByUserIdWithinBoundingBox(
|
||||
@Param("userId") UUID userId,
|
||||
@Param("minLon") double minLon,
|
||||
@Param("minLat") double minLat,
|
||||
@Param("maxLon") double maxLon,
|
||||
@Param("maxLat") double maxLat
|
||||
);
|
||||
|
||||
/**
|
||||
* Find a grid cell for a user by exact coordinates.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @param gridCell the grid cell point
|
||||
* @return optional grid cell
|
||||
*/
|
||||
Optional<UserHeatmapGrid> findByUserIdAndGridCell(UUID userId, Point gridCell);
|
||||
|
||||
/**
|
||||
* Delete all grid cells for a user.
|
||||
* Used when recalculating the entire heatmap.
|
||||
*
|
||||
* @param userId the user ID
|
||||
*/
|
||||
@Modifying
|
||||
@Query("DELETE FROM UserHeatmapGrid g WHERE g.userId = :userId")
|
||||
void deleteByUserId(@Param("userId") UUID userId);
|
||||
|
||||
/**
|
||||
* Count grid cells for a user.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return count of grid cells
|
||||
*/
|
||||
long countByUserId(UUID userId);
|
||||
|
||||
/**
|
||||
* Find maximum point count for a user.
|
||||
* Used for normalizing intensity values.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return maximum point count
|
||||
*/
|
||||
@Query("SELECT MAX(g.pointCount) FROM UserHeatmapGrid g WHERE g.userId = :userId")
|
||||
Integer findMaxPointCountByUserId(@Param("userId") UUID userId);
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package org.operaton.fitpub.scheduler;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.operaton.fitpub.model.entity.User;
|
||||
import org.operaton.fitpub.repository.UserRepository;
|
||||
import org.operaton.fitpub.service.HeatmapGridService;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Scheduled task to recalculate user heatmaps nightly.
|
||||
* Ensures heatmap data stays in sync with activities even if incremental updates fail.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class HeatmapRecalculationScheduler {
|
||||
|
||||
private final HeatmapGridService heatmapGridService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* Recalculate heatmaps for all users.
|
||||
* Runs daily at 3 AM server time.
|
||||
*/
|
||||
@Scheduled(cron = "0 0 3 * * *")
|
||||
public void recalculateAllUserHeatmaps() {
|
||||
log.info("Starting nightly heatmap recalculation for all users");
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
List<User> users = userRepository.findAll();
|
||||
log.info("Found {} users to process", users.size());
|
||||
|
||||
int successCount = 0;
|
||||
int errorCount = 0;
|
||||
|
||||
for (User user : users) {
|
||||
try {
|
||||
heatmapGridService.recalculateUserHeatmap(user);
|
||||
successCount++;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to recalculate heatmap for user {}", user.getUsername(), e);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.info("Heatmap recalculation completed in {}ms. Success: {}, Errors: {}",
|
||||
duration, successCount, errorCount);
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,7 @@ public class FitFileService {
|
|||
private final TrainingLoadService trainingLoadService;
|
||||
private final ActivitySummaryService activitySummaryService;
|
||||
private final WeatherService weatherService;
|
||||
private final HeatmapGridService heatmapGridService;
|
||||
|
||||
/**
|
||||
* Processes an uploaded FIT file and creates an activity.
|
||||
|
|
@ -113,6 +114,9 @@ public class FitFileService {
|
|||
personalRecordService.checkAndUpdatePersonalRecords(savedActivity);
|
||||
achievementService.checkAndAwardAchievements(savedActivity);
|
||||
|
||||
// Update heatmap grid
|
||||
heatmapGridService.updateHeatmapForActivity(savedActivity);
|
||||
|
||||
// Update training load and summaries (async)
|
||||
trainingLoadService.updateTrainingLoad(savedActivity);
|
||||
activitySummaryService.updateSummariesForActivity(savedActivity);
|
||||
|
|
@ -215,6 +219,9 @@ public class FitFileService {
|
|||
trainingLoadService.updateTrainingLoad(savedActivity);
|
||||
activitySummaryService.updateSummariesForActivity(savedActivity);
|
||||
|
||||
// Update heatmap grid
|
||||
heatmapGridService.updateHeatmapForActivity(savedActivity);
|
||||
|
||||
return savedActivity;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,277 @@
|
|||
package org.operaton.fitpub.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.locationtech.jts.geom.Coordinate;
|
||||
import org.locationtech.jts.geom.GeometryFactory;
|
||||
import org.locationtech.jts.geom.Point;
|
||||
import org.locationtech.jts.geom.PrecisionModel;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.User;
|
||||
import org.operaton.fitpub.model.entity.UserHeatmapGrid;
|
||||
import org.operaton.fitpub.repository.ActivityRepository;
|
||||
import org.operaton.fitpub.repository.UserHeatmapGridRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Service for managing user activity heatmap grids.
|
||||
* Aggregates GPS track points into spatial grid cells for efficient heatmap rendering.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class HeatmapGridService {
|
||||
|
||||
private final UserHeatmapGridRepository heatmapGridRepository;
|
||||
private final ActivityRepository activityRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Grid resolution in degrees (~100m at equator).
|
||||
* 0.001 degrees = ~111 meters
|
||||
*/
|
||||
private static final double GRID_SIZE = 0.001;
|
||||
|
||||
/**
|
||||
* SRID for WGS84 coordinate system.
|
||||
*/
|
||||
private static final int SRID = 4326;
|
||||
|
||||
/**
|
||||
* Geometry factory for creating PostGIS points.
|
||||
*/
|
||||
private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), SRID);
|
||||
|
||||
/**
|
||||
* Sampling rate for large activities.
|
||||
* Process every Nth point to avoid overwhelming the grid.
|
||||
*/
|
||||
private static final int SAMPLING_RATE = 10;
|
||||
|
||||
/**
|
||||
* Update heatmap grid for a single activity.
|
||||
* Called when a new activity is uploaded.
|
||||
*
|
||||
* @param activity the activity to process
|
||||
*/
|
||||
@Transactional
|
||||
public void updateHeatmapForActivity(Activity activity) {
|
||||
log.info("Updating heatmap grid for activity {} (user {})", activity.getId(), activity.getUserId());
|
||||
|
||||
List<Point> gridCells = extractGridCellsFromActivity(activity);
|
||||
if (gridCells.isEmpty()) {
|
||||
log.warn("No grid cells extracted from activity {}", activity.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
// Count frequency of each grid cell
|
||||
Map<String, Integer> cellCounts = new HashMap<>();
|
||||
for (Point cell : gridCells) {
|
||||
String key = cellKey(cell);
|
||||
cellCounts.put(key, cellCounts.getOrDefault(key, 0) + 1);
|
||||
}
|
||||
|
||||
// Upsert grid cells
|
||||
for (Map.Entry<String, Integer> entry : cellCounts.entrySet()) {
|
||||
Point cell = parseCell(entry.getKey());
|
||||
int count = entry.getValue();
|
||||
upsertGridCell(activity.getUserId(), cell, count);
|
||||
}
|
||||
|
||||
log.info("Updated {} unique grid cells for activity {}", cellCounts.size(), activity.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate entire heatmap for a user.
|
||||
* Called by scheduled job or when user requests full recalculation.
|
||||
*
|
||||
* @param user the user to recalculate
|
||||
*/
|
||||
@Transactional
|
||||
public void recalculateUserHeatmap(User user) {
|
||||
log.info("Recalculating heatmap for user {}", user.getUsername());
|
||||
|
||||
// Delete existing grid
|
||||
heatmapGridRepository.deleteByUserId(user.getId());
|
||||
|
||||
// Get all activities for user
|
||||
List<Activity> activities = activityRepository.findByUserIdOrderByStartedAtDesc(user.getId());
|
||||
if (activities.isEmpty()) {
|
||||
log.info("No activities found for user {}", user.getUsername());
|
||||
return;
|
||||
}
|
||||
|
||||
// Aggregate all grid cells across all activities
|
||||
Map<String, Integer> allCellCounts = new HashMap<>();
|
||||
|
||||
for (Activity activity : activities) {
|
||||
List<Point> gridCells = extractGridCellsFromActivity(activity);
|
||||
for (Point cell : gridCells) {
|
||||
String key = cellKey(cell);
|
||||
allCellCounts.put(key, allCellCounts.getOrDefault(key, 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk insert grid cells
|
||||
List<UserHeatmapGrid> gridEntities = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> entry : allCellCounts.entrySet()) {
|
||||
Point cell = parseCell(entry.getKey());
|
||||
UserHeatmapGrid grid = UserHeatmapGrid.builder()
|
||||
.userId(user.getId())
|
||||
.gridCell(cell)
|
||||
.pointCount(entry.getValue())
|
||||
.build();
|
||||
gridEntities.add(grid);
|
||||
}
|
||||
|
||||
heatmapGridRepository.saveAll(gridEntities);
|
||||
log.info("Recalculated {} grid cells for user {} from {} activities",
|
||||
gridEntities.size(), user.getUsername(), activities.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get heatmap data for a user, optionally filtered by bounding box.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @param minLon minimum longitude (optional)
|
||||
* @param minLat minimum latitude (optional)
|
||||
* @param maxLon maximum longitude (optional)
|
||||
* @param maxLat maximum latitude (optional)
|
||||
* @return list of grid cells with intensities
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<UserHeatmapGrid> getUserHeatmapData(UUID userId, Double minLon, Double minLat, Double maxLon, Double maxLat) {
|
||||
if (minLon != null && minLat != null && maxLon != null && maxLat != null) {
|
||||
log.debug("Fetching heatmap for user {} with bounding box", userId);
|
||||
return heatmapGridRepository.findByUserIdWithinBoundingBox(userId, minLon, minLat, maxLon, maxLat);
|
||||
} else {
|
||||
log.debug("Fetching full heatmap for user {}", userId);
|
||||
return heatmapGridRepository.findByUserId(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum point count for a user (for normalization).
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return maximum point count
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Integer getMaxPointCount(UUID userId) {
|
||||
Integer max = heatmapGridRepository.findMaxPointCountByUserId(userId);
|
||||
return max != null ? max : 1; // Avoid division by zero
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract grid cells from an activity's track points.
|
||||
*
|
||||
* @param activity the activity
|
||||
* @return list of grid cell points
|
||||
*/
|
||||
private List<Point> extractGridCellsFromActivity(Activity activity) {
|
||||
String trackPointsJson = activity.getTrackPointsJson();
|
||||
if (trackPointsJson == null || trackPointsJson.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(trackPointsJson);
|
||||
if (!root.isArray()) {
|
||||
log.warn("Track points JSON is not an array for activity {}", activity.getId());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<Point> gridCells = new ArrayList<>();
|
||||
int pointIndex = 0;
|
||||
|
||||
for (JsonNode pointNode : root) {
|
||||
// Sample every Nth point for large activities
|
||||
if (pointIndex % SAMPLING_RATE != 0) {
|
||||
pointIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonNode latNode = pointNode.get("latitude");
|
||||
JsonNode lonNode = pointNode.get("longitude");
|
||||
|
||||
if (latNode != null && lonNode != null) {
|
||||
double lat = latNode.asDouble();
|
||||
double lon = lonNode.asDouble();
|
||||
Point gridCell = snapToGrid(lat, lon);
|
||||
gridCells.add(gridCell);
|
||||
}
|
||||
|
||||
pointIndex++;
|
||||
}
|
||||
|
||||
return gridCells;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to parse track points for activity {}", activity.getId(), e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a coordinate to the nearest grid cell center.
|
||||
*
|
||||
* @param lat latitude
|
||||
* @param lon longitude
|
||||
* @return grid cell point
|
||||
*/
|
||||
private Point snapToGrid(double lat, double lon) {
|
||||
double gridLat = Math.floor(lat / GRID_SIZE) * GRID_SIZE + (GRID_SIZE / 2);
|
||||
double gridLon = Math.floor(lon / GRID_SIZE) * GRID_SIZE + (GRID_SIZE / 2);
|
||||
return geometryFactory.createPoint(new Coordinate(gridLon, gridLat));
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a grid cell (insert or increment count).
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @param gridCell the grid cell point
|
||||
* @param increment the count to add
|
||||
*/
|
||||
private void upsertGridCell(UUID userId, Point gridCell, int increment) {
|
||||
Optional<UserHeatmapGrid> existing = heatmapGridRepository.findByUserIdAndGridCell(userId, gridCell);
|
||||
if (existing.isPresent()) {
|
||||
UserHeatmapGrid grid = existing.get();
|
||||
grid.setPointCount(grid.getPointCount() + increment);
|
||||
heatmapGridRepository.save(grid);
|
||||
} else {
|
||||
UserHeatmapGrid grid = UserHeatmapGrid.builder()
|
||||
.userId(userId)
|
||||
.gridCell(gridCell)
|
||||
.pointCount(increment)
|
||||
.build();
|
||||
heatmapGridRepository.save(grid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique key for a grid cell.
|
||||
*
|
||||
* @param cell the grid cell point
|
||||
* @return cell key string
|
||||
*/
|
||||
private String cellKey(Point cell) {
|
||||
return String.format("%.6f,%.6f", cell.getY(), cell.getX());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a cell from a key string.
|
||||
*
|
||||
* @param key the cell key
|
||||
* @return grid cell point
|
||||
*/
|
||||
private Point parseCell(String key) {
|
||||
String[] parts = key.split(",");
|
||||
double lat = Double.parseDouble(parts[0]);
|
||||
double lon = Double.parseDouble(parts[1]);
|
||||
return geometryFactory.createPoint(new Coordinate(lon, lat));
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,12 @@ public class WebFingerClient {
|
|||
@Value("${fitpub.domain}")
|
||||
private String localDomain;
|
||||
|
||||
@Value("${fitpub.activitypub.allow-private-ips:false}")
|
||||
private boolean allowPrivateIps;
|
||||
|
||||
@Value("${fitpub.activitypub.federation-protocol:https}")
|
||||
private String federationProtocol;
|
||||
|
||||
private static final int TIMEOUT_SECONDS = 5;
|
||||
private static final String WEBFINGER_PATH = "/.well-known/webfinger";
|
||||
private static final String ACTIVITYPUB_CONTENT_TYPE = "application/activity+json";
|
||||
|
|
@ -101,13 +107,13 @@ public class WebFingerClient {
|
|||
}
|
||||
|
||||
// Validate domain format (basic check - allow domains and IP addresses)
|
||||
// Domain: must have at least one dot and end with 2+ letters
|
||||
// IP: must be 4 numbers separated by dots
|
||||
boolean isValidDomain = domain.matches("^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");
|
||||
boolean isValidIP = domain.matches("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$");
|
||||
// Domain: must have at least one dot and end with 2+ letters, optional port
|
||||
// IP: must be 4 numbers separated by dots, optional port
|
||||
boolean isValidDomain = domain.matches("^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}(:[0-9]+)?$");
|
||||
boolean isValidIP = domain.matches("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:[0-9]+)?$");
|
||||
|
||||
if (!isValidDomain && !isValidIP) {
|
||||
throw new IllegalArgumentException("Invalid domain format");
|
||||
throw new IllegalArgumentException("Invalid domain format. Domain must be a valid hostname or IP address, optionally with port");
|
||||
}
|
||||
|
||||
return new ParsedHandle(username, domain);
|
||||
|
|
@ -124,7 +130,7 @@ public class WebFingerClient {
|
|||
private Map<String, Object> fetchWebFingerResource(String domain, String username) throws IOException {
|
||||
// Construct WebFinger URL
|
||||
String resource = "acct:" + username + "@" + domain;
|
||||
String webFingerUrl = "https://" + domain + WEBFINGER_PATH + "?resource=" + resource;
|
||||
String webFingerUrl = federationProtocol + "://" + domain + WEBFINGER_PATH + "?resource=" + resource;
|
||||
|
||||
log.debug("Fetching WebFinger resource: {}", webFingerUrl);
|
||||
|
||||
|
|
@ -173,6 +179,12 @@ public class WebFingerClient {
|
|||
throw new IllegalArgumentException("Cannot discover local users via WebFinger. Use local API instead.");
|
||||
}
|
||||
|
||||
// If private IPs are allowed (local testing mode), skip SSRF protection
|
||||
if (allowPrivateIps) {
|
||||
log.debug("Private IPs allowed - skipping SSRF validation for domain: {}", domain);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
InetAddress address = InetAddress.getByName(domain);
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ fitpub:
|
|||
enabled: true
|
||||
max-federation-retries: 3
|
||||
request-timeout-seconds: 30
|
||||
# Allow connections to private IPs (for local testing only - disable in production!)
|
||||
allow-private-ips: ${FITPUB_ALLOW_PRIVATE_IPS:false}
|
||||
# Federation protocol (http or https) - use http for local testing, https for production
|
||||
federation-protocol: ${FITPUB_FEDERATION_PROTOCOL:https}
|
||||
|
||||
# Security settings
|
||||
security:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
-- Create user_heatmap_grid table for aggregated track density
|
||||
CREATE TABLE user_heatmap_grid (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
grid_cell GEOMETRY(Point, 4326) NOT NULL,
|
||||
point_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_user_grid_cell UNIQUE(user_id, grid_cell)
|
||||
);
|
||||
|
||||
-- Index for user lookups
|
||||
CREATE INDEX idx_user_heatmap_grid_user ON user_heatmap_grid(user_id);
|
||||
|
||||
-- Spatial index for grid cell lookups
|
||||
CREATE INDEX idx_user_heatmap_grid_spatial ON user_heatmap_grid USING GIST(grid_cell);
|
||||
|
||||
-- Index for time-based queries
|
||||
CREATE INDEX idx_user_heatmap_grid_updated ON user_heatmap_grid(last_updated);
|
||||
|
|
@ -217,6 +217,7 @@ const FitPubAuth = {
|
|||
const myActivitiesLink = document.getElementById('myActivitiesLink');
|
||||
const uploadLink = document.getElementById('uploadLink');
|
||||
const analyticsLink = document.getElementById('analyticsLink');
|
||||
const heatmapLink = document.getElementById('heatmapLink');
|
||||
const notificationsBell = document.getElementById('notificationsBell');
|
||||
|
||||
if (this.isAuthenticated()) {
|
||||
|
|
@ -241,6 +242,10 @@ const FitPubAuth = {
|
|||
analyticsLink.style.display = '';
|
||||
analyticsLink.parentElement.style.display = '';
|
||||
}
|
||||
if (heatmapLink) {
|
||||
heatmapLink.style.display = '';
|
||||
heatmapLink.parentElement.style.display = '';
|
||||
}
|
||||
|
||||
// Show notifications bell
|
||||
if (notificationsBell) {
|
||||
|
|
@ -280,6 +285,10 @@ const FitPubAuth = {
|
|||
analyticsLink.style.display = 'none';
|
||||
analyticsLink.parentElement.style.display = 'none';
|
||||
}
|
||||
if (heatmapLink) {
|
||||
heatmapLink.style.display = 'none';
|
||||
heatmapLink.parentElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
177
src/main/resources/static/js/heatmap.js
Normal file
177
src/main/resources/static/js/heatmap.js
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* Heatmap visualization module
|
||||
* Renders user activity heatmap using Leaflet.heat
|
||||
*/
|
||||
|
||||
let heatmapMap = null;
|
||||
let heatLayer = null;
|
||||
|
||||
/**
|
||||
* Initialize the heatmap on page load
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
// Check authentication
|
||||
if (!FitPubAuth.isAuthenticated()) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
await loadHeatmap();
|
||||
});
|
||||
|
||||
/**
|
||||
* Load and render the heatmap
|
||||
*/
|
||||
async function loadHeatmap() {
|
||||
const loadingIndicator = document.getElementById('loadingIndicator');
|
||||
const errorAlert = document.getElementById('errorAlert');
|
||||
const errorMessage = document.getElementById('errorMessage');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const heatmapContainer = document.getElementById('heatmapContainer');
|
||||
const statsCard = document.getElementById('statsCard');
|
||||
const legend = document.getElementById('legend');
|
||||
|
||||
// Show loading
|
||||
loadingIndicator.style.display = 'block';
|
||||
errorAlert.classList.add('d-none');
|
||||
emptyState.classList.add('d-none');
|
||||
heatmapContainer.style.display = 'none';
|
||||
statsCard.style.display = 'none';
|
||||
legend.style.display = 'none';
|
||||
|
||||
try {
|
||||
// Fetch heatmap data
|
||||
const response = await FitPubAuth.authenticatedFetch('/api/heatmap/me');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load heatmap data');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Hide loading
|
||||
loadingIndicator.style.display = 'none';
|
||||
|
||||
// Check if user has any data
|
||||
if (!data.features || data.features.length === 0) {
|
||||
emptyState.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show map and stats
|
||||
heatmapContainer.style.display = 'block';
|
||||
statsCard.style.display = 'block';
|
||||
legend.style.display = 'block';
|
||||
|
||||
// Update stats
|
||||
document.getElementById('cellCount').textContent = data.features.length.toLocaleString();
|
||||
document.getElementById('maxIntensity').textContent = data.maxIntensity.toLocaleString();
|
||||
|
||||
// Initialize map
|
||||
initializeMap();
|
||||
|
||||
// Render heatmap
|
||||
renderHeatmap(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading heatmap:', error);
|
||||
loadingIndicator.style.display = 'none';
|
||||
errorAlert.classList.remove('d-none');
|
||||
errorMessage.textContent = 'Failed to load heatmap. Please try again later.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Leaflet map
|
||||
*/
|
||||
function initializeMap() {
|
||||
if (heatmapMap) {
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
// Create map centered on world
|
||||
heatmapMap = L.map('heatmapContainer').setView([20, 0], 2);
|
||||
|
||||
// Add OpenStreetMap tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 18
|
||||
}).addTo(heatmapMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render heatmap layer from GeoJSON data
|
||||
*/
|
||||
function renderHeatmap(data) {
|
||||
// Convert GeoJSON features to Leaflet.heat format: [lat, lon, intensity]
|
||||
const heatData = data.features.map(feature => {
|
||||
const lon = feature.geometry.coordinates[0];
|
||||
const lat = feature.geometry.coordinates[1];
|
||||
const intensity = feature.properties.intensity;
|
||||
|
||||
// Normalize intensity to 0-1 range
|
||||
const normalizedIntensity = Math.min(intensity / data.maxIntensity, 1.0);
|
||||
|
||||
return [lat, lon, normalizedIntensity];
|
||||
});
|
||||
|
||||
// Remove existing heat layer if present
|
||||
if (heatLayer) {
|
||||
heatmapMap.removeLayer(heatLayer);
|
||||
}
|
||||
|
||||
// Create heat layer
|
||||
heatLayer = L.heatLayer(heatData, {
|
||||
radius: 25,
|
||||
blur: 15,
|
||||
maxZoom: 17,
|
||||
max: 1.0,
|
||||
gradient: {
|
||||
0.0: 'blue',
|
||||
0.4: 'cyan',
|
||||
0.6: 'lime',
|
||||
0.7: 'yellow',
|
||||
0.9: 'orange',
|
||||
1.0: 'red'
|
||||
}
|
||||
}).addTo(heatmapMap);
|
||||
|
||||
// Fit map bounds to heatmap data
|
||||
fitMapToBounds(data.features);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit map to show all heatmap data
|
||||
*/
|
||||
function fitMapToBounds(features) {
|
||||
if (features.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate bounds
|
||||
let minLat = Infinity;
|
||||
let maxLat = -Infinity;
|
||||
let minLon = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
|
||||
features.forEach(feature => {
|
||||
const lon = feature.geometry.coordinates[0];
|
||||
const lat = feature.geometry.coordinates[1];
|
||||
|
||||
minLat = Math.min(minLat, lat);
|
||||
maxLat = Math.max(maxLat, lat);
|
||||
minLon = Math.min(minLon, lon);
|
||||
maxLon = Math.max(maxLon, lon);
|
||||
});
|
||||
|
||||
// Add padding
|
||||
const latPadding = (maxLat - minLat) * 0.1;
|
||||
const lonPadding = (maxLon - minLon) * 0.1;
|
||||
|
||||
const bounds = [
|
||||
[minLat - latPadding, minLon - lonPadding],
|
||||
[maxLat + latPadding, maxLon + lonPadding]
|
||||
];
|
||||
|
||||
heatmapMap.fitBounds(bounds);
|
||||
}
|
||||
132
src/main/resources/templates/heatmap.html
Normal file
132
src/main/resources/templates/heatmap.html
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout}">
|
||||
|
||||
<head>
|
||||
<title>My Heatmap - FitPub</title>
|
||||
<style>
|
||||
#heatmapContainer {
|
||||
height: 80vh;
|
||||
min-height: 500px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.heatmap-stats {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-item i {
|
||||
font-size: 1.5rem;
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="mb-4">
|
||||
<h2 class="mb-3">
|
||||
<i class="bi bi-map text-primary"></i>
|
||||
My Activity Heatmap
|
||||
</h2>
|
||||
<p class="text-muted">
|
||||
Visualize all your activities on a single map. Hotter colors show areas you visit more frequently.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Card -->
|
||||
<div class="heatmap-stats mb-4" id="statsCard" style="display: none;">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3 mb-md-0">
|
||||
<div class="stat-item">
|
||||
<i class="bi bi-grid-3x3"></i>
|
||||
<div>
|
||||
<small class="text-muted d-block">Grid Cells</small>
|
||||
<strong id="cellCount">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3 mb-md-0">
|
||||
<div class="stat-item">
|
||||
<i class="bi bi-fire"></i>
|
||||
<div>
|
||||
<small class="text-muted d-block">Max Intensity</small>
|
||||
<strong id="maxIntensity">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="stat-item">
|
||||
<i class="bi bi-clock-history"></i>
|
||||
<div>
|
||||
<small class="text-muted d-block">Last Updated</small>
|
||||
<strong id="lastUpdated">Just now</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<div id="loadingIndicator" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-2 text-muted">Loading your heatmap...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<div id="errorAlert" class="alert alert-danger d-none" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
<span id="errorMessage"></span>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="emptyState" class="empty-state empty-state-activities d-none">
|
||||
<div class="empty-state-icon">
|
||||
<i class="bi bi-map"></i>
|
||||
</div>
|
||||
<h3>No Activities Yet</h3>
|
||||
<p>Upload your first activity to see your heatmap!</p>
|
||||
<a th:href="@{/upload}" class="btn btn-primary">
|
||||
<i class="bi bi-upload"></i> Upload Activity
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Map Container -->
|
||||
<div id="heatmapContainer" style="display: none;"></div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="mt-3 text-center text-muted" id="legend" style="display: none;">
|
||||
<small>
|
||||
<span style="color: blue;">●</span> Low Activity
|
||||
<span class="ms-2" style="color: cyan;">●</span> Moderate
|
||||
<span class="ms-2" style="color: yellow;">●</span> High
|
||||
<span class="ms-2" style="color: red;">●</span> Very High
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<th:block layout:fragment="scripts">
|
||||
<script th:src="@{/js/heatmap.js}"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -75,6 +75,11 @@
|
|||
<i class="bi bi-graph-up"></i> Analytics
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" th:href="@{/heatmap}" id="heatmapLink" style="display: none;">
|
||||
<i class="bi bi-map"></i> Heatmap
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Right side navigation -->
|
||||
|
|
@ -218,6 +223,9 @@
|
|||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<!-- Leaflet.heat Plugin -->
|
||||
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||||
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@
|
|||
<input type="text"
|
||||
class="form-control"
|
||||
id="remoteUserHandle"
|
||||
placeholder="username@domain.com"
|
||||
pattern="[a-zA-Z0-9_]+@[a-zA-Z0-9.-]+"
|
||||
placeholder="username@domain.com or username@instance.local:8080"
|
||||
pattern="[a-zA-Z0-9_-]+@[a-zA-Z0-9.-]+(:[0-9]+)?"
|
||||
autocomplete="off"
|
||||
required>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ class FitFileServiceTest {
|
|||
@Mock
|
||||
private WeatherService weatherService;
|
||||
|
||||
@Mock
|
||||
private HeatmapGridService heatmapGridService;
|
||||
|
||||
@Spy
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
package org.operaton.fitpub.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.locationtech.jts.geom.Coordinate;
|
||||
import org.locationtech.jts.geom.GeometryFactory;
|
||||
import org.locationtech.jts.geom.Point;
|
||||
import org.locationtech.jts.geom.PrecisionModel;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.User;
|
||||
import org.operaton.fitpub.model.entity.UserHeatmapGrid;
|
||||
import org.operaton.fitpub.repository.ActivityRepository;
|
||||
import org.operaton.fitpub.repository.UserHeatmapGridRepository;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for HeatmapGridService.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class HeatmapGridServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserHeatmapGridRepository heatmapGridRepository;
|
||||
|
||||
@Mock
|
||||
private ActivityRepository activityRepository;
|
||||
|
||||
private HeatmapGridService heatmapGridService;
|
||||
private ObjectMapper objectMapper;
|
||||
private GeometryFactory geometryFactory;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = new ObjectMapper();
|
||||
heatmapGridService = new HeatmapGridService(
|
||||
heatmapGridRepository,
|
||||
activityRepository,
|
||||
objectMapper
|
||||
);
|
||||
geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateHeatmapForActivity_WithValidTrackPoints() throws Exception {
|
||||
// Create test activity with track points JSON
|
||||
UUID userId = UUID.randomUUID();
|
||||
Activity activity = Activity.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.userId(userId)
|
||||
.activityType(Activity.ActivityType.RUN)
|
||||
.title("Test Run")
|
||||
.visibility(Activity.Visibility.PUBLIC)
|
||||
.build();
|
||||
|
||||
// Create track points JSON (3 points in a small area)
|
||||
List<Map<String, Object>> trackPoints = new ArrayList<>();
|
||||
trackPoints.add(createTrackPoint(52.520008, 13.404954)); // Berlin
|
||||
trackPoints.add(createTrackPoint(52.520108, 13.405054)); // ~15m away
|
||||
trackPoints.add(createTrackPoint(52.520208, 13.405154)); // ~30m away
|
||||
|
||||
String trackPointsJson = objectMapper.writeValueAsString(trackPoints);
|
||||
activity.setTrackPointsJson(trackPointsJson);
|
||||
|
||||
// Mock repository behavior
|
||||
when(heatmapGridRepository.findByUserIdAndGridCell(any(UUID.class), any(Point.class)))
|
||||
.thenReturn(Optional.empty());
|
||||
when(heatmapGridRepository.save(any(UserHeatmapGrid.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
// Execute
|
||||
heatmapGridService.updateHeatmapForActivity(activity);
|
||||
|
||||
// Verify that grid cells were saved
|
||||
ArgumentCaptor<UserHeatmapGrid> gridCaptor = ArgumentCaptor.forClass(UserHeatmapGrid.class);
|
||||
verify(heatmapGridRepository, atLeastOnce()).save(gridCaptor.capture());
|
||||
|
||||
List<UserHeatmapGrid> savedGrids = gridCaptor.getAllValues();
|
||||
assertFalse(savedGrids.isEmpty(), "Should save at least one grid cell");
|
||||
|
||||
// Verify grid cell properties
|
||||
UserHeatmapGrid firstGrid = savedGrids.get(0);
|
||||
assertEquals(userId, firstGrid.getUserId());
|
||||
assertNotNull(firstGrid.getGridCell());
|
||||
assertTrue(firstGrid.getPointCount() > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateHeatmapForActivity_WithEmptyTrackPoints() {
|
||||
// Create activity with empty track points
|
||||
Activity activity = Activity.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.userId(UUID.randomUUID())
|
||||
.activityType(Activity.ActivityType.RUN)
|
||||
.title("Test Run")
|
||||
.trackPointsJson("[]")
|
||||
.build();
|
||||
|
||||
// Execute
|
||||
heatmapGridService.updateHeatmapForActivity(activity);
|
||||
|
||||
// Verify no grid cells were saved
|
||||
verify(heatmapGridRepository, never()).save(any(UserHeatmapGrid.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateHeatmapForActivity_WithNullTrackPoints() {
|
||||
// Create activity with null track points
|
||||
Activity activity = Activity.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.userId(UUID.randomUUID())
|
||||
.activityType(Activity.ActivityType.RUN)
|
||||
.title("Test Run")
|
||||
.trackPointsJson(null)
|
||||
.build();
|
||||
|
||||
// Execute
|
||||
heatmapGridService.updateHeatmapForActivity(activity);
|
||||
|
||||
// Verify no grid cells were saved
|
||||
verify(heatmapGridRepository, never()).save(any(UserHeatmapGrid.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRecalculateUserHeatmap() throws Exception {
|
||||
// Create test user and activities
|
||||
UUID userId = UUID.randomUUID();
|
||||
User user = User.builder()
|
||||
.id(userId)
|
||||
.username("testuser")
|
||||
.build();
|
||||
|
||||
// Create activities with track points
|
||||
List<Activity> activities = new ArrayList<>();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
Activity activity = Activity.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.userId(userId)
|
||||
.activityType(Activity.ActivityType.RUN)
|
||||
.title("Test Run " + i)
|
||||
.build();
|
||||
|
||||
List<Map<String, Object>> trackPoints = new ArrayList<>();
|
||||
trackPoints.add(createTrackPoint(52.520008 + i * 0.01, 13.404954 + i * 0.01));
|
||||
activity.setTrackPointsJson(objectMapper.writeValueAsString(trackPoints));
|
||||
activities.add(activity);
|
||||
}
|
||||
|
||||
// Mock repository behavior
|
||||
when(activityRepository.findByUserIdOrderByStartedAtDesc(userId))
|
||||
.thenReturn(activities);
|
||||
when(heatmapGridRepository.saveAll(anyList()))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
// Execute
|
||||
heatmapGridService.recalculateUserHeatmap(user);
|
||||
|
||||
// Verify
|
||||
verify(heatmapGridRepository).deleteByUserId(userId);
|
||||
verify(activityRepository).findByUserIdOrderByStartedAtDesc(userId);
|
||||
verify(heatmapGridRepository, atLeastOnce()).saveAll(anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetUserHeatmapData_WithBoundingBox() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
List<UserHeatmapGrid> expectedGrids = new ArrayList<>();
|
||||
|
||||
// Mock repository
|
||||
when(heatmapGridRepository.findByUserIdWithinBoundingBox(
|
||||
userId, 13.0, 52.0, 14.0, 53.0))
|
||||
.thenReturn(expectedGrids);
|
||||
|
||||
// Execute
|
||||
List<UserHeatmapGrid> result = heatmapGridService.getUserHeatmapData(
|
||||
userId, 13.0, 52.0, 14.0, 53.0);
|
||||
|
||||
// Verify
|
||||
assertEquals(expectedGrids, result);
|
||||
verify(heatmapGridRepository).findByUserIdWithinBoundingBox(
|
||||
userId, 13.0, 52.0, 14.0, 53.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetUserHeatmapData_WithoutBoundingBox() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
List<UserHeatmapGrid> expectedGrids = new ArrayList<>();
|
||||
|
||||
// Mock repository
|
||||
when(heatmapGridRepository.findByUserId(userId))
|
||||
.thenReturn(expectedGrids);
|
||||
|
||||
// Execute
|
||||
List<UserHeatmapGrid> result = heatmapGridService.getUserHeatmapData(
|
||||
userId, null, null, null, null);
|
||||
|
||||
// Verify
|
||||
assertEquals(expectedGrids, result);
|
||||
verify(heatmapGridRepository).findByUserId(userId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetMaxPointCount() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
Integer expectedMax = 150;
|
||||
|
||||
// Mock repository
|
||||
when(heatmapGridRepository.findMaxPointCountByUserId(userId))
|
||||
.thenReturn(expectedMax);
|
||||
|
||||
// Execute
|
||||
Integer result = heatmapGridService.getMaxPointCount(userId);
|
||||
|
||||
// Verify
|
||||
assertEquals(expectedMax, result);
|
||||
verify(heatmapGridRepository).findMaxPointCountByUserId(userId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetMaxPointCount_ReturnsOneWhenNull() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
|
||||
// Mock repository to return null
|
||||
when(heatmapGridRepository.findMaxPointCountByUserId(userId))
|
||||
.thenReturn(null);
|
||||
|
||||
// Execute
|
||||
Integer result = heatmapGridService.getMaxPointCount(userId);
|
||||
|
||||
// Verify it returns 1 to avoid division by zero
|
||||
assertEquals(1, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create a track point map.
|
||||
*/
|
||||
private Map<String, Object> createTrackPoint(double latitude, double longitude) {
|
||||
Map<String, Object> point = new HashMap<>();
|
||||
point.put("latitude", latitude);
|
||||
point.put("longitude", longitude);
|
||||
point.put("elevation", 100.0);
|
||||
point.put("timestamp", "2025-01-01T12:00:00");
|
||||
return point;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue