This commit is contained in:
2026-04-20 21:28:46 +02:00
parent 63f8ca1104
commit c82cfaa9cd
21 changed files with 426 additions and 11 deletions

View File

@@ -6,7 +6,7 @@
<entry key="peakup-backend">
<value>
<ModuleState>
<option name="hash" value="746995196" />
<option name="hash" value="-1586394178" />
<option name="version" value="1" />
</ModuleState>
</value>

2
.idea/misc.xml generated
View File

@@ -8,6 +8,8 @@
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration">
<file type="Quarkus" url="file://$PROJECT_DIR$" />
<file type="Qute" url="file://$PROJECT_DIR$/peakup-backend" />
<file type="Qute" url="file://$PROJECT_DIR$/peakup-tools" />
</component>
<component name="MavenProjectsManager">
<option name="originalFiles">

View File

@@ -65,6 +65,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-scheduler</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-mailer</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>

View File

@@ -24,6 +24,9 @@ public class Athlete extends AbstractTimestampedEntity {
@Column(nullable = false)
private String login;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private Double weight;

View File

@@ -427,6 +427,8 @@ public interface IntervalsClient {
// Power field names and misc
public @Nullable Object power_field_names;
public @Nullable Object analysis_issues;
public Map<String, Object> unknownFields = new LinkedHashMap<>();
@SuppressWarnings({"NullAway.Init", "unused"})

View File

@@ -0,0 +1,26 @@
package com.peakup.goal.dao;
import com.peakup.common.dao.AbstractDAO;
import com.peakup.goal.model.GoalAchievementNotification;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class GoalAchievementNotificationDAO extends AbstractDAO<GoalAchievementNotification> {
@Override
protected Class<GoalAchievementNotification> getEntityClass() {
return GoalAchievementNotification.class;
}
public boolean existsByKey(final long athleteId, final long goalId, final int year, final int periodIndex) {
return em.createQuery(
"SELECT COUNT(n) FROM GoalAchievementNotification n " +
"WHERE n.athleteId = :athleteId AND n.goalId = :goalId " +
"AND n.year = :year AND n.periodIndex = :periodIndex", Long.class)
.setParameter("athleteId", athleteId)
.setParameter("goalId", goalId)
.setParameter("year", year)
.setParameter("periodIndex", periodIndex)
.getSingleResult() > 0;
}
}

View File

@@ -0,0 +1,34 @@
package com.peakup.goal.model;
import com.peakup.common.model.AbstractTimestampedEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Entity
@Table(name = "goal_achievement_notification", uniqueConstraints = @UniqueConstraint(
columnNames = {"athlete_id", "goal_id", "year", "period_index"}))
@Data
@EqualsAndHashCode(callSuper = true)
public class GoalAchievementNotification extends AbstractTimestampedEntity {
@Column(name = "athlete_id", nullable = false)
private Long athleteId;
@Column(name = "goal_id", nullable = false)
private Long goalId;
@Column(name = "year", nullable = false)
private int year;
@Column(name = "period_index", nullable = false)
private int periodIndex;
@SuppressWarnings("NullAway.Init")
public GoalAchievementNotification() {
// hibernate
}
}

View File

@@ -0,0 +1,118 @@
package com.peakup.goal.service;
import com.peakup.athlete.model.Athlete;
import com.peakup.athlete.service.AthleteService;
import com.peakup.goal.dao.GoalAchievementNotificationDAO;
import com.peakup.goal.model.Goal;
import com.peakup.goal.model.GoalAchievementNotification;
import com.peakup.goal.model.GoalMetricPeriodTotal;
import com.peakup.goal.model.GoalPeriodEnum;
import io.quarkus.mailer.Mail;
import io.quarkus.mailer.Mailer;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.temporal.WeekFields;
import java.util.List;
import java.util.Locale;
@lombok.extern.slf4j.Slf4j
@ApplicationScoped
public class GoalNotificationService {
@Inject
@SuppressWarnings("NullAway.Init")
GoalService goalService;
@Inject
@SuppressWarnings("NullAway.Init")
GoalMetricPeriodTotalService goalMetricPeriodTotalService;
@Inject
@SuppressWarnings("NullAway.Init")
GoalAchievementNotificationDAO goalAchievementNotificationDAO;
@Inject
@SuppressWarnings("NullAway.Init")
AthleteService athleteService;
@Inject
@SuppressWarnings("NullAway.Init")
Mailer mailer;
@Transactional
public void checkAndNotify(final long athleteId) {
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
final int currentYear = today.get(WeekFields.ISO.weekBasedYear());
final int currentCalendarWeek = today.get(WeekFields.ISO.weekOfWeekBasedYear());
final List<Goal> goals = goalService.getByAthleteId(athleteId);
for (final Goal goal : goals) {
if (goal.getPeriod() != GoalPeriodEnum.WEEKLY && goal.getPeriod() != GoalPeriodEnum.MONTHLY) {
continue;
}
checkGoal(goal, athleteId, today, currentYear, currentCalendarWeek);
}
}
private void checkGoal(
final Goal goal,
final long athleteId,
final LocalDate today,
final int currentYear,
final int currentCalendarWeek) {
final int notificationYear;
final int periodIndex;
if (goal.getPeriod() == GoalPeriodEnum.WEEKLY) {
notificationYear = currentYear;
periodIndex = currentCalendarWeek;
} else {
notificationYear = today.getYear();
periodIndex = today.getMonthValue();
}
final Long goalId = goal.getId();
if (goalId == null) {
return;
}
if (goalAchievementNotificationDAO.existsByKey(athleteId, goalId, notificationYear, periodIndex)) {
return;
}
final GoalMetricPeriodTotal total = goalMetricPeriodTotalService.getAggregate(
athleteId, goal.getMetric(), goal.getPeriod(), currentYear, currentCalendarWeek);
if (total == null || total.getAggregatedValue() < goal.getTargetValue()) {
return;
}
final Athlete athlete = athleteService.getById(athleteId);
sendGoalAchievementEmail(athlete, goal, total.getAggregatedValue());
final GoalAchievementNotification notification = new GoalAchievementNotification();
notification.setAthleteId(athleteId);
notification.setGoalId(goalId);
notification.setYear(notificationYear);
notification.setPeriodIndex(periodIndex);
goalAchievementNotificationDAO.insert(notification);
log.info("Goal achievement notification sent for athleteId={} goalId={} period={} year={} periodIndex={}",
athleteId, goalId, goal.getPeriod(), notificationYear, periodIndex);
}
private void sendGoalAchievementEmail(final Athlete athlete, final Goal goal, final double achievedValue) {
final String periodLabel = goal.getPeriod() == GoalPeriodEnum.WEEKLY ? "weekly" : "monthly";
final String subject = String.format("peakUp – %s %s goal achieved!", periodLabel, goal.getMetric().name().toLowerCase(Locale.ROOT));
final String body = String.format(
"Hi %s,%n%nCongratulations! You have achieved your %s %s goal.%n%nTarget: %.2f%nAchieved: %.2f%n%nKeep it up!%n%npeakUp",
athlete.getFirstName(), periodLabel, goal.getMetric().name().toLowerCase(Locale.ROOT),
goal.getTargetValue(), achievedValue);
mailer.send(Mail.withText(athlete.getEmail(), subject, body));
}
}

View File

@@ -8,6 +8,7 @@ import com.peakup.connector.intervals.service.IntervalsConnectorService;
import com.peakup.connector.strava.model.StravaConnector;
import com.peakup.connector.strava.service.StravaConnectorService;
import com.peakup.goal.service.GoalMetricPeriodTotalService;
import com.peakup.goal.service.GoalNotificationService;
import io.quarkus.scheduler.Scheduled;
import io.smallrye.common.annotation.RunOnVirtualThread;
import jakarta.enterprise.context.ApplicationScoped;
@@ -38,6 +39,9 @@ public class ActivityImportScheduler {
@Inject
@SuppressWarnings("NullAway.Init")
GoalMetricPeriodTotalService goalMetricPeriodTotalService;
@Inject
@SuppressWarnings("NullAway.Init")
GoalNotificationService goalNotificationService;
@Scheduled(every = "20m")
@RunOnVirtualThread
@@ -86,6 +90,9 @@ public class ActivityImportScheduler {
log.error("Failed to recalculate goal metric period totals for athleteId {}.", connector.getAthleteId(), e);
}
}
for (final IntervalsConnector connector : intervalsConnectorService.getIntervalsConnectors()) {
goalNotificationService.checkAndNotify(connector.getAthleteId());
}
} finally {
running.set(false);
}

View File

@@ -19,3 +19,12 @@ strava-api/mp-rest/url=https://www.strava.com
# intervals.icu
intervals-api/mp-rest/url=https://intervals.icu
peakup.stream-import.delay-ms=1000
# mailer
quarkus.mailer.from=peakup@peakup.app
quarkus.mailer.host=${SMTP_HOST:smtp.gmail.com}
quarkus.mailer.port=${SMTP_PORT:587}
quarkus.mailer.start-tls=REQUIRED
quarkus.mailer.username=ingo.schnabel.im@gmail.com
quarkus.mailer.password=wghcnkgcltgydurh
# To use DB-stored credentials instead of env vars:
quarkus.mailer.mock=false

View File

@@ -0,0 +1,4 @@
ALTER TABLE athlete ADD COLUMN email VARCHAR(255) NOT NULL DEFAULT '';
UPDATE athlete SET email = 'ingo.schnabel@gmail.com' WHERE id = 90;
UPDATE athlete SET email = 'mirja.schnabel@gmail.com' WHERE id = 91;
ALTER TABLE athlete ALTER COLUMN email DROP DEFAULT;

View File

@@ -0,0 +1,10 @@
CREATE TABLE goal_achievement_notification (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
modified_at TIMESTAMP NOT NULL DEFAULT NOW(),
athlete_id BIGINT NOT NULL,
goal_id BIGINT NOT NULL REFERENCES goal(id),
year INTEGER NOT NULL,
period_index INTEGER NOT NULL,
CONSTRAINT uq_goal_achievement_notification UNIQUE (athlete_id, goal_id, year, period_index)
);

View File

@@ -67,8 +67,8 @@ public abstract class AbstractPeakUpTest {
// Athlete 90 — Ingo
stmt.execute("""
INSERT INTO athlete (id, first_name, last_name, login, weight, created_at, modified_at)
VALUES (90, 'Ingo', 'Schnabel', 'ingo', 93.0, now(), now())
INSERT INTO athlete (id, first_name, last_name, login, email, weight, created_at, modified_at)
VALUES (90, 'Ingo', 'Schnabel', 'ingo', 'ingo.schnabel@gmail.com', 93.0, now(), now())
""");
stmt.execute("""
INSERT INTO strava_connector
@@ -89,8 +89,8 @@ public abstract class AbstractPeakUpTest {
// Athlete 91 — Mirja
stmt.execute("""
INSERT INTO athlete (id, first_name, last_name, login, weight, created_at, modified_at)
VALUES (91, 'Mirja', 'Schnabel', 'mirja', 50.0, now(), now())
INSERT INTO athlete (id, first_name, last_name, login, email, weight, created_at, modified_at)
VALUES (91, 'Mirja', 'Schnabel', 'mirja', 'mirja.schnabel@gmail.com', 50.0, now(), now())
""");
stmt.execute("""
INSERT INTO strava_connector

View File

@@ -23,7 +23,8 @@ public class PostgresTestProfile implements QuarkusTestProfile {
return Map.of(
"quarkus.scheduler.enabled", "false",
"quarkus.http.test-port", "0",
"peakup.stream-import.delay-ms", "0"
"peakup.stream-import.delay-ms", "0",
"quarkus.mailer.mock", "true"
);
}
}

View File

@@ -0,0 +1,14 @@
package com.peakup;
import java.util.HashMap;
import java.util.Map;
public class RealMailTestProfile extends PostgresTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
final Map<String, String> overrides = new HashMap<>(super.getConfigOverrides());
overrides.put("quarkus.mailer.mock", "false");
return overrides;
}
}

