From bcc026a7688ace52f389d0f90c01ee3057f8562b Mon Sep 17 00:00:00 2001 From: Ingo Schnabel Date: Fri, 15 May 2026 14:32:46 +0200 Subject: [PATCH] Scheduled ta --- CLAUDE.md | 36 ++-- .../java/com/peakup/goal/GoalResource.java | 10 - .../AbstractActivityImporter.java | 2 +- .../IntervalsActivityImporter.java | 2 +- .../StravaActivityImporter.java | 2 +- .../com/peakup/importer/package-info.java | 2 + .../scheduledtask/ScheduledTaskResource.java | 82 +++++++++ .../scheduledtask/dao/ScheduledTaskDAO.java | 38 ++++ .../scheduledtask/dao/package-info.java | 2 + .../scheduledtask/model/PeriodicityEnum.java | 39 ++++ .../scheduledtask/model/ScheduledTask.java | 68 +++++++ .../scheduledtask/model/TaskTypeEnum.java | 33 ++++ .../scheduledtask/model/package-info.java | 2 + .../peakup/scheduledtask/package-info.java | 2 + .../service/ScheduledTaskService.java | 48 +++++ .../scheduledtask/service/package-info.java | 2 + ...ityImportScheduler.java => Scheduler.java} | 16 +- .../V46__create_scheduled_task_table.sql | 16 ++ ...cheduled_task_unique_athlete_task_type.sql | 2 + .../activity/importer/ActivityImportTest.java | 4 +- peakup-frontend/src/api/GoalClient.ts | 2 - .../src/api/ScheduledTaskClient.ts | 17 ++ peakup-frontend/src/api/hooks.ts | 58 +++++- peakup-frontend/src/pages/Settings.tsx | 12 +- peakup-frontend/src/types/PeriodicityEnum.ts | 9 + peakup-frontend/src/types/ScheduledTaskDo.ts | 14 ++ peakup-frontend/src/types/TaskTypeEnum.ts | 6 + .../generator/FlywayMigrationGenerator.java | 173 ++++++++++++++++++ .../tools/generator/ModelGeneratorTool.java | 69 +++++-- .../resources/definitions/scheduled-task.json | 37 ++++ 30 files changed, 750 insertions(+), 55 deletions(-) rename peakup-backend/src/main/java/com/peakup/{scheduler => importer}/AbstractActivityImporter.java (96%) rename peakup-backend/src/main/java/com/peakup/{scheduler => importer}/IntervalsActivityImporter.java (99%) rename peakup-backend/src/main/java/com/peakup/{scheduler => importer}/StravaActivityImporter.java (99%) create mode 100644 peakup-backend/src/main/java/com/peakup/importer/package-info.java create mode 100644 peakup-backend/src/main/java/com/peakup/scheduledtask/ScheduledTaskResource.java create mode 100644 peakup-backend/src/main/java/com/peakup/scheduledtask/dao/ScheduledTaskDAO.java create mode 100644 peakup-backend/src/main/java/com/peakup/scheduledtask/dao/package-info.java create mode 100644 peakup-backend/src/main/java/com/peakup/scheduledtask/model/PeriodicityEnum.java create mode 100644 peakup-backend/src/main/java/com/peakup/scheduledtask/model/ScheduledTask.java create mode 100644 peakup-backend/src/main/java/com/peakup/scheduledtask/model/TaskTypeEnum.java create mode 100644 peakup-backend/src/main/java/com/peakup/scheduledtask/model/package-info.java create mode 100644 peakup-backend/src/main/java/com/peakup/scheduledtask/package-info.java create mode 100644 peakup-backend/src/main/java/com/peakup/scheduledtask/service/ScheduledTaskService.java create mode 100644 peakup-backend/src/main/java/com/peakup/scheduledtask/service/package-info.java rename peakup-backend/src/main/java/com/peakup/scheduler/{ActivityImportScheduler.java => Scheduler.java} (85%) create mode 100644 peakup-backend/src/main/resources/db/migration/V46__create_scheduled_task_table.sql create mode 100644 peakup-backend/src/main/resources/db/migration/V47__scheduled_task_unique_athlete_task_type.sql create mode 100644 peakup-frontend/src/api/ScheduledTaskClient.ts create mode 100644 peakup-frontend/src/types/PeriodicityEnum.ts create mode 100644 peakup-frontend/src/types/ScheduledTaskDo.ts create mode 100644 peakup-frontend/src/types/TaskTypeEnum.ts create mode 100644 peakup-tools/src/main/java/com/peakup/tools/generator/FlywayMigrationGenerator.java create mode 100644 peakup-tools/src/main/resources/definitions/scheduled-task.json diff --git a/CLAUDE.md b/CLAUDE.md index 9665152..f075e4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,18 +24,26 @@ - **Tools**: `peakup-maven` – Maven plugin with two mojos: `PackageInfoGeneratorMojo` (generates `package-info.java` with `@NonNull` per package) and `TypeScriptClientGeneratorMojo` (generates TS client from REST endpoints) - **Tools**: `peakup-tools` – standalone dev utilities: - - `ModelGeneratorTool` – generates a full domain slice (entity, DAO, service, resource) from a JSON definition file. - Schema: `peakup-tools/src/main/resources/model-definition-schema.json` - Example: `peakup-tools/src/main/resources/example-model-definition.json` - Run (from project root): - ``` - $MVN -f peakup-tools/pom.xml -Dmaven.repo.local=/home/ingo/.m2/peak-repo \ - compile exec:java -Dexec.mainClass=com.peakup.tools.generator.ModelGeneratorTool \ - "-Dexec.args=path/to/definition.json" - ``` - Output defaults to `peakup-backend/src/main/java/`. Pass a second arg to override. - Generated service only contains `getDAO()` + domain-specific methods — CRUD is inherited from - `AbstractService` as `public final @Transactional`. + - `ModelGeneratorTool` – generates a full domain slice (entity, DAO, service, resource) from a JSON definition file. + Schema: `peakup-tools/src/main/resources/model-definition-schema.json` + Example: `peakup-tools/src/main/resources/example/example-model-definition.json` + Models are stored: `src/main/resources/definitions` + Run (from project root): + ``` + # Normal run — fails if any output file already exists, generates Flyway migration + $MVN -f peakup-tools/pom.xml -Dmaven.repo.local=/home/ingo/.m2/peak-repo \ + compile exec:java -Dexec.mainClass=com.peakup.tools.generator.ModelGeneratorTool \ + "-Dexec.args=path/to/definition.json" + + # --force — overwrites existing files, skips Flyway migration generation + $MVN -f peakup-tools/pom.xml -Dmaven.repo.local=/home/ingo/.m2/peak-repo \ + compile exec:java -Dexec.mainClass=com.peakup.tools.generator.ModelGeneratorTool \ + "-Dexec.args=--force path/to/definition.json" + ``` + Output defaults to `peakup-backend/src/main/java/`. Pass a second arg to override output dir, + third arg to override Flyway migration dir (default: `peakup-backend/src/main/resources/db/migration/`). + Generated service only contains `getDAO()` + domain-specific methods — CRUD is inherited from + `AbstractService` as `public final @Transactional`. **Backend Architecture**: @@ -52,7 +60,9 @@ - Lombok: `@Data`, `@NoArgsConstructor`, `@AllArgsConstructor`, `@Slf4j` - Checkstyle: 4 spaces, 240 char max line, K&R braces, no star imports - Methods: preferred < 60 lines (blank lines excluded), max 7 parameters -- **Type safety is mandatory**: use native types throughout — `Instant`/`LocalDate` for dates, `Long`/`Integer` for IDs and counts, enums for fixed value sets. Never pass dates, IDs, or enums as `String` across layer boundaries. REST DTOs are the only exception — parse to native types immediately at the boundary. +- **Type safety is mandatory**: use native types throughout — `Instant`/`LocalDate` for dates, `Long`/`Integer` for IDs + and counts, enums for fixed value sets. Never pass dates, IDs, or enums as `String` across layer boundaries. REST DTOs + are the only exception — parse to native types immediately at the boundary. - Use `AbstractEntity` and `AbstractTimestampedEntity` - TimescaleDB hypertables via `AbstractTimescaleEntity` diff --git a/peakup-backend/src/main/java/com/peakup/goal/GoalResource.java b/peakup-backend/src/main/java/com/peakup/goal/GoalResource.java index ca56d56..b82d407 100644 --- a/peakup-backend/src/main/java/com/peakup/goal/GoalResource.java +++ b/peakup-backend/src/main/java/com/peakup/goal/GoalResource.java @@ -73,16 +73,6 @@ public class GoalResource extends AbstractResource { } } - @POST - @Path("/metric-totals/recalculate") - public RestResponse recalculateMetricTotals(@QueryParam("athleteId") @Nullable final Long athleteId) { - if (athleteId == null) { - throw new BadRequestException("athleteId query parameter is required"); - } - goalMetricPeriodTotalService.deleteAndRecalculateByAthleteId(athleteId); - return RestResponse.noContent(); - } - @DELETE @Path("/{id}") public RestResponse delete(@PathParam("id") final long id) { diff --git a/peakup-backend/src/main/java/com/peakup/scheduler/AbstractActivityImporter.java b/peakup-backend/src/main/java/com/peakup/importer/AbstractActivityImporter.java similarity index 96% rename from peakup-backend/src/main/java/com/peakup/scheduler/AbstractActivityImporter.java rename to peakup-backend/src/main/java/com/peakup/importer/AbstractActivityImporter.java index 14669af..f43bc82 100644 --- a/peakup-backend/src/main/java/com/peakup/scheduler/AbstractActivityImporter.java +++ b/peakup-backend/src/main/java/com/peakup/importer/AbstractActivityImporter.java @@ -1,4 +1,4 @@ -package com.peakup.scheduler; +package com.peakup.importer; import com.peakup.activity.model.AbstractActivity; import jakarta.annotation.Nullable; diff --git a/peakup-backend/src/main/java/com/peakup/scheduler/IntervalsActivityImporter.java b/peakup-backend/src/main/java/com/peakup/importer/IntervalsActivityImporter.java similarity index 99% rename from peakup-backend/src/main/java/com/peakup/scheduler/IntervalsActivityImporter.java rename to peakup-backend/src/main/java/com/peakup/importer/IntervalsActivityImporter.java index dd2b069..ec8d2fc 100644 --- a/peakup-backend/src/main/java/com/peakup/scheduler/IntervalsActivityImporter.java +++ b/peakup-backend/src/main/java/com/peakup/importer/IntervalsActivityImporter.java @@ -1,4 +1,4 @@ -package com.peakup.scheduler; +package com.peakup.importer; import com.peakup.activity.model.IntervalsActivity; import com.peakup.activity.model.StreamStatusEnum; diff --git a/peakup-backend/src/main/java/com/peakup/scheduler/StravaActivityImporter.java b/peakup-backend/src/main/java/com/peakup/importer/StravaActivityImporter.java similarity index 99% rename from peakup-backend/src/main/java/com/peakup/scheduler/StravaActivityImporter.java rename to peakup-backend/src/main/java/com/peakup/importer/StravaActivityImporter.java index 5e5ca8a..770bace 100644 --- a/peakup-backend/src/main/java/com/peakup/scheduler/StravaActivityImporter.java +++ b/peakup-backend/src/main/java/com/peakup/importer/StravaActivityImporter.java @@ -1,4 +1,4 @@ -package com.peakup.scheduler; +package com.peakup.importer; import com.peakup.activity.model.StravaActivity; import com.peakup.activity.service.StravaActivityService; diff --git a/peakup-backend/src/main/java/com/peakup/importer/package-info.java b/peakup-backend/src/main/java/com/peakup/importer/package-info.java new file mode 100644 index 0000000..ddf8a69 --- /dev/null +++ b/peakup-backend/src/main/java/com/peakup/importer/package-info.java @@ -0,0 +1,2 @@ +@com.peakup.common.annotation.NonNull +package com.peakup.importer; diff --git a/peakup-backend/src/main/java/com/peakup/scheduledtask/ScheduledTaskResource.java b/peakup-backend/src/main/java/com/peakup/scheduledtask/ScheduledTaskResource.java new file mode 100644 index 0000000..dae7ea7 --- /dev/null +++ b/peakup-backend/src/main/java/com/peakup/scheduledtask/ScheduledTaskResource.java @@ -0,0 +1,82 @@ +package com.peakup.scheduledtask; + +import com.peakup.common.AbstractResource; +import com.peakup.scheduledtask.model.ScheduledTask; +import com.peakup.scheduledtask.service.ScheduledTaskService; +import jakarta.annotation.Nullable; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.persistence.NoResultException; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import org.jboss.resteasy.reactive.RestResponse; + +@Path("/api/scheduled-tasks") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class ScheduledTaskResource extends AbstractResource { + @Inject + @SuppressWarnings("NullAway.Init") + ScheduledTaskService scheduledTaskService; + + @GET + public RestResponse> getByAthlete( + @QueryParam("athleteId") @Nullable final Long athleteId) { + if (athleteId == null) { + throw new BadRequestException("athleteId query parameter is required"); + } + return RestResponse.ok(scheduledTaskService.getByAthleteId(athleteId)); + } + + @GET + @Path("/{id}") + public RestResponse getById(@PathParam("id") final long id) { + try { + return RestResponse.ok(scheduledTaskService.getById(id)); + } catch (final NoResultException e) { + return RestResponse.notFound(); + } + } + + @POST + public RestResponse create(final ScheduledTask scheduledTask) { + return RestResponse.ok(scheduledTaskService.upsert(scheduledTask)); + } + + @PUT + @Path("/{id}") + public RestResponse update(@PathParam("id") final long id, + final ScheduledTask scheduledTask) { + try { + final ScheduledTask existing = scheduledTaskService.getById(id); + existing.mergeFrom(scheduledTask); + scheduledTaskService.update(existing); + return RestResponse.ok(existing); + } catch (final NoResultException e) { + return RestResponse.notFound(); + } + } + + @DELETE + @Path("/{id}") + public RestResponse delete(@PathParam("id") final long id) { + try { + final ScheduledTask existing = scheduledTaskService.getById(id); + scheduledTaskService.delete(existing); + return RestResponse.noContent(); + } catch (final NoResultException e) { + return RestResponse.notFound(); + } + } +} diff --git a/peakup-backend/src/main/java/com/peakup/scheduledtask/dao/ScheduledTaskDAO.java b/peakup-backend/src/main/java/com/peakup/scheduledtask/dao/ScheduledTaskDAO.java new file mode 100644 index 0000000..3773799 --- /dev/null +++ b/peakup-backend/src/main/java/com/peakup/scheduledtask/dao/ScheduledTaskDAO.java @@ -0,0 +1,38 @@ +package com.peakup.scheduledtask.dao; + +import com.peakup.common.dao.AbstractDAO; +import com.peakup.scheduledtask.model.ScheduledTask; +import com.peakup.scheduledtask.model.TaskTypeEnum; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; +import java.util.List; +import java.util.Optional; + +@ApplicationScoped +public class ScheduledTaskDAO extends AbstractDAO { + @Override + protected Class getEntityClass() { + return ScheduledTask.class; + } + + public List getByAthleteId(final long athleteId) { + final CriteriaBuilder cb = em.getCriteriaBuilder(); + final CriteriaQuery cq = cb.createQuery(ScheduledTask.class); + final Root root = cq.from(ScheduledTask.class); + cq.select(root).where(cb.equal(root.get("athleteId"), athleteId)); + return em.createQuery(cq).getResultList(); + } + + public Optional findByAthleteIdAndTaskType(final long athleteId, final TaskTypeEnum taskType) { + final CriteriaBuilder cb = em.getCriteriaBuilder(); + final CriteriaQuery cq = cb.createQuery(ScheduledTask.class); + final Root root = cq.from(ScheduledTask.class); + cq.select(root).where( + cb.equal(root.get("athleteId"), athleteId), + cb.equal(root.get("taskType"), taskType) + ); + return em.createQuery(cq).getResultStream().findFirst(); + } +} diff --git a/peakup-backend/src/main/java/com/peakup/scheduledtask/dao/package-info.java b/peakup-backend/src/main/java/com/peakup/scheduledtask/dao/package-info.java new file mode 100644 index 0000000..c825c09 --- /dev/null +++ b/peakup-backend/src/main/java/com/peakup/scheduledtask/dao/package-info.java @@ -0,0 +1,2 @@ +@com.peakup.common.annotation.NonNull +package com.peakup.scheduledtask.dao; diff --git a/peakup-backend/src/main/java/com/peakup/scheduledtask/model/PeriodicityEnum.java b/peakup-backend/src/main/java/com/peakup/scheduledtask/model/PeriodicityEnum.java new file mode 100644 index 0000000..5245c51 --- /dev/null +++ b/peakup-backend/src/main/java/com/peakup/scheduledtask/model/PeriodicityEnum.java @@ -0,0 +1,39 @@ +package com.peakup.scheduledtask.model; + +import com.peakup.common.model.AbstractEnumConverter; +import com.peakup.common.model.PersistableEnum; + +public enum PeriodicityEnum implements PersistableEnum { + ONCE(1), + + HOURLY(2), + + DAILY(3), + + WEEKLY(4), + + MONTHLY(5), + + YEARLY(6); + + private final int key; + + PeriodicityEnum(final int key) { + this.key = key; + } + + @Override + public Integer getKey() { + return key; + } + + @jakarta.persistence.Converter( + autoApply = true + ) + public static class Converter extends AbstractEnumConverter { + @Override + public PeriodicityEnum getEnumByKey(final Integer key) { + return PersistableEnum.getEnumByKey(key, PeriodicityEnum.values()); + } + } +} diff --git a/peakup-backend/src/main/java/com/peakup/scheduledtask/model/ScheduledTask.java b/peakup-backend/src/main/java/com/peakup/scheduledtask/model/ScheduledTask.java new file mode 100644 index 0000000..db147ea --- /dev/null +++ b/peakup-backend/src/main/java/com/peakup/scheduledtask/model/ScheduledTask.java @@ -0,0 +1,68 @@ +package com.peakup.scheduledtask.model; + +import com.peakup.common.model.AbstractTimestampedEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.time.Instant; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Entity +@Table( + name = "scheduled_task" +) +@Data +@EqualsAndHashCode( + callSuper = true +) +public class ScheduledTask extends AbstractTimestampedEntity { + @Column( + name = "athlete_id" + ) + public long athleteId; + + @Column( + name = "task_type", + nullable = false + ) + public TaskTypeEnum taskType; + + @Column( + name = "periodicity", + nullable = false + ) + public PeriodicityEnum periodicity; + + @Column( + name = "last_execution_at" + ) + @Nullable + public Instant lastExecutionAt; + + @Column( + name = "next_execution_at", + nullable = false + ) + public Instant nextExecutionAt; + + @Column( + name = "enabled" + ) + public boolean enabled; + + @SuppressWarnings("NullAway.Init") + public ScheduledTask() { + // hibernate + } + + public void mergeFrom(final ScheduledTask source) { + this.athleteId = source.athleteId; + this.taskType = source.taskType; + this.periodicity = source.periodicity; + this.lastExecutionAt = source.lastExecutionAt; + this.nextExecutionAt = source.nextExecutionAt; + this.enabled = source.enabled; + } +} diff --git a/peakup-backend/src/main/java/com/peakup/scheduledtask/model/TaskTypeEnum.java b/peakup-backend/src/main/java/com/peakup/scheduledtask/model/TaskTypeEnum.java new file mode 100644 index 0000000..ba2f34e --- /dev/null +++ b/peakup-backend/src/main/java/com/peakup/scheduledtask/model/TaskTypeEnum.java @@ -0,0 +1,33 @@ +package com.peakup.scheduledtask.model; + +import com.peakup.common.model.AbstractEnumConverter; +import com.peakup.common.model.PersistableEnum; + +public enum TaskTypeEnum implements PersistableEnum { + RECALCULATE_METRIC(1), + + SEND_MAILS(2), + + EXPORT_ACTIVITIES(3); + + private final int key; + + TaskTypeEnum(final int key) { + this.key = key; + } + + @Override + public Integer getKey() { + return key; + } + + @jakarta.persistence.Converter( + autoApply = true + ) + public static class Converter extends AbstractEnumConverter { + @Override + public TaskTypeEnum getEnumByKey(final Integer key) { + return PersistableEnum.getEnumByKey(key, TaskTypeEnum.values()); + } + } +} diff --git a/peakup-backend/src/main/java/com/peakup/scheduledtask/model/package-info.java b/peakup-backend/src/main/java/com/peakup/scheduledtask/model/package-info.java new file mode 100644 index 0000000..f78cd61 --- /dev/null +++ b/peakup-backend/src/main/java/com/peakup/scheduledtask/model/package-info.java @@ -0,0 +1,2 @@ +@com.peakup.common.annotation.NonNull +package com.peakup.scheduledtask.model; diff --git a/peakup-backend/src/main/java/com/peakup/scheduledtask/package-info.java b/peakup-backend/src/main/java/com/peakup/scheduledtask/package-info.java new file mode 100644 index 0000000..8203c4a --- /dev/null +++ b/peakup-backend/src/main/java/com/peakup/scheduledtask/package-info.java @@ -0,0 +1,2 @@ +@com.peakup.common.annotation.NonNull +package com.peakup.scheduledtask; diff --git a/peakup-backend/src/main/java/com/peakup/scheduledtask/service/ScheduledTaskService.java b/peakup-backend/src/main/java/com/peakup/scheduledtask/service/ScheduledTaskService.java new file mode 100644 index 0000000..93145c5 --- /dev/null +++ b/peakup-backend/src/main/java/com/peakup/scheduledtask/service/ScheduledTaskService.java @@ -0,0 +1,48 @@ +package com.peakup.scheduledtask.service; + +import com.peakup.common.service.AbstractService; +import com.peakup.scheduledtask.dao.ScheduledTaskDAO; +import com.peakup.scheduledtask.model.ScheduledTask; +import com.peakup.scheduledtask.model.TaskTypeEnum; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; + +import java.util.List; +import java.util.Optional; + +@ApplicationScoped +public class ScheduledTaskService extends AbstractService { + @Inject + @SuppressWarnings("NullAway.Init") + ScheduledTaskDAO scheduledTaskDAO; + + @Override + protected ScheduledTaskDAO getDAO() { + return scheduledTaskDAO; + } + + @Transactional + public List getByAthleteId(final long athleteId) { + return scheduledTaskDAO.getByAthleteId(athleteId); + } + + @Transactional + public ScheduledTask upsert(final ScheduledTask scheduledTask) { + final Optional existing = + scheduledTaskDAO.findByAthleteIdAndTaskType(scheduledTask.athleteId, scheduledTask.taskType); + if (existing.isPresent()) { + existing.get().mergeFrom(scheduledTask); + scheduledTaskDAO.update(existing.get()); + return existing.get(); + } + scheduledTaskDAO.insert(scheduledTask); + return scheduledTask; + } + + @Transactional + public Optional findByAthleteIdAndTaskType(final long athleteId, final TaskTypeEnum taskType) { + final Optional result = scheduledTaskDAO.findByAthleteIdAndTaskType(athleteId, taskType); + return result; + } +} diff --git a/peakup-backend/src/main/java/com/peakup/scheduledtask/service/package-info.java b/peakup-backend/src/main/java/com/peakup/scheduledtask/service/package-info.java new file mode 100644 index 0000000..57b9bec --- /dev/null +++ b/peakup-backend/src/main/java/com/peakup/scheduledtask/service/package-info.java @@ -0,0 +1,2 @@ +@com.peakup.common.annotation.NonNull +package com.peakup.scheduledtask.service; diff --git a/peakup-backend/src/main/java/com/peakup/scheduler/ActivityImportScheduler.java b/peakup-backend/src/main/java/com/peakup/scheduler/Scheduler.java similarity index 85% rename from peakup-backend/src/main/java/com/peakup/scheduler/ActivityImportScheduler.java rename to peakup-backend/src/main/java/com/peakup/scheduler/Scheduler.java index 3d195ed..80ff410 100644 --- a/peakup-backend/src/main/java/com/peakup/scheduler/ActivityImportScheduler.java +++ b/peakup-backend/src/main/java/com/peakup/scheduler/Scheduler.java @@ -7,8 +7,14 @@ 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 com.peakup.importer.IntervalsActivityImporter; +import com.peakup.importer.StravaActivityImporter; +import com.peakup.scheduledtask.model.ScheduledTask; +import com.peakup.scheduledtask.model.TaskTypeEnum; +import com.peakup.scheduledtask.service.ScheduledTaskService; import io.quarkus.scheduler.Scheduled; import io.smallrye.common.annotation.RunOnVirtualThread; +import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -16,7 +22,7 @@ import java.util.concurrent.atomic.AtomicBoolean; @lombok.extern.slf4j.Slf4j @ApplicationScoped -public class ActivityImportScheduler { +public class Scheduler { private final AtomicBoolean running = new AtomicBoolean(false); @Inject @@ -40,6 +46,8 @@ public class ActivityImportScheduler { @Inject @SuppressWarnings("NullAway.Init") GoalNotificationService goalNotificationService; + @Inject + ScheduledTaskService scheduledTaskService; @Scheduled(every = "20m") @RunOnVirtualThread @@ -82,10 +90,16 @@ public class ActivityImportScheduler { } for (final IntervalsConnector connector : intervalsConnectorService.getIntervalsConnectors()) { + @Nullable final ScheduledTask task = scheduledTaskService.findByAthleteIdAndTaskType(connector.getAthleteId(), TaskTypeEnum.RECALCULATE_METRIC).orElse(null); + if (task == null) { + continue; + } try { goalMetricPeriodTotalService.recalculateUpToCurrentWeek(connector.getAthleteId()); } catch (final Exception e) { log.error("Failed to recalculate goal metric period totals for athleteId {}.", connector.getAthleteId(), e); + } finally { + scheduledTaskService.delete(task); } } for (final IntervalsConnector connector : intervalsConnectorService.getIntervalsConnectors()) { diff --git a/peakup-backend/src/main/resources/db/migration/V46__create_scheduled_task_table.sql b/peakup-backend/src/main/resources/db/migration/V46__create_scheduled_task_table.sql new file mode 100644 index 0000000..34fb707 --- /dev/null +++ b/peakup-backend/src/main/resources/db/migration/V46__create_scheduled_task_table.sql @@ -0,0 +1,16 @@ +-- V46: Scheduled tasks per athlete (periodic jobs like metric recalculation, mail sending, activity export) + +CREATE TABLE scheduled_task ( + id BIGINT PRIMARY KEY DEFAULT nextval('global_id_seq'), + athlete_id BIGINT NOT NULL REFERENCES athlete(id) ON DELETE CASCADE, + task_type INTEGER NOT NULL, + periodicity INTEGER NOT NULL, + last_execution_at TIMESTAMP WITH TIME ZONE, + next_execution_at TIMESTAMP WITH TIME ZONE NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + modified_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +CREATE INDEX idx_scheduled_task_athlete ON scheduled_task(athlete_id); +CREATE INDEX idx_scheduled_task_next_execution ON scheduled_task(next_execution_at) WHERE enabled = true; diff --git a/peakup-backend/src/main/resources/db/migration/V47__scheduled_task_unique_athlete_task_type.sql b/peakup-backend/src/main/resources/db/migration/V47__scheduled_task_unique_athlete_task_type.sql new file mode 100644 index 0000000..3dc0b63 --- /dev/null +++ b/peakup-backend/src/main/resources/db/migration/V47__scheduled_task_unique_athlete_task_type.sql @@ -0,0 +1,2 @@ +ALTER TABLE scheduled_task + ADD CONSTRAINT uq_scheduled_task_athlete_task_type UNIQUE (athlete_id, task_type); \ No newline at end of file diff --git a/peakup-backend/src/test/java/com/peakup/activity/importer/ActivityImportTest.java b/peakup-backend/src/test/java/com/peakup/activity/importer/ActivityImportTest.java index 26ba0b3..292835f 100644 --- a/peakup-backend/src/test/java/com/peakup/activity/importer/ActivityImportTest.java +++ b/peakup-backend/src/test/java/com/peakup/activity/importer/ActivityImportTest.java @@ -10,8 +10,8 @@ import com.peakup.connector.intervals.client.IntervalsContext; import com.peakup.connector.intervals.model.IntervalsConnector; import com.peakup.connector.intervals.service.IntervalsConnectorService; import com.peakup.connector.strava.service.StravaConnectorService; -import com.peakup.scheduler.IntervalsActivityImporter; -import com.peakup.scheduler.StravaActivityImporter; +import com.peakup.importer.IntervalsActivityImporter; +import com.peakup.importer.StravaActivityImporter; import io.agroal.api.AgroalDataSource; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; diff --git a/peakup-frontend/src/api/GoalClient.ts b/peakup-frontend/src/api/GoalClient.ts index 2495a3d..9975ef5 100644 --- a/peakup-frontend/src/api/GoalClient.ts +++ b/peakup-frontend/src/api/GoalClient.ts @@ -10,8 +10,6 @@ export const goals = { del(`/api/goals/${id}`), getByAthlete: (athleteId?: number): Promise => get(`/api/goals${buildQuery({ athleteId })}`), - recalculateMetricTotals: (athleteId?: number): Promise => - post(`/api/goals/metric-totals/recalculate${buildQuery({ athleteId })}`), update: (id: number, body: GoalDo): Promise => put(`/api/goals/${id}`, body), }; diff --git a/peakup-frontend/src/api/ScheduledTaskClient.ts b/peakup-frontend/src/api/ScheduledTaskClient.ts new file mode 100644 index 0000000..eaf4268 --- /dev/null +++ b/peakup-frontend/src/api/ScheduledTaskClient.ts @@ -0,0 +1,17 @@ +// GENERATED — do not edit manually. + +import { buildQuery, post, del, get, put } from './httpClient'; +import type { ScheduledTaskDo } from '../types/ScheduledTaskDo'; + +export const scheduledTasks = { + create: (body: ScheduledTaskDo): Promise => + post('/api/scheduled-tasks', body), + delete: (id: number): Promise => + del(`/api/scheduled-tasks/${id}`), + getByAthlete: (athleteId?: number): Promise => + get(`/api/scheduled-tasks${buildQuery({ athleteId })}`), + getById: (id: number): Promise => + get(`/api/scheduled-tasks/${id}`), + update: (id: number, body: ScheduledTaskDo): Promise => + put(`/api/scheduled-tasks/${id}`, body), +}; diff --git a/peakup-frontend/src/api/hooks.ts b/peakup-frontend/src/api/hooks.ts index ccf769d..d4b8c08 100644 --- a/peakup-frontend/src/api/hooks.ts +++ b/peakup-frontend/src/api/hooks.ts @@ -5,9 +5,11 @@ import { activities } from './IntervalsActivityClient'; import { athletes } from './AthleteClient'; import { dashboard } from './DashboardClient'; import { goals } from './GoalClient'; +import { scheduledTasks } from './ScheduledTaskClient'; import { weekPlans } from './WeekPlanClient'; import type { IntervalsActivityDo } from '../types/IntervalsActivityDo'; import type { GoalDo } from '../types/GoalDo'; +import type { ScheduledTaskDo } from '../types/ScheduledTaskDo'; import type { WeekPlanDo } from '../types/WeekPlanDo'; import type { WorkoutDo } from '../types/WorkoutDo'; @@ -27,6 +29,10 @@ export const queryKeys = { goals: { getByAthlete: (athleteId?: number) => ['goals', 'getByAthlete', athleteId] as const, }, + scheduledTasks: { + getByAthlete: (athleteId?: number) => ['scheduledTasks', 'getByAthlete', athleteId] as const, + getById: (id: number) => ['scheduledTasks', 'getById', id] as const, + }, weekPlans: { exportPlan: (id: number) => ['weekPlans', 'exportPlan', id] as const, getByAthlete: (athleteId?: number) => ['weekPlans', 'getByAthlete', athleteId] as const, @@ -129,15 +135,6 @@ export function useGoalsGetByAthlete(athleteId?: number) { }); } -export function useGoalsRecalculateMetricTotals() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (athleteId) => - goals.recalculateMetricTotals(athleteId), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['goals'] }), - }); -} - export function useGoalsUpdate() { const queryClient = useQueryClient(); return useMutation({ @@ -147,6 +144,49 @@ export function useGoalsUpdate() { }); } +// ── scheduledTasks ──────────────────────────────────── + +export function useScheduledTasksCreate() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body) => + scheduledTasks.create(body), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scheduledTasks'] }), + }); +} + +export function useScheduledTasksDelete() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id) => + scheduledTasks.delete(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scheduledTasks'] }), + }); +} + +export function useScheduledTasksGetByAthlete(athleteId?: number) { + return useQuery({ + queryKey: queryKeys.scheduledTasks.getByAthlete(athleteId), + queryFn: () => scheduledTasks.getByAthlete(athleteId), + }); +} + +export function useScheduledTasksGetById(id: number) { + return useQuery({ + queryKey: queryKeys.scheduledTasks.getById(id), + queryFn: () => scheduledTasks.getById(id), + }); +} + +export function useScheduledTasksUpdate() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }) => + scheduledTasks.update(id, body), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scheduledTasks'] }), + }); +} + // ── weekPlans ──────────────────────────────────── export function useWeekPlansCreate() { diff --git a/peakup-frontend/src/pages/Settings.tsx b/peakup-frontend/src/pages/Settings.tsx index 731f5a0..d8867b1 100644 --- a/peakup-frontend/src/pages/Settings.tsx +++ b/peakup-frontend/src/pages/Settings.tsx @@ -1,6 +1,8 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { useState, useEffect } from 'react' import { goals } from '@/api/GoalClient' +import { scheduledTasks } from '@/api/ScheduledTaskClient' +import type { ScheduledTaskDo } from '@/types/ScheduledTaskDo' import { queryKeys } from '@/api/hooks' import type { GoalDo } from '@/types/GoalDo' import type { GoalPeriodEnum } from '@/types/GoalPeriodEnum' @@ -170,8 +172,14 @@ export function Settings({ athleteId }: { athleteId: number }) { setRecalculated(false) setRecalcError(null) try { - await goals.recalculateMetricTotals(athleteId) - await qc.invalidateQueries({ queryKey: queryKeys.goals.getByAthlete(athleteId) }) + await scheduledTasks.create({ + athleteId, + taskType: 'RECALCULATE_METRIC', + periodicity: 'ONCE', + nextExecutionAt: new Date().toISOString(), + lastExecutionAt: null, + enabled: true, + } as unknown as ScheduledTaskDo) setRecalculated(true) } catch { setRecalcError('Recalculation failed — please retry') diff --git a/peakup-frontend/src/types/PeriodicityEnum.ts b/peakup-frontend/src/types/PeriodicityEnum.ts new file mode 100644 index 0000000..203673e --- /dev/null +++ b/peakup-frontend/src/types/PeriodicityEnum.ts @@ -0,0 +1,9 @@ +// GENERATED — do not edit manually. + +export type PeriodicityEnum = + | 'ONCE' + | 'HOURLY' + | 'DAILY' + | 'WEEKLY' + | 'MONTHLY' + | 'YEARLY'; diff --git a/peakup-frontend/src/types/ScheduledTaskDo.ts b/peakup-frontend/src/types/ScheduledTaskDo.ts new file mode 100644 index 0000000..37a5d1d --- /dev/null +++ b/peakup-frontend/src/types/ScheduledTaskDo.ts @@ -0,0 +1,14 @@ +// GENERATED — do not edit manually. + +import type { AbstractTimestampedEntityDo } from './AbstractTimestampedEntityDo'; +import type { TaskTypeEnum } from './TaskTypeEnum'; +import type { PeriodicityEnum } from './PeriodicityEnum'; + +export interface ScheduledTaskDo extends AbstractTimestampedEntityDo { + athleteId: number; + taskType: TaskTypeEnum; + periodicity: PeriodicityEnum; + lastExecutionAt: string | null; + nextExecutionAt: string; + enabled: boolean; +} diff --git a/peakup-frontend/src/types/TaskTypeEnum.ts b/peakup-frontend/src/types/TaskTypeEnum.ts new file mode 100644 index 0000000..4117755 --- /dev/null +++ b/peakup-frontend/src/types/TaskTypeEnum.ts @@ -0,0 +1,6 @@ +// GENERATED — do not edit manually. + +export type TaskTypeEnum = + | 'RECALCULATE_METRIC' + | 'SEND_MAILS' + | 'EXPORT_ACTIVITIES'; diff --git a/peakup-tools/src/main/java/com/peakup/tools/generator/FlywayMigrationGenerator.java b/peakup-tools/src/main/java/com/peakup/tools/generator/FlywayMigrationGenerator.java new file mode 100644 index 0000000..be28a87 --- /dev/null +++ b/peakup-tools/src/main/java/com/peakup/tools/generator/FlywayMigrationGenerator.java @@ -0,0 +1,173 @@ +package com.peakup.tools.generator; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +public class FlywayMigrationGenerator { + + private static final Pattern VERSION_PATTERN = Pattern.compile("V(\\d+)__.*\\.sql"); + + public void generate(final ModelDefinition def, final Set enumNames, + final Path migrationDir) throws IOException { + final int nextVersion = detectNextVersion(migrationDir); + final String fileName = "V" + nextVersion + "__create_" + def.tableName + "_table.sql"; + final Path outputFile = migrationDir.resolve(fileName); + + final String sql = buildSql(def, enumNames, nextVersion); + Files.writeString(outputFile, sql); + System.out.println(" migration/" + fileName); + } + + private int detectNextVersion(final Path migrationDir) throws IOException { + int max = 0; + try (Stream paths = Files.list(migrationDir)) { + for (final Path path : paths.toList()) { + final Matcher matcher = VERSION_PATTERN.matcher(path.getFileName().toString()); + if (matcher.matches()) { + max = Math.max(max, Integer.parseInt(matcher.group(1))); + } + } + } + return max + 1; + } + + private String buildSql(final ModelDefinition def, final Set enumNames, + final int version) { + final StringBuilder sql = new StringBuilder(); + sql.append("-- V").append(version).append(": ").append(def.modelName).append(" table\n\n"); + sql.append(buildMainTable(def, enumNames)).append("\n"); + + if (hasAthleteId(def)) { + sql.append("\nCREATE INDEX idx_").append(def.tableName).append("_athlete ON ") + .append(def.tableName).append("(athlete_id);\n"); + } + + for (final ElementCollectionDefinition ec : def.elementCollections) { + sql.append("\n").append(buildCollectionTable(ec, def.tableName, enumNames)).append("\n"); + sql.append("\nCREATE INDEX idx_").append(ec.collectionTable).append("_fk ON ") + .append(ec.collectionTable).append("(").append(ec.joinColumn).append(");\n"); + } + + return sql.toString(); + } + + private String buildMainTable(final ModelDefinition def, final Set enumNames) { + final int padWidth = resolveMainTablePadWidth(def); + final List columnDefs = new ArrayList<>(); + + columnDefs.add(" " + pad("id", padWidth) + "BIGINT PRIMARY KEY DEFAULT nextval('global_id_seq')"); + + for (final FieldDefinition field : def.fields) { + final StringBuilder column = new StringBuilder(" "); + column.append(pad(field.columnName, padWidth)); + column.append(toSqlType(field, enumNames)); + if (!field.nullable) { + column.append(" NOT NULL"); + } + if ("athleteId".equals(field.name)) { + column.append(" REFERENCES athlete(id) ON DELETE CASCADE"); + } + columnDefs.add(column.toString()); + } + + if ("AbstractTimestampedEntity".equals(def.baseClass)) { + columnDefs.add(" " + pad("created_at", padWidth) + "TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()"); + columnDefs.add(" " + pad("modified_at", padWidth) + "TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()"); + } + + return "CREATE TABLE " + def.tableName + " (\n" + + String.join(",\n", columnDefs) + "\n);"; + } + + private String buildCollectionTable(final ElementCollectionDefinition ec, + final String ownerTable, final Set enumNames) { + final int padWidth = resolveCollectionTablePadWidth(ec); + final List columnDefs = new ArrayList<>(); + + columnDefs.add(" " + pad(ec.joinColumn, padWidth) + + "BIGINT NOT NULL REFERENCES " + ownerTable + "(id) ON DELETE CASCADE"); + + if (ec.valueColumn != null) { + columnDefs.add(" " + pad(ec.valueColumn, padWidth) + "VARCHAR(255) NOT NULL"); + } else if (ec.embeddable != null) { + for (final FieldDefinition field : ec.embeddable.fields) { + final StringBuilder column = new StringBuilder(" "); + column.append(pad(field.columnName, padWidth)); + column.append(toSqlType(field, enumNames)); + if (!field.nullable) { + column.append(" NOT NULL"); + } + columnDefs.add(column.toString()); + } + } + + if (ec.orderColumn != null) { + columnDefs.add(" " + pad(ec.orderColumn, padWidth) + "INTEGER NOT NULL"); + } + + return "CREATE TABLE " + ec.collectionTable + " (\n" + + String.join(",\n", columnDefs) + "\n);"; + } + + private int resolveMainTablePadWidth(final ModelDefinition def) { + int max = "id".length(); + for (final FieldDefinition field : def.fields) { + max = Math.max(max, field.columnName.length()); + } + if ("AbstractTimestampedEntity".equals(def.baseClass)) { + max = Math.max(max, "modified_at".length()); + } + return max + 2; + } + + private int resolveCollectionTablePadWidth(final ElementCollectionDefinition ec) { + int max = ec.joinColumn.length(); + if (ec.valueColumn != null) { + max = Math.max(max, ec.valueColumn.length()); + } else if (ec.embeddable != null) { + for (final FieldDefinition field : ec.embeddable.fields) { + max = Math.max(max, field.columnName.length()); + } + } + if (ec.orderColumn != null) { + max = Math.max(max, ec.orderColumn.length()); + } + return max + 2; + } + + private static String toSqlType(final FieldDefinition field, final Set enumNames) { + if (field.columnDefinition != null) { + return field.columnDefinition; + } + return switch (field.javaType) { + case "long", "Long" -> "BIGINT"; + case "int", "Integer" -> "INTEGER"; + case "double", "Double" -> "DOUBLE PRECISION"; + case "float", "Float" -> "REAL"; + case "boolean", "Boolean" -> "BOOLEAN"; + case "String" -> "VARCHAR(255)"; + case "Instant" -> "TIMESTAMP WITH TIME ZONE"; + case "LocalDate" -> "DATE"; + case "int[]" -> "integer[]"; + case "long[]" -> "bigint[]"; + case "double[]" -> "double precision[]"; + case "String[]" -> "text[]"; + default -> enumNames.contains(field.javaType) ? "INTEGER" : "VARCHAR(255)"; + }; + } + + private static String pad(final String value, final int width) { + return String.format("%-" + width + "s", value); + } + + private static boolean hasAthleteId(final ModelDefinition def) { + return def.fields.stream().anyMatch(field -> "athleteId".equals(field.name)); + } +} \ No newline at end of file diff --git a/peakup-tools/src/main/java/com/peakup/tools/generator/ModelGeneratorTool.java b/peakup-tools/src/main/java/com/peakup/tools/generator/ModelGeneratorTool.java index 112e347..26eaeab 100644 --- a/peakup-tools/src/main/java/com/peakup/tools/generator/ModelGeneratorTool.java +++ b/peakup-tools/src/main/java/com/peakup/tools/generator/ModelGeneratorTool.java @@ -13,6 +13,7 @@ import com.squareup.javapoet.TypeName; import com.squareup.javapoet.TypeSpec; import javax.lang.model.element.Modifier; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; @@ -42,41 +43,83 @@ public class ModelGeneratorTool { // ----------------------------------------------------------------------- public static void main(final String[] args) throws Exception { - if (args.length < 1) { - System.err.println("Usage: ModelGeneratorTool [outputDir]"); + boolean force = false; + final List positionalArgs = new ArrayList<>(); + for (final String arg : args) { + if ("--force".equals(arg)) { + force = true; + } else { + positionalArgs.add(arg); + } + } + + if (positionalArgs.isEmpty()) { + System.err.println("Usage: ModelGeneratorTool [--force] [outputDir] [migrationDir]"); System.exit(1); } - final Path definitionPath = Paths.get(args[0]); - final Path outputDir = args.length >= 2 - ? Paths.get(args[1]) + final Path definitionPath = Paths.get(positionalArgs.get(0)); + final Path outputDir = positionalArgs.size() >= 2 + ? Paths.get(positionalArgs.get(1)) : Paths.get(System.getProperty("user.dir")).resolve("peakup-backend/src/main/java"); + final Path migrationDir = positionalArgs.size() >= 3 + ? Paths.get(positionalArgs.get(2)) + : Paths.get(System.getProperty("user.dir")).resolve("peakup-backend/src/main/resources/db/migration"); final ModelDefinition def = new ObjectMapper() .configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .readValue(definitionPath.toFile(), ModelDefinition.class); - System.out.println("Generating: " + def.modelName + " → " + outputDir.toAbsolutePath()); - final ModelGeneratorTool tool = new ModelGeneratorTool(); final Set enumNames = def.enums.stream().map(e -> e.className).collect(Collectors.toSet()); + final List javaFiles = new ArrayList<>(); for (final EnumDefinition enumDef : def.enums) { - write(tool.generateEnum(enumDef, def.packageBase + ".model"), outputDir, "model/" + enumDef.className); + javaFiles.add(tool.generateEnum(enumDef, def.packageBase + ".model")); } for (final ElementCollectionDefinition ec : def.elementCollections) { if (ec.embeddable != null) { - write(tool.generateEmbeddable(ec.embeddable, def.packageBase + ".model"), outputDir, "model/" + ec.embeddable.className); + javaFiles.add(tool.generateEmbeddable(ec.embeddable, def.packageBase + ".model")); } } - write(tool.generateModel(def, enumNames), outputDir, "model/" + def.modelName); - write(tool.generateDAO(def), outputDir, "dao/" + def.modelName + "DAO"); - write(tool.generateService(def), outputDir, "service/" + def.modelName + "Service"); - write(tool.generateResource(def), outputDir, def.modelName + "Resource"); + javaFiles.add(tool.generateModel(def, enumNames)); + javaFiles.add(tool.generateDAO(def)); + javaFiles.add(tool.generateService(def)); + javaFiles.add(tool.generateResource(def)); + + final List conflicts = javaFiles.stream() + .map(javaFile -> resolveOutputPath(javaFile, outputDir)) + .filter(Files::exists) + .collect(Collectors.toList()); + + if (!conflicts.isEmpty() && !force) { + System.err.println("ERROR: The following files already exist (use --force to overwrite):"); + conflicts.forEach(conflictPath -> System.err.println(" " + conflictPath)); + System.exit(1); + } + + System.out.println("Generating: " + def.modelName + " → " + outputDir.toAbsolutePath()); + + for (final JavaFile javaFile : javaFiles) { + final String packageSuffix = javaFile.packageName.substring(javaFile.packageName.lastIndexOf('.') + 1); + write(javaFile, outputDir, packageSuffix + "/" + javaFile.typeSpec.name); + } + + if (force && !conflicts.isEmpty()) { + System.out.println("WARNING: Overwrote " + conflicts.size() + " existing file(s). Skipping Flyway migration."); + } else { + new FlywayMigrationGenerator().generate(def, enumNames, migrationDir); + } System.out.println("Done."); } + private static Path resolveOutputPath(final JavaFile javaFile, final Path outputDir) { + return outputDir + .resolve(javaFile.packageName.replace('.', '/')) + .resolve(javaFile.typeSpec.name + ".java"); + } + private static void write(final JavaFile file, final Path outputDir, final String label) throws Exception { file.writeTo(outputDir); System.out.println(" " + label + ".java"); diff --git a/peakup-tools/src/main/resources/definitions/scheduled-task.json b/peakup-tools/src/main/resources/definitions/scheduled-task.json new file mode 100644 index 0000000..ab1d33c --- /dev/null +++ b/peakup-tools/src/main/resources/definitions/scheduled-task.json @@ -0,0 +1,37 @@ +{ + "$schema": "../model-definition-schema.json", + "modelName": "ScheduledTask", + "packageBase": "com.peakup.scheduledtask", + "tableName": "scheduled_task", + "baseClass": "AbstractTimestampedEntity", + "apiPath": "/api/scheduled-tasks", + "fields": [ + { "name": "athleteId", "javaType": "long", "nullable": false, "columnName": "athlete_id" }, + { "name": "taskType", "javaType": "TaskTypeEnum", "nullable": false, "columnName": "task_type" }, + { "name": "periodicity", "javaType": "PeriodicityEnum", "nullable": false, "columnName": "periodicity" }, + { "name": "lastExecutionAt", "javaType": "Instant", "nullable": true, "columnName": "last_execution_at" }, + { "name": "nextExecutionAt", "javaType": "Instant", "nullable": false, "columnName": "next_execution_at" }, + { "name": "enabled", "javaType": "boolean", "nullable": false, "columnName": "enabled" } + ], + "enums": [ + { + "className": "TaskTypeEnum", + "values": [ + { "name": "RECALCULATE_METRIC", "key": 1 }, + { "name": "SEND_MAILS", "key": 2 }, + { "name": "EXPORT_ACTIVITIES", "key": 3 } + ] + }, + { + "className": "PeriodicityEnum", + "values": [ + { "name": "ONCE", "key": 1 }, + { "name": "HOURLY", "key": 2 }, + { "name": "DAILY", "key": 3 }, + { "name": "WEEKLY", "key": 4 }, + { "name": "MONTHLY", "key": 5 }, + { "name": "YEARLY", "key": 6 } + ] + } + ] +}