diff --git a/src/main/java/io/redlink/more/data/controller/GarminApiV1Controller.java b/src/main/java/io/redlink/more/data/controller/GarminApiV1Controller.java index d2d1c36..482b8a6 100644 --- a/src/main/java/io/redlink/more/data/controller/GarminApiV1Controller.java +++ b/src/main/java/io/redlink/more/data/controller/GarminApiV1Controller.java @@ -4,6 +4,7 @@ import io.redlink.more.data.api.app.v1.model.UpdatePermissionsRequestDTO; import io.redlink.more.data.api.app.v1.webservices.GarminUserManagementApi; import io.redlink.more.data.custom.model.GarminDataPoint; +import io.redlink.more.data.model.Alias; import io.redlink.more.data.model.garmin.GarminSummaryType; import io.redlink.more.data.service.garmin.GarminService; import io.redlink.more.data.util.MapperUtils; @@ -30,6 +31,11 @@ public class GarminApiV1Controller implements GarminUserManagementApi { private final GarminService garminService; + private final List aliases = List.of( + new Alias("measurementTimeInSeconds", "startTimeInSeconds"), + new Alias("measurementTimeOffsetInSeconds", "startTimeOffsetInSeconds") + ); + public GarminApiV1Controller(GarminService garminService) { this.garminService = garminService; } @@ -73,7 +79,11 @@ public ResponseEntity submitSummaries(String userAgent, String garminClien .collect(Collectors.toMap( entry -> GarminSummaryType.fromLabel(entry.getKey()), entry -> ((List) entry.getValue()).stream() - .map(item -> MapperUtils.convertValue(item, GarminDataPoint.class)) + .map(item -> MapperUtils.convertValueWithAliases( + item, + GarminDataPoint.class, + aliases + )) .collect(Collectors.toList()) )); garminService.storeData(requestDataPoints); diff --git a/src/main/java/io/redlink/more/data/model/Alias.java b/src/main/java/io/redlink/more/data/model/Alias.java new file mode 100644 index 0000000..4324ace --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/Alias.java @@ -0,0 +1,4 @@ +package io.redlink.more.data.model; + +public record Alias(String key, String replacement) { +} diff --git a/src/main/java/io/redlink/more/data/model/DataType.java b/src/main/java/io/redlink/more/data/model/DataType.java index 1e690b0..d572a38 100644 --- a/src/main/java/io/redlink/more/data/model/DataType.java +++ b/src/main/java/io/redlink/more/data/model/DataType.java @@ -7,7 +7,8 @@ public enum DataType { SLEEP_START("sleep_start"), SLEEP_END("sleep_end"), DAILY_STEPS("daily_steps"), - EPOCH_STEPS("epoch_steps"); + EPOCH_STEPS("epoch_steps"), + BLOOD_PRESSURE("blood_pressure"); public final String dataType; diff --git a/src/main/java/io/redlink/more/data/model/garmin/GarminSummaryType.java b/src/main/java/io/redlink/more/data/model/garmin/GarminSummaryType.java index ffd2ac5..65afcea 100644 --- a/src/main/java/io/redlink/more/data/model/garmin/GarminSummaryType.java +++ b/src/main/java/io/redlink/more/data/model/garmin/GarminSummaryType.java @@ -9,7 +9,8 @@ public enum GarminSummaryType { STRESSDETAILS, PULSEOX, SLEEPS, - HRV; + HRV, + BLOODPRESSURES; public final String label; diff --git a/src/main/java/io/redlink/more/data/model/garmin/transformation/GarminBloodPressure.java b/src/main/java/io/redlink/more/data/model/garmin/transformation/GarminBloodPressure.java new file mode 100644 index 0000000..49d0732 --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/garmin/transformation/GarminBloodPressure.java @@ -0,0 +1,29 @@ +package io.redlink.more.data.model.garmin.transformation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Represents a blood pressure record in the Garmin ecosystem. + * This record encapsulates systolic and diastolic blood pressure readings, + * pulse information, and the source from which the data was obtained. + *

+ * The {@link SourceType} enum defines the possible origins of the measurement, + * which can either be entered manually or recorded by a device. + * + * @param systolic The systolic blood pressure value measured in mmHg. + * @param diastolic The diastolic blood pressure value measured in mmHg. + * @param pulse The pulse rate measured in beats per minute (BPM). + * @param sourceType The origin of the blood pressure record (manual entry or device). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record GarminBloodPressure( + Integer systolic, + Integer diastolic, + Integer pulse, + SourceType sourceType +) { + public enum SourceType { + MANUAL, + DEVICE + } +} diff --git a/src/main/java/io/redlink/more/data/transformers/garmin/AbstractGarminTransformer.java b/src/main/java/io/redlink/more/data/transformers/garmin/AbstractGarminTransformer.java index 8f238c8..f52493b 100644 --- a/src/main/java/io/redlink/more/data/transformers/garmin/AbstractGarminTransformer.java +++ b/src/main/java/io/redlink/more/data/transformers/garmin/AbstractGarminTransformer.java @@ -7,11 +7,11 @@ import io.redlink.more.data.model.garmin.GarminSummaryType; import io.redlink.more.data.model.garmin.transformation.GarminTimeData; import io.redlink.more.data.schedule.SchedulerUtils; +import io.redlink.more.data.util.DateTimeUtils; import org.apache.commons.lang3.Range; import java.time.Instant; import java.time.OffsetDateTime; -import java.time.ZoneOffset; import java.util.Collections; import java.util.List; import java.util.UUID; @@ -58,16 +58,12 @@ protected List transformGarminTimeDataToDataPoint(List o } protected OffsetDateTime recordingTimestamp(GarminDataPoint garminDataPoint) { - Instant unixTimestamp = Instant.ofEpochSecond(garminDataPoint.getStartTimeInSeconds()); - ZoneOffset offset = ZoneOffset.ofTotalSeconds(garminDataPoint.getStartTimeOffsetInSeconds()); - return unixTimestamp.atOffset(offset); + return DateTimeUtils.offsetDateTimeFromEpochSeconds(garminDataPoint.getStartTimeInSeconds(), garminDataPoint.getStartTimeOffsetInSeconds()); } protected OffsetDateTime endDateTime(GarminDataPoint garminDataPoint) { int endTimestamp = garminDataPoint.getStartTimeInSeconds() + garminDataPoint.getDurationInSeconds(); - Instant unixTimestamp = Instant.ofEpochSecond(endTimestamp); - ZoneOffset offset = ZoneOffset.ofTotalSeconds(garminDataPoint.getStartTimeOffsetInSeconds()); - return unixTimestamp.atOffset(offset); + return DateTimeUtils.offsetDateTimeFromEpochSeconds(endTimestamp, garminDataPoint.getStartTimeOffsetInSeconds()); } protected Range getGarminDataPointTimeRange(GarminDataPoint garminDataPoint) { diff --git a/src/main/java/io/redlink/more/data/transformers/garmin/BloodPressureTransformer.java b/src/main/java/io/redlink/more/data/transformers/garmin/BloodPressureTransformer.java new file mode 100644 index 0000000..5758b76 --- /dev/null +++ b/src/main/java/io/redlink/more/data/transformers/garmin/BloodPressureTransformer.java @@ -0,0 +1,40 @@ +package io.redlink.more.data.transformers.garmin; + +import io.redlink.more.data.custom.model.GarminDataPoint; +import io.redlink.more.data.model.DataPoint; +import io.redlink.more.data.model.DataType; +import io.redlink.more.data.model.Observation; +import io.redlink.more.data.model.garmin.GarminSummaryType; +import io.redlink.more.data.model.garmin.transformation.GarminBloodPressure; +import io.redlink.more.data.model.garmin.transformation.GarminTimeData; +import io.redlink.more.data.util.MapperUtils; +import org.apache.commons.lang3.Range; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.List; + +@Component +public class BloodPressureTransformer extends AbstractGarminTransformer { + @Override + public GarminSummaryType getSupportedType() { + return GarminSummaryType.BLOODPRESSURES; + } + + @Override + protected List transformToDataPoint(List observations, GarminDataPoint garminDataPoint) { + var data = extractBloodPressure(garminDataPoint); + return super.transformGarminTimeDataToDataPoint(observations, garminDataPoint.getSummaryId(), DataType.BLOOD_PRESSURE, data); + } + + @Override + protected List filterDataPointByTimeRange(List> validTimeRanges, List dataBulk) { + return dataBulk; + } + + private GarminTimeData extractBloodPressure(GarminDataPoint garminDataPoint) { + GarminBloodPressure data = MapperUtils.convertValue(garminDataPoint, GarminBloodPressure.class); + var measurementTime = super.recordingTimestamp(garminDataPoint); + return new GarminTimeData<>(measurementTime.toInstant(), data); + } +} diff --git a/src/main/java/io/redlink/more/data/util/DateTimeUtils.java b/src/main/java/io/redlink/more/data/util/DateTimeUtils.java new file mode 100644 index 0000000..27d31d8 --- /dev/null +++ b/src/main/java/io/redlink/more/data/util/DateTimeUtils.java @@ -0,0 +1,13 @@ +package io.redlink.more.data.util; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +public class DateTimeUtils { + public static OffsetDateTime offsetDateTimeFromEpochSeconds(long epochSecond, int offset) { + Instant unixTimestamp = Instant.ofEpochSecond(epochSecond); + ZoneOffset zoneOffset = ZoneOffset.ofTotalSeconds(offset); + return unixTimestamp.atOffset(zoneOffset); + } +} diff --git a/src/main/java/io/redlink/more/data/util/MapperUtils.java b/src/main/java/io/redlink/more/data/util/MapperUtils.java index 7edd3f6..69dca48 100644 --- a/src/main/java/io/redlink/more/data/util/MapperUtils.java +++ b/src/main/java/io/redlink/more/data/util/MapperUtils.java @@ -4,6 +4,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import io.redlink.more.data.exception.BadRequestException; +import io.redlink.more.data.model.Alias; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; public class MapperUtils { public static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new JavaTimeModule()); @@ -29,6 +34,21 @@ public static T convertValue(Object o, Class c) { return MAPPER.convertValue(o, c); } + public static T convertValueWithAliases(Object o, Class targetClass, List aliases) { + if (o == null) return null; + + Map sourceMap = convertValue(o, Map.class); + Map mappedData = new HashMap<>(sourceMap); + + aliases.forEach((alias) -> { + if (!mappedData.containsKey(alias.replacement()) && mappedData.containsKey(alias.key())) { + mappedData.put(alias.replacement(), mappedData.get(alias.key())); + } + }); + + return convertValue(mappedData, targetClass); + } + public static boolean isPrimitiveLike(Object value) { return value instanceof String || value instanceof Number diff --git a/src/main/resources/openapi/CustomModelAPI.yaml b/src/main/resources/openapi/CustomModelAPI.yaml index 9b4967f..634052a 100644 --- a/src/main/resources/openapi/CustomModelAPI.yaml +++ b/src/main/resources/openapi/CustomModelAPI.yaml @@ -123,6 +123,26 @@ components: stepsGoal: type: integer description: Daily steps goal + systolic: + type: integer + format: int32 + description: The systolic value of the blood pressure reading. + diastolic: + type: integer + format: int32 + description: The diastolic value of the blood pressure reading. + pulse: + type: integer + format: int32 + description: Pulse rate at the time the blood pressure reading + sourceType: + type: string + enum: [ MANUAL, DEVICE ] + default: "MANUAL" + description: | + This field is used to determine if blood pressure data was entered manually or synced from aGarmin Device. Possible values: + MANUAL: The user entered blood pressure information manually through a web form. + DEVICE: The user used a Garmin device to perform a blood pressure reading. required: - userId - summaryId diff --git a/src/test/java/io/redlink/more/data/service/BloodPressureTransformerTest.java b/src/test/java/io/redlink/more/data/service/BloodPressureTransformerTest.java new file mode 100644 index 0000000..8c5ad96 --- /dev/null +++ b/src/test/java/io/redlink/more/data/service/BloodPressureTransformerTest.java @@ -0,0 +1,203 @@ +package io.redlink.more.data.service; + +import io.redlink.more.data.custom.model.GarminDataPoint; +import io.redlink.more.data.model.DataPoint; +import io.redlink.more.data.model.DataType; +import io.redlink.more.data.model.Observation; +import io.redlink.more.data.model.garmin.GarminSummaryType; +import io.redlink.more.data.model.garmin.transformation.GarminBloodPressure; +import io.redlink.more.data.model.garmin.transformation.GarminTimeData; +import io.redlink.more.data.model.scheduler.Event; +import io.redlink.more.data.transformers.garmin.BloodPressureTransformer; +import org.apache.commons.lang3.Range; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static io.redlink.more.data.util.ElasticUtils.Constants.GARMIN_SUMMARY_ID_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class BloodPressureTransformerTest { + + private final BloodPressureTransformer transformer = new BloodPressureTransformer(); + + @Test + @DisplayName("getSupportedType: returns BLOODPRESSURES") + void getSupportedType_returnsBloodPressures() { + assertThat(transformer.getSupportedType()).isEqualTo(GarminSummaryType.BLOODPRESSURES); + } + + @Test + @DisplayName("transformGarminTimeDataToDataPoint: builds DataPoint with BP data and summary id") + void transformGarminTimeDataToDataPoint_buildsDataPoint() { + // Subclass to expose protected helper + class TestTransformer extends BloodPressureTransformer { + public List expose(List observations, String summaryId, GarminTimeData data) { + return super.transformGarminTimeDataToDataPoint(observations, summaryId, DataType.BLOOD_PRESSURE, data); + } + } + + TestTransformer t = new TestTransformer(); + + Observation observation = new Observation( + 1, + null, + "Test Obs", + "garmin-bp", + null, + null, + null, + null, + null, + false, + false + ); + + GarminBloodPressure bp = new GarminBloodPressure(120, 80, 65, GarminBloodPressure.SourceType.DEVICE); + Instant ts = Instant.parse("2024-03-01T12:34:56Z"); + GarminTimeData timeData = new GarminTimeData<>(ts, bp); + + List results = t.expose(List.of(observation), "sum-123", timeData); + + assertThat(results).hasSize(1); + DataPoint dp = results.get(0); + assertThat(dp.observationId()).isEqualTo("1"); + assertThat(dp.dataType()).isEqualTo(DataType.BLOOD_PRESSURE.name()); + assertThat(dp.effectiveDateTime()).isEqualTo(ts); + assertThat(dp.data()).containsEntry("systolic", 120) + .containsEntry("diastolic", 80) + .containsEntry("pulse", 65) + .containsEntry("sourceType", "DEVICE") + .containsEntry(GARMIN_SUMMARY_ID_KEY, "sum-123"); + } + + @Test + @DisplayName("transform: returns datapoint using measurement time when observation overlaps Garmin time range") + void transform_returnsDataPoint_whenObservationOverlaps() { + GarminDataPoint garmin = mock(GarminDataPoint.class); + + // Garmin time range (for filtering) + int start = (int) Instant.parse("2024-03-01T12:00:00Z").getEpochSecond(); + int duration = 600; // 10 min + int offset = 0; + + when(garmin.getStartTimeInSeconds()).thenReturn(start); + when(garmin.getDurationInSeconds()).thenReturn(duration); + when(garmin.getStartTimeOffsetInSeconds()).thenReturn(offset); + + // Measurement timestamp used as effective time + Instant measurement = Instant.parse("2024-03-01T12:05:00Z"); + when(garmin.getStartTimeInSeconds()).thenReturn((int) measurement.getEpochSecond()); + when(garmin.getStartTimeOffsetInSeconds()).thenReturn(0); + + when(garmin.getSummaryId()).thenReturn("sum-abc"); + + when(garmin.getSystolic()).thenReturn(118); + when(garmin.getDiastolic()).thenReturn(79); + when(garmin.getPulse()).thenReturn(64); + when(garmin.getSourceType()).thenReturn(GarminDataPoint.SourceTypeEnum.MANUAL); + + // Observation that overlaps Garmin range + Event event = new Event() + .setDateStart(Instant.parse("2024-03-01T11:59:00Z")) + .setDateEnd(Instant.parse("2024-03-01T12:20:00Z")); + + Observation observation = new Observation( + 42, + null, + "BP Observation", + "garmin-bp", + null, + null, + event, + null, + null, + false, + false + ); + + List result = transformer.transform(List.of(observation), garmin, Instant.MIN, Instant.MAX); + + assertThat(result).hasSize(1); + + DataPoint dp = result.get(0); + assertThat(dp.observationId()).isEqualTo("42"); + assertThat(dp.dataType()).isEqualTo(DataType.BLOOD_PRESSURE.name()); + assertThat(dp.effectiveDateTime()).isEqualTo(measurement); + + Map data = dp.data(); + assertThat(data).containsEntry("systolic", 118); + assertThat(data).containsEntry("diastolic", 79); + assertThat(data).containsEntry("pulse", 64); + // Enum serialized as string + assertThat(data).containsEntry("sourceType", "MANUAL"); + // Summary id included + assertThat(data).containsEntry(GARMIN_SUMMARY_ID_KEY, "sum-abc"); + } + + @Test + @DisplayName("filterDataPointByTimeRange: returns data as-is (no filtering)") + void filterDataPointByTimeRange_returnsInput() throws Exception { + // expose method via small subclass to call protected method + class TestTransformer extends BloodPressureTransformer { + public List exposeFilter(List> ranges, List bulk) { + return super.filterDataPointByTimeRange(ranges, bulk); + } + } + + TestTransformer t = new TestTransformer(); + + DataPoint dp1 = new DataPoint("id1", "1", "garmin-bp", DataType.BLOOD_PRESSURE.name(), Instant.now(), Instant.now(), Map.of()); + DataPoint dp2 = new DataPoint("id2", "2", "garmin-bp", DataType.BLOOD_PRESSURE.name(), Instant.now(), Instant.now(), Map.of()); + + List input = List.of(dp1, dp2); + List out = t.exposeFilter(List.of(Range.of(Instant.EPOCH, Instant.now())), input); + + assertThat(out).containsExactlyElementsOf(input); + } + + @Test + @DisplayName("transform: returns empty when no overlapping observation") + void transform_returnsEmpty_whenNoOverlap() { + GarminDataPoint garmin = mock(GarminDataPoint.class); + + int start = (int) Instant.parse("2024-03-01T12:00:00Z").getEpochSecond(); + int duration = 300; // 5 min + int offset = 0; + + when(garmin.getStartTimeInSeconds()).thenReturn(start); + when(garmin.getDurationInSeconds()).thenReturn(duration); + when(garmin.getStartTimeOffsetInSeconds()).thenReturn(offset); + + when(garmin.getStartTimeInSeconds()).thenReturn((int) Instant.parse("2024-03-01T12:02:00Z").getEpochSecond()); + when(garmin.getStartTimeOffsetInSeconds()).thenReturn(0); + when(garmin.getSummaryId()).thenReturn("sum-none"); + + // No overlap: observation is before Garmin time range + Event event = new Event() + .setDateStart(Instant.parse("2024-03-01T11:00:00Z")) + .setDateEnd(Instant.parse("2024-03-01T11:10:00Z")); + + Observation observation = new Observation( + 5, + null, + "BP Observation", + "garmin-bp", + null, + null, + event, + null, + null, + false, + false + ); + + List result = transformer.transform(List.of(observation), garmin, Instant.MIN, Instant.MAX); + assertThat(result).isEmpty(); + } +} diff --git a/src/test/java/io/redlink/more/data/util/MapperUtilsTest.java b/src/test/java/io/redlink/more/data/util/MapperUtilsTest.java new file mode 100644 index 0000000..e9dc249 --- /dev/null +++ b/src/test/java/io/redlink/more/data/util/MapperUtilsTest.java @@ -0,0 +1,75 @@ +package io.redlink.more.data.util; + +import io.redlink.more.data.exception.BadRequestException; +import io.redlink.more.data.model.Alias; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MapperUtilsTest { + + @Test + @DisplayName("writeValueAsString serializes map to json") + void writeValueAsString_serializes() { + String json = MapperUtils.writeValueAsString(Map.of("a", 1, "b", "x")); + assertThat(json).isIn("{\"a\":1,\"b\":\"x\"}", "{\"b\":\"x\",\"a\":1}"); + } + + @Test + @DisplayName("readValue returns null for null input") + void readValue_null_returnsNull() { + Map value = MapperUtils.readValue(null, Map.class); + assertThat(value).isNull(); + } + + @Test + @DisplayName("readValue throws BadRequestException for invalid json") + void readValue_invalid_throws() { + assertThatThrownBy(() -> MapperUtils.readValue("not-json", Map.class)) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("convertValueWithAliases copies value to replacement key if missing") + void convertValueWithAliases_copiesWhenMissing() { + Map src = Map.of("oldKey", 42); + Alias alias = new Alias("oldKey", "newKey"); + + Map out = MapperUtils.convertValueWithAliases(src, Map.class, List.of(alias)); + + assertThat(out).containsEntry("oldKey", 42) + .containsEntry("newKey", 42); + } + + @Test + @DisplayName("convertValueWithAliases does not overwrite existing replacement key") + void convertValueWithAliases_doesNotOverwrite() { + Map src = Map.of("oldKey", 42, "newKey", 99); + Alias alias = new Alias("oldKey", "newKey"); + + Map out = MapperUtils.convertValueWithAliases(src, Map.class, List.of(alias)); + + assertThat(out).containsEntry("newKey", 99) + .containsEntry("oldKey", 42); + } + + @Test + @DisplayName("isPrimitiveLike correctly identifies primitive-like values") + void isPrimitiveLike_checks() { + assertThat(MapperUtils.isPrimitiveLike("str")).isTrue(); + assertThat(MapperUtils.isPrimitiveLike(123)).isTrue(); + assertThat(MapperUtils.isPrimitiveLike(123.45)).isTrue(); + assertThat(MapperUtils.isPrimitiveLike(true)).isTrue(); + assertThat(MapperUtils.isPrimitiveLike('c')).isTrue(); + assertThat(MapperUtils.isPrimitiveLike(Thread.State.NEW)).isTrue(); + + assertThat(MapperUtils.isPrimitiveLike(Map.of("a", 1))).isFalse(); + assertThat(MapperUtils.isPrimitiveLike(List.of(1, 2, 3))).isFalse(); + assertThat(MapperUtils.isPrimitiveLike(new Object())).isFalse(); + } +}