View File

@@ -210,8 +210,8 @@ public class TestDataInitializer {
// Athlete 90 — Ingo
s.execute("""
INSERT INTO athlete (id, first_name, last_name, login, weight, created_at, modified_at)
VALUES (90, 'Ingo', 'Schnabel', 'ingo', 93.0, now(), now())
INSERT INTO athlete (id, first_name, last_name, login, email, weight, created_at, modified_at)
VALUES (90, 'Ingo', 'Schnabel', 'ingo', 'ingo.schnabel@gmail.com', 93.0, now(), now())
""");
s.execute("""
INSERT INTO strava_connector
@@ -232,8 +232,8 @@ public class TestDataInitializer {
// Athlete 91 — Mirja
s.execute("""
INSERT INTO athlete (id, first_name, last_name, login, weight, created_at, modified_at)
VALUES (91, 'Mirja', 'Schnabel', 'mirja', 50.0, now(), now())
INSERT INTO athlete (id, first_name, last_name, login, email, weight, created_at, modified_at)
VALUES (91, 'Mirja', 'Schnabel', 'mirja', 'mirja.schnabel@gmail.com', 50.0, now(), now())
""");
s.execute("""
INSERT INTO strava_connector

View File

@@ -105,6 +105,7 @@ class IntervalsActivityMappingTest extends AbstractPeakUpTest {
"hrrStartTimeSec", "hrrEndTimeSec", "hrrAverageWatts"));
m.put("gear", List.of("gearId", "gearName", "gearDistanceM", "gearPrimary"));
m.put("icu_achievements", List.of()); // stored in ActivityAchievement, not on entity
m.put("analysis_issues", List.of()); // informational field, not mapped to entity
// New fields — unit-suffix renames
// icu_ignore_time → icuIgnoreTime (Boolean flag, direct camelCase match)
m.put("icu_recording_time", List.of("icuRecordingTimeS"));

View File

@@ -0,0 +1,73 @@
package com.peakup.goal;
import com.peakup.AbstractPeakUpTest;
import com.peakup.RealMailTestProfile;
import com.peakup.goal.model.GoalMetricEnum;
import com.peakup.goal.model.GoalPeriodEnum;
import com.peakup.goal.service.GoalMetricPeriodTotalService;
import com.peakup.goal.service.GoalNotificationService;
import io.agroal.api.AgroalDataSource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import java.sql.Connection;
import java.sql.Statement;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.temporal.WeekFields;
@QuarkusTest
@TestProfile(RealMailTestProfile.class)
class GoalNotificationRealMailTest extends AbstractPeakUpTest {
private static final long ATHLETE_ID = 90L;
@Inject
@SuppressWarnings("NullAway.Init")
GoalNotificationService goalNotificationService;
@Inject
@SuppressWarnings("NullAway.Init")
GoalMetricPeriodTotalService goalMetricPeriodTotalService;
@Inject
@SuppressWarnings("NullAway.Init")
AgroalDataSource dataSource;
@Test
void checkAndNotify_sendsRealEmail() throws Exception {
insertGoal(GoalPeriodEnum.WEEKLY, GoalMetricEnum.DISTANCE, 1000.0);
insertActivity(1500.0);
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
final int weekYear = today.get(WeekFields.ISO.weekBasedYear());
final int weekIndex = today.get(WeekFields.ISO.weekOfWeekBasedYear());
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, weekYear, weekIndex);
goalNotificationService.checkAndNotify(ATHLETE_ID);
}
private void insertGoal(final GoalPeriodEnum period, final GoalMetricEnum metric, final double targetValue)
throws Exception {
try (final Connection conn = dataSource.getConnection();
final Statement stmt = conn.createStatement()) {
stmt.execute("""
INSERT INTO goal (athlete_id, period, metric, target_value, created_at, modified_at)
VALUES (%d, %d, %d, %f, now(), now())
""".formatted(ATHLETE_ID, period.getKey(), metric.getKey(), targetValue));
}
}
private void insertActivity(final double distanceM) throws Exception {
try (final Connection conn = dataSource.getConnection();
final Statement stmt = conn.createStatement()) {
stmt.execute("""
INSERT INTO intervals_activity
(athlete_id, start_time, distance_m, training_load, total_elevation_gain_m, moving_time_s, created_at, modified_at)
VALUES (%d, now(), %f, 0.0, 0.0, 0, now(), now())
""".formatted(ATHLETE_ID, distanceM));
}
}
}

