Scheduled ta
This commit is contained in:
16
CLAUDE.md
16
CLAUDE.md
@@ -26,14 +26,22 @@
|
||||
- **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`
|
||||
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 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`.
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.peakup.scheduler;
|
||||
package com.peakup.importer;
|
||||
|
||||
import com.peakup.activity.model.AbstractActivity;
|
||||
import jakarta.annotation.Nullable;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.peakup.scheduler;
|
||||
package com.peakup.importer;
|
||||
|
||||
import com.peakup.activity.model.IntervalsActivity;
|
||||
import com.peakup.activity.model.StreamStatusEnum;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.peakup.scheduler;
|
||||
package com.peakup.importer;
|
||||
|
||||
import com.peakup.activity.model.StravaActivity;
|
||||
import com.peakup.activity.service.StravaActivityService;
|
||||
@@ -0,0 +1,2 @@
|
||||
@com.peakup.common.annotation.NonNull
|
||||
package com.peakup.importer;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@com.peakup.common.annotation.NonNull
|
||||
package com.peakup.scheduledtask.dao;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@com.peakup.common.annotation.NonNull
|
||||
package com.peakup.scheduledtask.model;
|
||||
@@ -0,0 +1,2 @@
|
||||
@com.peakup.common.annotation.NonNull
|
||||
package com.peakup.scheduledtask;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@com.peakup.common.annotation.NonNull
|
||||
package com.peakup.scheduledtask.service;
|
||||
@@ -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()) {
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE scheduled_task
|
||||
ADD CONSTRAINT uq_scheduled_task_athlete_task_type UNIQUE (athlete_id, task_type);
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
17
peakup-frontend/src/api/ScheduledTaskClient.ts
Normal file
17
peakup-frontend/src/api/ScheduledTaskClient.ts
Normal 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),
|
||||
};
|
||||
@@ -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() {
|
||||
|
||||
@@ -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')
|
||||
|
||||
9
peakup-frontend/src/types/PeriodicityEnum.ts
Normal file
9
peakup-frontend/src/types/PeriodicityEnum.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// GENERATED — do not edit manually.
|
||||
|
||||
export type PeriodicityEnum =
|
||||
| 'ONCE'
|
||||
| 'HOURLY'
|
||||
| 'DAILY'
|
||||
| 'WEEKLY'
|
||||
| 'MONTHLY'
|
||||
| 'YEARLY';
|
||||
14
peakup-frontend/src/types/ScheduledTaskDo.ts
Normal file
14
peakup-frontend/src/types/ScheduledTaskDo.ts
Normal 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;
|
||||
}
|
||||
6
peakup-frontend/src/types/TaskTypeEnum.ts
Normal file
6
peakup-frontend/src/types/TaskTypeEnum.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// GENERATED — do not edit manually.
|
||||
|
||||
export type TaskTypeEnum =
|
||||
| 'RECALCULATE_METRIC'
|
||||
| 'SEND_MAILS'
|
||||
| 'EXPORT_ACTIVITIES';
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user