Scheduled ta

This commit is contained in:
2026-05-15 14:32:46 +02:00
parent d11923c4f2
commit bcc026a768
30 changed files with 750 additions and 55 deletions

View File

@@ -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`

View File

@@ -73,16 +73,6 @@ public class GoalResource extends AbstractResource {
}
}
@POST
@Path("/metric-totals/recalculate")
public RestResponse<Void> 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<Void> delete(@PathParam("id") final long id) {

View File

@@ -1,4 +1,4 @@
package com.peakup.scheduler;
package com.peakup.importer;
import com.peakup.activity.model.AbstractActivity;
import jakarta.annotation.Nullable;

View File

@@ -1,4 +1,4 @@
package com.peakup.scheduler;
package com.peakup.importer;
import com.peakup.activity.model.IntervalsActivity;
import com.peakup.activity.model.StreamStatusEnum;

View File

@@ -1,4 +1,4 @@
package com.peakup.scheduler;
package com.peakup.importer;
import com.peakup.activity.model.StravaActivity;
import com.peakup.activity.service.StravaActivityService;

View File

@@ -0,0 +1,2 @@
@com.peakup.common.annotation.NonNull
package com.peakup.importer;

View File

@@ -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<List<ScheduledTask>> 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<ScheduledTask> getById(@PathParam("id") final long id) {
try {
return RestResponse.ok(scheduledTaskService.getById(id));
} catch (final NoResultException e) {
return RestResponse.notFound();
}
}
@POST
public RestResponse<ScheduledTask> create(final ScheduledTask scheduledTask) {
return RestResponse.ok(scheduledTaskService.upsert(scheduledTask));
}
@PUT
@Path("/{id}")
public RestResponse<ScheduledTask> 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<Void> 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();
}
}
}

View File

@@ -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<ScheduledTask> {
@Override
protected Class<ScheduledTask> getEntityClass() {
return ScheduledTask.class;
}
public List<ScheduledTask> getByAthleteId(final long athleteId) {
final CriteriaBuilder cb = em.getCriteriaBuilder();
final CriteriaQuery<ScheduledTask> cq = cb.createQuery(ScheduledTask.class);
final Root<ScheduledTask> root = cq.from(ScheduledTask.class);
cq.select(root).where(cb.equal(root.get("athleteId"), athleteId));
return em.createQuery(cq).getResultList();
}
public Optional<ScheduledTask> findByAthleteIdAndTaskType(final long athleteId, final TaskTypeEnum taskType) {
final CriteriaBuilder cb = em.getCriteriaBuilder();
final CriteriaQuery<ScheduledTask> cq = cb.createQuery(ScheduledTask.class);
final Root<ScheduledTask> 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();
}
}

View File

@@ -0,0 +1,2 @@
@com.peakup.common.annotation.NonNull
package com.peakup.scheduledtask.dao;

View File

@@ -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<PeriodicityEnum> {
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<PeriodicityEnum> {
@Override
public PeriodicityEnum getEnumByKey(final Integer key) {
return PersistableEnum.getEnumByKey(key, PeriodicityEnum.values());
}
}
}

View File

@@ -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;
}
}

View File

@@ -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<TaskTypeEnum> {
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<TaskTypeEnum> {
@Override
public TaskTypeEnum getEnumByKey(final Integer key) {
return PersistableEnum.getEnumByKey(key, TaskTypeEnum.values());
}
}
}

View File

@@ -0,0 +1,2 @@
@com.peakup.common.annotation.NonNull
package com.peakup.scheduledtask.model;

View File

@@ -0,0 +1,2 @@
@com.peakup.common.annotation.NonNull
package com.peakup.scheduledtask;

View File

@@ -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<ScheduledTask, ScheduledTaskDAO> {
@Inject
@SuppressWarnings("NullAway.Init")
ScheduledTaskDAO scheduledTaskDAO;
@Override
protected ScheduledTaskDAO getDAO() {
return scheduledTaskDAO;
}
@Transactional
public List<ScheduledTask> getByAthleteId(final long athleteId) {
return scheduledTaskDAO.getByAthleteId(athleteId);
}
@Transactional
public ScheduledTask upsert(final ScheduledTask scheduledTask) {
final Optional<ScheduledTask> 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<ScheduledTask> findByAthleteIdAndTaskType(final long athleteId, final TaskTypeEnum taskType) {
final Optional<ScheduledTask> result = scheduledTaskDAO.findByAthleteIdAndTaskType(athleteId, taskType);
return result;
}
}

View File

@@ -0,0 +1,2 @@
@com.peakup.common.annotation.NonNull
package com.peakup.scheduledtask.service;

View File

@@ -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()) {

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
ALTER TABLE scheduled_task
ADD CONSTRAINT uq_scheduled_task_athlete_task_type UNIQUE (athlete_id, task_type);

View File

@@ -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;

View File

@@ -10,8 +10,6 @@ export const goals = {
del<void>(`/api/goals/${id}`),
getByAthlete: (athleteId?: number): Promise<GoalDo[]> =>
get<GoalDo[]>(`/api/goals${buildQuery({ athleteId })}`),
recalculateMetricTotals: (athleteId?: number): Promise<void> =>
post<void>(`/api/goals/metric-totals/recalculate${buildQuery({ athleteId })}`),
update: (id: number, body: GoalDo): Promise<GoalDo> =>
put<GoalDo>(`/api/goals/${id}`, body),
};

View File

@@ -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<ScheduledTaskDo> =>
post<ScheduledTaskDo>('/api/scheduled-tasks', body),
delete: (id: number): Promise<void> =>
del<void>(`/api/scheduled-tasks/${id}`),
getByAthlete: (athleteId?: number): Promise<ScheduledTaskDo[]> =>
get<ScheduledTaskDo[]>(`/api/scheduled-tasks${buildQuery({ athleteId })}`),
getById: (id: number): Promise<ScheduledTaskDo> =>
get<ScheduledTaskDo>(`/api/scheduled-tasks/${id}`),
update: (id: number, body: ScheduledTaskDo): Promise<ScheduledTaskDo> =>
put<ScheduledTaskDo>(`/api/scheduled-tasks/${id}`, body),
};

View File

@@ -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<void, Error, number>({
mutationFn: (athleteId) =>
goals.recalculateMetricTotals(athleteId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['goals'] }),
});
}
export function useGoalsUpdate() {
const queryClient = useQueryClient();
return useMutation<GoalDo, Error, { id: number; body: GoalDo }>({
@@ -147,6 +144,49 @@ export function useGoalsUpdate() {
});
}
// ── scheduledTasks ────────────────────────────────────
export function useScheduledTasksCreate() {
const queryClient = useQueryClient();
return useMutation<ScheduledTaskDo, Error, ScheduledTaskDo>({
mutationFn: (body) =>
scheduledTasks.create(body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scheduledTasks'] }),
});
}
export function useScheduledTasksDelete() {
const queryClient = useQueryClient();
return useMutation<void, Error, number>({
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<ScheduledTaskDo, Error, { id: number; body: ScheduledTaskDo }>({
mutationFn: ({ id, body }) =>
scheduledTasks.update(id, body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scheduledTasks'] }),
});
}
// ── weekPlans ────────────────────────────────────
export function useWeekPlansCreate() {

View File

@@ -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')

View File

@@ -0,0 +1,9 @@
// GENERATED — do not edit manually.
export type PeriodicityEnum =
| 'ONCE'
| 'HOURLY'
| 'DAILY'
| 'WEEKLY'
| 'MONTHLY'
| 'YEARLY';

View File

@@ -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;
}

View File

@@ -0,0 +1,6 @@
// GENERATED — do not edit manually.
export type TaskTypeEnum =
| 'RECALCULATE_METRIC'
| 'SEND_MAILS'
| 'EXPORT_ACTIVITIES';

View File

@@ -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<String> 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<Path> 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<String> 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<String> enumNames) {
final int padWidth = resolveMainTablePadWidth(def);
final List<String> 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<String> enumNames) {
final int padWidth = resolveCollectionTablePadWidth(ec);
final List<String> 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<String> 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));
}
}

View File

@@ -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 <definition.json> [outputDir]");
boolean force = false;
final List<String> 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] <definition.json> [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<String> enumNames = def.enums.stream().map(e -> e.className).collect(Collectors.toSet());
final List<JavaFile> 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<Path> 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");

View File

@@ -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 }
]
}
]
}