View File

@@ -0,0 +1,106 @@
package com.peakup.goal;
import com.peakup.AbstractPeakUpTest;
import com.peakup.PostgresTestProfile;
import com.peakup.goal.model.GoalMetricEnum;
import com.peakup.goal.model.GoalPeriodEnum;
import com.peakup.goal.service.GoalMetricPeriodTotalService;
import com.peakup.goal.service.GoalNotificationService;
import io.agroal.api.AgroalDataSource;
import io.quarkus.mailer.MockMailbox;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.sql.Connection;
import java.sql.Statement;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.temporal.WeekFields;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@QuarkusTest
@TestProfile(PostgresTestProfile.class)
class GoalNotificationServiceTest extends AbstractPeakUpTest {
private static final long ATHLETE_ID = 90L;
@Inject
@SuppressWarnings("NullAway.Init")
GoalNotificationService goalNotificationService;
@Inject
@SuppressWarnings("NullAway.Init")
GoalMetricPeriodTotalService goalMetricPeriodTotalService;
@Inject
@SuppressWarnings("NullAway.Init")
MockMailbox mockMailbox;
@Inject
@SuppressWarnings("NullAway.Init")
AgroalDataSource dataSource;
@BeforeEach
void clearMailbox() {
mockMailbox.clear();
}
@Test
void checkAndNotify_sendsEmailWhenGoalAchieved() throws Exception {
insertGoal(GoalPeriodEnum.WEEKLY, GoalMetricEnum.DISTANCE, 1000.0);
insertActivity(1500.0);
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
final int weekYear = today.get(WeekFields.ISO.weekBasedYear());
final int weekIndex = today.get(WeekFields.ISO.weekOfWeekBasedYear());
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, weekYear, weekIndex);
goalNotificationService.checkAndNotify(ATHLETE_ID);
final var messages = mockMailbox.getMessagesSentTo("ingo.schnabel@gmail.com");
assertEquals(1, messages.size());
assertTrue(messages.get(0).getSubject().contains("weekly distance"));
}
@Test
void checkAndNotify_doesNotSendEmailWhenGoalNotReached() throws Exception {
insertGoal(GoalPeriodEnum.WEEKLY, GoalMetricEnum.DISTANCE, 1000.0);
insertActivity(500.0);
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
final int weekYear = today.get(WeekFields.ISO.weekBasedYear());
final int weekIndex = today.get(WeekFields.ISO.weekOfWeekBasedYear());
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, weekYear, weekIndex);
goalNotificationService.checkAndNotify(ATHLETE_ID);
assertTrue(mockMailbox.getMessagesSentTo("ingo.schnabel@gmail.com").isEmpty());
}
private void insertGoal(final GoalPeriodEnum period, final GoalMetricEnum metric, final double targetValue)
throws Exception {
try (final Connection conn = dataSource.getConnection();
final Statement stmt = conn.createStatement()) {
stmt.execute("""
INSERT INTO goal (athlete_id, period, metric, target_value, created_at, modified_at)
VALUES (%d, %d, %d, %f, now(), now())
""".formatted(ATHLETE_ID, period.getKey(), metric.getKey(), targetValue));
}
}
private void insertActivity(final double distanceM) throws Exception {
try (final Connection conn = dataSource.getConnection();
final Statement stmt = conn.createStatement()) {
stmt.execute("""
INSERT INTO intervals_activity
(athlete_id, start_time, distance_m, training_load, total_elevation_gain_m, moving_time_s, created_at, modified_at)
VALUES (%d, now(), %f, 0.0, 0.0, 0, now(), now())
""".formatted(ATHLETE_ID, distanceM));
}
}
}

View File

@@ -7,6 +7,7 @@ export interface AthleteDo extends AbstractTimestampedEntityDo {
firstName: string;
lastName: string;
login: string;
email: string;
weight: number;
sex: SexEnum | null;
}

View File

@@ -17,4 +17,4 @@ VALUES (nextval('global_id_seq'), 90, '36523216', '12c757d282015eb26c21117c3a9a5
-- Intervals Connectors
INSERT INTO intervals_connector (id, athlete_id, intervals_athlete_id, api_key, created_at, modified_at)
VALUES (nextval('global_id_seq'), 90, 'i157824', '38he2acw5nakpy749jabprdtz', now(), now()),
(nextval('global_id_seq'), 91, 'i310361', '7er8wec6ziuaitp6nrw6wteqf', now(), now());
(nextval('global_id_seq'), 91, 'i310361', '7er8wec6ziuaitp6nrw6wteqf', now(), now());