diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml
index 1c167c5..b0418cd 100644
--- a/.gitea/workflows/deploy.yaml
+++ b/.gitea/workflows/deploy.yaml
@@ -3,7 +3,7 @@ on:
workflow_dispatch:
inputs:
mode:
- description: 'Release mode — deployment can erase data until S16 is implemented'
+ description: 'Release mode — export legacy data before the first S16 deployment'
required: true
type: choice
default: new
@@ -44,7 +44,7 @@ jobs:
persist-credentials: false
- name: Identify workflow attempt
run: java ci/Ci.java attempt
- - name: Release verified images — data can be lost until S16
+ - name: Release verified images — persistent PostgreSQL storage
env:
RELEASE_MODE: ${{ inputs.mode }}
RELEASE_INCREMENT: ${{ inputs.increment }}
diff --git a/.gitea/workflows/rollback.yaml b/.gitea/workflows/rollback.yaml
index 1f3721d..feff460 100644
--- a/.gitea/workflows/rollback.yaml
+++ b/.gitea/workflows/rollback.yaml
@@ -21,7 +21,7 @@ jobs:
persist-credentials: false
- name: Identify workflow attempt
run: java ci/Ci.java attempt
- - name: Restore previous successful release — data can be lost until S16
+ - name: Restore previous successful release — requires a compatible database schema
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
diff --git a/Dockerfile b/Dockerfile
index 18de3e0..fe44e43 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,7 +6,12 @@ COPY tooling tooling
COPY ci ci
COPY agiledancers-be/pom.xml agiledancers-be/pom.xml
COPY agiledancers-be/src agiledancers-be/src
-RUN mvn -B -ntp clean verify
+# Source builds run the same PostgreSQL tests as local/CI verification.
+# Supply connection configuration as BuildKit secrets, which are not retained in image layers.
+RUN --mount=type=secret,id=database_url,env=QUARKUS_DATASOURCE_JDBC_URL,required=true \
+ --mount=type=secret,id=database_user,env=QUARKUS_DATASOURCE_USERNAME,required=true \
+ --mount=type=secret,id=database_password,env=QUARKUS_DATASOURCE_PASSWORD,required=true \
+ mvn -B -ntp clean verify
FROM eclipse-temurin:25-jre AS runtime
WORKDIR /app
diff --git a/README.md b/README.md
index 8a43ab6..ecbebc5 100644
--- a/README.md
+++ b/README.md
@@ -19,14 +19,19 @@ mvn -pl agiledancers-be quarkus:dev
Run the packaged application after building:
```sh
+# Set DATABASE_URL, DATABASE_USER and DATABASE_PASSWORD first.
java -jar agiledancers-be/target/quarkus-app/quarkus-run.jar
```
-Build and run a Docker container from the repository root (only Docker is required):
+Build a Docker container against an isolated test database (see [connection setup](docs/persistent-storage.md)):
```sh
-docker build -t agiledancers .
-docker run --rm --name agiledancers -p 8080:8080 agiledancers
+docker build --network=host \
+ --secret id=database_url,env=TEST_DATABASE_URL \
+ --secret id=database_user,env=TEST_DATABASE_USER \
+ --secret id=database_password,env=TEST_DATABASE_PASSWORD -t agiledancers .
+docker run --rm --name agiledancers -p 8080:8080 \
+ -e DATABASE_URL -e DATABASE_USER -e DATABASE_PASSWORD agiledancers
```
The Docker build compiles the application and runs the tests in a Maven build stage,
@@ -42,7 +47,13 @@ curl --fail http://localhost:8080/q/health/ready
Quarkus configuration can be overridden with environment variables, for example
`docker run --rm -e QUARKUS_HTTP_PORT=9090 -p 9090:9090 agiledancers`.
-The REST API uses shared in-memory storage; data is lost when the process stops.
+PostgreSQL stores all school data, planning state and the latest scheduling run
+through JPA/Hibernate ORM. Persistence entities remain in the repository layer.
+Flyway initializes and versions the schema. Production requires `DATABASE_URL`
+(a JDBC PostgreSQL URL), `DATABASE_USER` and `DATABASE_PASSWORD`; missing credentials,
+connection failures and migration failures prevent startup. See
+[database setup and transition](docs/persistent-storage.md) before replacing an
+in-memory release: export every school before stopping it.
Architecture checks run with the normal test suite, or separately with:
@@ -56,7 +67,7 @@ mvn -pl agiledancers-be -Dtest=ArchitectureTest test
| --- | --- | --- |
| `model` | Domain entities and specifications | Model |
| `service` | Application use cases and future ports | Model, repositories |
-| `repository` | Current in-memory persistence | Model |
+| `repository` | JPA/Hibernate entities, repositories and transaction boundaries | Model |
| `rest` | Future REST services | Services, model |
| `solver` | Future OR-Tools calculations | Services (ports), model |
@@ -175,8 +186,9 @@ recover current state on reconnection rather than requiring event replay. The
production proxy disables buffering. The UI shows phase, elapsed time, solution
discovery and final objective scores, and reports completion in an application-wide
snackbar. Reloading recovers the latest state without replaying old notifications.
-Run state currently shares the application's in-memory lifetime; durable restart
-recovery requires the PostgreSQL integration specified in S16.
+The latest run and saved assignments survive restart. Startup marks formerly
+running calculations `INTERRUPTED`, preserves the previous assignments, and allows
+a new run. Results and successful status commit in one database transaction.
`PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an
ordered list of group IDs per daypart. Incomplete or invalid drafts can be saved;
@@ -281,7 +293,7 @@ for the current operational status.
Production releases and rollback use separate manual Gitea workflows. See the
[deployment runbook](docs/production-deployment.md) for Synology provisioning,
semantic version tags, release/retry/rollback and the accepted data-loss limitation
-until S16, and [S6 evidence](docs/s6-validation.md) for deployment readiness.
+during the one-time transition from an in-memory release, and [S6 evidence](docs/s6-validation.md) for deployment readiness.
### Java formatting (S8)
@@ -346,3 +358,18 @@ analysis tools, coverage policy and standalone commands. `mvn verify` runs stand
checks and requires at least 80% module-wide backend line coverage. CI also collects
and merges coverage from Playwright before publication. Reports are retained with
CI diagnostics. See [S7 validation](specs/S7-validation.md) for current migration status.
+
+
+Existing-school mutations require `X-School-Revision` with the revision loaded by
+the editor. `GET /api/dance-schools/{id}/data` returns the school and all its editor
+data from one consistent snapshot; `school.revision` identifies that snapshot.
+Missing or stale revisions receive `409` with a reload instruction. The import
+preview returns `existingRevision` for replacement confirmation. Creation of a
+new school does not need a revision. The frontend never automatically retries a
+conflicting edit with a newer revision.
+
+
+Domain-to-view projection is generated by MapStruct in `rest.mapping.DomainViewMapper`.
+It is a CDI bean with `unmappedTargetPolicy = ERROR`, so unmatched target fields fail
+compilation. REST resources supply calculated validation results explicitly. The
+entity-to-domain graph mappings remain handwritten in the repository layer.
diff --git a/agiledancers-be/pom.xml b/agiledancers-be/pom.xml
index 6d850c0..5f45e1f 100644
--- a/agiledancers-be/pom.xml
+++ b/agiledancers-be/pom.xml
@@ -14,6 +14,11 @@
jar
+ org.mapstructmapstruct${mapstruct.version}
+ io.quarkusquarkus-hibernate-orm
+ io.quarkusquarkus-jdbc-postgresql
+ io.quarkusquarkus-flyway
+ org.flywaydbflyway-database-postgresql
io.quarkus
quarkus-rest-jackson
@@ -63,6 +68,16 @@
+
+ org.apache.maven.pluginsmaven-compiler-plugin
+
+
+ org.mapstructmapstruct-processor${mapstruct.version}
+ org.projectlomboklombok-mapstruct-binding${lombok-mapstruct-binding.version}
+
+ -Amapstruct.suppressGeneratorTimestamp=true
+
+
org.apache.maven.pluginsmaven-dependency-plugin3.8.1
standards-classpathprepare-packagebuild-classpath
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java
index d2d3ca1..fe9b448 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java
@@ -20,6 +20,9 @@ public class DanceSchool extends AbstractEntity {
@Setter
private String name;
+ @Setter
+ private long revision;
+
@Builder.Default
private final Set dancers = new LinkedHashSet<>();
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchedulingRun.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingRun.java
similarity index 85%
rename from agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchedulingRun.java
rename to agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingRun.java
index 2570019..16084d8 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchedulingRun.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingRun.java
@@ -1,10 +1,7 @@
-package nl.agilecc.agiledancers.service.model;
+package nl.agilecc.agiledancers.model.scheduling;
import java.time.Instant;
import java.util.UUID;
-import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
-import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
-import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
/** Latest immutable state for one show's calculation; runId identifies notifications across reconnects. */
public record SchedulingRun(
@@ -24,7 +21,7 @@ public record SchedulingRun(
schoolId,
showId,
name,
- Instant.now(),
+ Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS),
null,
SchedulingState.RUNNING,
new SchedulingProgress(SchedulingPhase.PREPARING, false),
@@ -54,7 +51,7 @@ public record SchedulingRun(
showId,
showName,
startedAt,
- Instant.now(),
+ Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS),
outcome,
new SchedulingProgress(progress.phase(), progress.feasible() || scores != null),
explanation,
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchedulingState.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingState.java
similarity index 69%
rename from agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchedulingState.java
rename to agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingState.java
index f0ae7c4..4df1e0c 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchedulingState.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingState.java
@@ -1,7 +1,8 @@
-package nl.agilecc.agiledancers.service.model;
+package nl.agilecc.agiledancers.model.scheduling;
public enum SchedulingState {
RUNNING,
+ INTERRUPTED,
OPTIMAL,
VALID,
STALE,
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java
index 5da8a3c..6180885 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java
@@ -7,11 +7,19 @@ import nl.agilecc.agiledancers.model.AbstractEntity;
public abstract class AbstractRepository {
private long sequence = 0L;
+ private java.util.function.LongSupplier identifiers;
protected final Map entities = new LinkedHashMap<>();
public long nextId() {
- return sequence++;
+ return identifiers == null ? sequence++ : identifiers.getAsLong();
+ }
+
+ /** Replaces detached transaction state; production identifiers come from PostgreSQL. */
+ public void load(final java.util.Collection values, final java.util.function.LongSupplier identifiers) {
+ entities.clear();
+ values.forEach((final T entity) -> entities.put(entity.getId(), entity));
+ this.identifiers = identifiers;
}
public T add(final T entity) {
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/Database.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/Database.java
new file mode 100644
index 0000000..8720acb
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/Database.java
@@ -0,0 +1,113 @@
+package nl.agilecc.agiledancers.repository;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.LockModeType;
+import jakarta.persistence.OptimisticLockException;
+import jakarta.persistence.PersistenceException;
+import jakarta.transaction.Status;
+import jakarta.transaction.TransactionManager;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import nl.agilecc.agiledancers.repository.entity.EntityIdentifiers;
+
+/** Owns short JTA units of work; Hibernate flushes before commit and publication follows durable commit. */
+@Singleton
+public class Database {
+ private final EntityManager entities;
+ private final TransactionManager manager;
+ private final ThreadLocal> committed = new ThreadLocal<>();
+ private final ThreadLocal> rolledBack = new ThreadLocal<>();
+
+ @Inject
+ public Database(final EntityManager entities, final TransactionManager manager) {
+ this.entities = entities;
+ this.manager = manager;
+ }
+
+ /**
+ * Runs work in a new transaction, or joins the current JTA transaction. Callback registration is supported only
+ * inside transactions started by this instance. Commit callbacks run in registration order after durable commit;
+ * their exceptions propagate to the caller without rolling back committed work or running remaining callbacks.
+ * Rollback callbacks run before the original failure is rethrown; a callback exception replaces that failure.
+ */
+ public T transaction(final Callable work) throws Exception {
+ try {
+ if (manager.getStatus() != Status.STATUS_NO_TRANSACTION) {
+ return work.call();
+ }
+ return outerTransaction(work);
+ } catch (final OptimisticLockException failure) {
+ throw new RevisionConflictException();
+ } catch (final PersistenceException
+ | jakarta.transaction.SystemException
+ | jakarta.transaction.RollbackException
+ | jakarta.transaction.HeuristicMixedException
+ | jakarta.transaction.HeuristicRollbackException
+ | jakarta.transaction.NotSupportedException failure) {
+ if (optimisticConflict(failure)) {
+ throw new RevisionConflictException();
+ }
+ System.getLogger(Database.class.getName())
+ .log(System.Logger.Level.WARNING, "JPA transaction failed", failure);
+ throw new StorageException(failure);
+ }
+ }
+
+ private static boolean optimisticConflict(final Throwable failure) {
+ for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
+ if (cause instanceof OptimisticLockException) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private T outerTransaction(final Callable work) throws Exception {
+ final List publications = new ArrayList<>();
+ manager.begin();
+ committed.set(publications);
+ rolledBack.set(new ArrayList<>());
+ final T result;
+ try {
+ result = work.call();
+ manager.commit();
+ } catch (final Exception | Error failure) {
+ rollback(failure);
+ rolledBack.get().forEach(Runnable::run);
+ throw failure;
+ } finally {
+ committed.remove();
+ rolledBack.remove();
+ }
+ publications.forEach(Runnable::run);
+ return result;
+ }
+
+ private void rollback(final Throwable failure) {
+ try {
+ if (manager.getStatus() != Status.STATUS_NO_TRANSACTION) {
+ manager.rollback();
+ }
+ } catch (final jakarta.transaction.SystemException rollbackFailure) {
+ failure.addSuppressed(rollbackFailure);
+ }
+ }
+
+ /** Registers a callback on this thread's transaction owned by this instance; see {@link #transaction(Callable)}. */
+ public void afterCommit(final Runnable action) {
+ committed.get().add(action);
+ }
+
+ /** Registers a rollback callback under the same ownership requirement as {@link #afterCommit(Runnable)}. */
+ public void afterRollback(final Runnable action) {
+ rolledBack.get().add(action);
+ }
+
+ public long nextId() {
+ return entities.find(EntityIdentifiers.class, 1, LockModeType.PESSIMISTIC_WRITE)
+ .claim();
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/GroupMapping.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/GroupMapping.java
new file mode 100644
index 0000000..51c0f26
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/GroupMapping.java
@@ -0,0 +1,62 @@
+package nl.agilecc.agiledancers.repository;
+
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import nl.agilecc.agiledancers.model.Coach;
+import nl.agilecc.agiledancers.model.DanceGroup;
+import nl.agilecc.agiledancers.model.DanceSchool;
+import nl.agilecc.agiledancers.model.Dancer;
+import nl.agilecc.agiledancers.repository.entity.CoachEntity;
+import nl.agilecc.agiledancers.repository.entity.GroupEntity;
+import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
+import nl.agilecc.agiledancers.repository.entity.SchoolMemberId;
+
+public class GroupMapping {
+ public static Map groups(
+ final SchoolEntity stored,
+ final DanceSchool school,
+ final Map coaches,
+ final Map dancers) {
+ final Map result = new LinkedHashMap<>();
+ for (final GroupEntity entity : stored.getGroups()) {
+ final DanceGroup group = DanceGroup.builder()
+ .category(entity.getCategory())
+ .type(entity.getType())
+ .startAge(entity.getStartAge())
+ .endAge(entity.getEndAge())
+ .coach(
+ entity.getCoach() == null
+ ? null
+ : coaches.get(entity.getCoach().getId().getLocalId()))
+ .styles(new LinkedHashSet<>(entity.getStyles()))
+ .build();
+ group.setId(entity.getId().getLocalId());
+ entity.getDancerIds().stream().sorted().forEach((final Long id) -> group.addDancer(dancers.get(id)));
+ school.addDanceGroup(group);
+ result.put(group.getId(), group);
+ }
+ return result;
+ }
+
+ public static GroupEntity group(final SchoolEntity school, final DanceGroup group) {
+ final GroupEntity entity = new GroupEntity();
+ entity.setId(new SchoolMemberId(school.getId(), group.getId()));
+ entity.setSchool(school);
+ entity.setCategory(group.getCategory());
+ entity.setType(group.getType());
+ entity.setStartAge(group.getStartAge());
+ entity.setEndAge(group.getEndAge());
+ if (group.getCoach() != null) {
+ entity.setCoach(school.getCoaches().stream()
+ .filter((final CoachEntity coach) ->
+ coach.getId().getLocalId() == group.getCoach().getId())
+ .findFirst()
+ .orElseThrow());
+ }
+ entity.setStyles(new LinkedHashSet<>(group.getStyles()));
+ group.getDancers()
+ .forEach((final Dancer dancer) -> entity.getDancerIds().add(dancer.getId()));
+ return entity;
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/PeopleMapping.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/PeopleMapping.java
new file mode 100644
index 0000000..71d42ae
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/PeopleMapping.java
@@ -0,0 +1,70 @@
+package nl.agilecc.agiledancers.repository;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import nl.agilecc.agiledancers.model.Coach;
+import nl.agilecc.agiledancers.model.DanceSchool;
+import nl.agilecc.agiledancers.model.Dancer;
+import nl.agilecc.agiledancers.repository.entity.CoachEntity;
+import nl.agilecc.agiledancers.repository.entity.DancerEntity;
+import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
+import nl.agilecc.agiledancers.repository.entity.SchoolMemberId;
+
+/** Converts people while preserving the shared dancer identity used by coach links. */
+public class PeopleMapping {
+ public static Map dancers(final SchoolEntity stored, final DanceSchool school) {
+ final Map result = new LinkedHashMap<>();
+ for (final DancerEntity person : stored.getDancers()) {
+ final Dancer dancer = Dancer.builder()
+ .name(person.getName())
+ .birthDate(person.getBirthDate())
+ .build();
+ dancer.setId(person.getId().getLocalId());
+ school.addDancer(dancer);
+ result.put(dancer.getId(), dancer);
+ }
+ return result;
+ }
+
+ public static Map coaches(
+ final SchoolEntity stored, final DanceSchool school, final Map dancers) {
+ final Map result = new LinkedHashMap<>();
+ for (final CoachEntity trainer : stored.getCoaches()) {
+ final Coach coach = Coach.builder()
+ .name(trainer.getName())
+ .dancer(
+ trainer.getDancer() == null
+ ? null
+ : dancers.get(trainer.getDancer().getId().getLocalId()))
+ .build();
+ coach.setId(trainer.getId().getLocalId());
+ school.addCoach(coach);
+ result.put(coach.getId(), coach);
+ }
+ return result;
+ }
+
+ public static DancerEntity dancer(final SchoolEntity school, final Dancer dancer) {
+ final DancerEntity entity = new DancerEntity();
+ entity.setId(new SchoolMemberId(school.getId(), dancer.getId()));
+ entity.setSchool(school);
+ entity.setName(dancer.getName());
+ entity.setBirthDate(dancer.getBirthDate());
+ return entity;
+ }
+
+ public static CoachEntity coach(final SchoolEntity school, final Coach coach) {
+ final CoachEntity entity = new CoachEntity();
+ entity.setId(new SchoolMemberId(school.getId(), coach.getId()));
+ entity.setSchool(school);
+ entity.setName(coach.getName());
+ if (coach.getDancer() != null) {
+ entity.setDancer(school.getDancers().stream()
+ .filter((final DancerEntity dancer) ->
+ dancer.getId().getLocalId() == coach.getDancer().getId())
+ .findFirst()
+ .orElseThrow());
+ }
+ return entity;
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/RevisionConflictException.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/RevisionConflictException.java
new file mode 100644
index 0000000..b7c6836
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/RevisionConflictException.java
@@ -0,0 +1,8 @@
+package nl.agilecc.agiledancers.repository;
+
+/** An optimistic persistence conflict requires a new editor snapshot, never an automatic retry. */
+public class RevisionConflictException extends RuntimeException {
+ public RevisionConflictException() {
+ super("Deze school is gewijzigd. Herlaad de gegevens en probeer opnieuw.");
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/RunRepository.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/RunRepository.java
new file mode 100644
index 0000000..a51175c
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/RunRepository.java
@@ -0,0 +1,78 @@
+package nl.agilecc.agiledancers.repository;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+import jakarta.persistence.EntityManager;
+import java.util.List;
+import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
+import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
+import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
+import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
+import nl.agilecc.agiledancers.repository.entity.RunEntity;
+import nl.agilecc.agiledancers.repository.entity.SchoolMemberId;
+
+@Singleton
+public class RunRepository {
+ private final EntityManager entities;
+
+ @Inject
+ public RunRepository(final EntityManager entities) {
+ this.entities = entities;
+ }
+
+ public List load() {
+ return entities
+ .createQuery("from StoredRun run order by run.id.schoolId,run.id.localId", RunEntity.class)
+ .getResultList()
+ .stream()
+ .map(this::read)
+ .toList();
+ }
+
+ public void recover() {
+ load().stream()
+ .filter((final SchedulingRun run) -> run.state() == SchedulingState.RUNNING)
+ .forEach((final SchedulingRun run) -> save(run.completed(
+ SchedulingState.INTERRUPTED,
+ "Berekening onderbroken door een backendherstart. Geen nieuw resultaat toegepast; bestaande"
+ + " indeling behouden. Start de berekening opnieuw.",
+ null)));
+ }
+
+ public void save(final SchedulingRun run) {
+ final RunEntity entity = new RunEntity();
+ entity.setId(new SchoolMemberId(run.schoolId(), run.showId()));
+ entity.setRunId(run.runId());
+ entity.setShowName(run.showName());
+ entity.setStartedAt(run.startedAt());
+ entity.setFinishedAt(run.finishedAt());
+ entity.setState(run.state());
+ entity.setPhase(run.progress().phase());
+ entity.setFeasible(run.progress().feasible());
+ entity.setMessage(run.message());
+ if (run.summary() != null) {
+ entity.setAttendanceExcess(run.summary().attendanceExcess());
+ entity.setPerformanceSpread(run.summary().performanceSpread());
+ entity.setVarietyPenalty(run.summary().varietyPenalty());
+ }
+ entities.merge(entity);
+ }
+
+ private SchedulingRun read(final RunEntity entity) {
+ final SchedulingSummary summary = entity.getAttendanceExcess() == null
+ ? null
+ : new SchedulingSummary(
+ entity.getAttendanceExcess(), entity.getPerformanceSpread(), entity.getVarietyPenalty());
+ return new SchedulingRun(
+ entity.getRunId(),
+ entity.getId().getSchoolId(),
+ entity.getId().getLocalId(),
+ entity.getShowName(),
+ entity.getStartedAt(),
+ entity.getFinishedAt(),
+ entity.getState(),
+ new SchedulingProgress(entity.getPhase(), entity.isFeasible()),
+ entity.getMessage(),
+ summary);
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/SchoolReader.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/SchoolReader.java
new file mode 100644
index 0000000..05f173c
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/SchoolReader.java
@@ -0,0 +1,47 @@
+package nl.agilecc.agiledancers.repository;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+import jakarta.persistence.EntityManager;
+import java.util.List;
+import java.util.Map;
+import nl.agilecc.agiledancers.model.Coach;
+import nl.agilecc.agiledancers.model.DanceGroup;
+import nl.agilecc.agiledancers.model.DanceSchool;
+import nl.agilecc.agiledancers.model.Dancer;
+import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
+import nl.agilecc.agiledancers.repository.entity.ShowEntity;
+
+/** Exposes detached domain snapshots, keeping Hibernate sessions and lazy collections inside the adapter. */
+@Singleton
+public class SchoolReader {
+ private final EntityManager entities;
+
+ @Inject
+ public SchoolReader(final EntityManager entities) {
+ this.entities = entities;
+ }
+
+ public List load() {
+ return entities
+ .createQuery("from StoredSchool school order by school.id", SchoolEntity.class)
+ .getResultList()
+ .stream()
+ .map(this::school)
+ .toList();
+ }
+
+ private DanceSchool school(final SchoolEntity entity) {
+ final DanceSchool school = DanceSchool.builder()
+ .key(entity.getKey())
+ .name(entity.getName())
+ .revision(entity.getRevision())
+ .build();
+ school.setId(entity.getId());
+ final Map dancers = PeopleMapping.dancers(entity, school);
+ final Map coaches = PeopleMapping.coaches(entity, school, dancers);
+ final Map groups = GroupMapping.groups(entity, school, coaches, dancers);
+ entity.getShows().forEach((final ShowEntity show) -> school.addShow(ShowMapping.show(show, groups)));
+ return school;
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/SchoolWriter.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/SchoolWriter.java
new file mode 100644
index 0000000..bd31d75
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/SchoolWriter.java
@@ -0,0 +1,82 @@
+package nl.agilecc.agiledancers.repository;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.LockModeType;
+import java.util.List;
+import nl.agilecc.agiledancers.model.Coach;
+import nl.agilecc.agiledancers.model.DanceGroup;
+import nl.agilecc.agiledancers.model.DanceSchool;
+import nl.agilecc.agiledancers.model.Dancer;
+import nl.agilecc.agiledancers.model.Show;
+import nl.agilecc.agiledancers.repository.entity.RunEntity;
+import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
+
+/** Hibernate merges the aggregate graph and handles child differences, orphan removal and optimistic versioning. */
+@Singleton
+public class SchoolWriter {
+ private final EntityManager entities;
+
+ @Inject
+ public SchoolWriter(final EntityManager entities) {
+ this.entities = entities;
+ }
+
+ public void checkRevision(final long schoolId, final long expected) {
+ final SchoolEntity school = entities.find(SchoolEntity.class, schoolId, LockModeType.OPTIMISTIC);
+ if (school == null || school.getRevision() != expected) {
+ throw new RevisionConflictException();
+ }
+ }
+
+ public void save(final DanceSchool school) {
+ final SchoolEntity existing = entities.find(SchoolEntity.class, school.getId());
+ final SchoolEntity graph = graph(school);
+ if (existing == null) {
+ entities.persist(graph);
+ } else {
+ removeDeletedRuns(school);
+ graph.setRevision(existing.getRevision());
+ graph.setChangeStamp(existing.getChangeStamp() + 1);
+ entities.merge(graph);
+ }
+ entities.flush();
+ }
+
+ private SchoolEntity graph(final DanceSchool school) {
+ final SchoolEntity graph = new SchoolEntity();
+ graph.setId(school.getId());
+ graph.setKey(school.getKey());
+ graph.setName(school.getName());
+ school.getDancers()
+ .forEach((final Dancer dancer) -> graph.getDancers().add(PeopleMapping.dancer(graph, dancer)));
+ school.getCoaches().forEach((final Coach coach) -> graph.getCoaches().add(PeopleMapping.coach(graph, coach)));
+ school.getDanceGroups()
+ .forEach((final DanceGroup group) -> graph.getGroups().add(GroupMapping.group(graph, group)));
+ school.getShows().forEach((final Show show) -> graph.getShows().add(ShowMapping.show(graph, show)));
+ return graph;
+ }
+
+ private void removeDeletedRuns(final DanceSchool school) {
+ final List shows = school.getShows().stream().map(Show::getId).toList();
+ entities
+ .createQuery("from StoredRun run where run.id.schoolId=:school", RunEntity.class)
+ .setParameter("school", school.getId())
+ .getResultList()
+ .stream()
+ .filter((final RunEntity run) -> !shows.contains(run.getId().getLocalId()))
+ .forEach(entities::remove);
+ }
+
+ public void delete(final long schoolId) {
+ entities.createQuery("from StoredRun run where run.id.schoolId=:school", RunEntity.class)
+ .setParameter("school", schoolId)
+ .getResultList()
+ .forEach(entities::remove);
+ final SchoolEntity school = entities.find(SchoolEntity.class, schoolId);
+ if (school != null) {
+ entities.remove(school);
+ }
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/ShowMapping.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/ShowMapping.java
new file mode 100644
index 0000000..a49d264
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/ShowMapping.java
@@ -0,0 +1,59 @@
+package nl.agilecc.agiledancers.repository;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import nl.agilecc.agiledancers.model.DanceGroup;
+import nl.agilecc.agiledancers.model.DayPart;
+import nl.agilecc.agiledancers.model.Show;
+import nl.agilecc.agiledancers.repository.entity.DayPartEntity;
+import nl.agilecc.agiledancers.repository.entity.DayPartId;
+import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
+import nl.agilecc.agiledancers.repository.entity.SchoolMemberId;
+import nl.agilecc.agiledancers.repository.entity.ShowEntity;
+
+public class ShowMapping {
+ public static Show show(final ShowEntity entity, final Map groups) {
+ final Show show = Show.builder()
+ .name(entity.getName())
+ .preferredDayParts(entity.getPreferredDayParts())
+ .finalized(entity.isFinalized())
+ .appearances(Map.copyOf(entity.getAppearances()))
+ .dayParts(entity.getDayParts().stream()
+ .map((final DayPartEntity part) -> DayPart.builder()
+ .date(part.getDate())
+ .type(part.getType())
+ .danceGroups(part.getGroupIds().stream()
+ .map(groups::get)
+ .toList())
+ .build())
+ .toList())
+ .build();
+ show.setId(entity.getId().getLocalId());
+ return show;
+ }
+
+ public static ShowEntity show(final SchoolEntity school, final Show show) {
+ final ShowEntity entity = new ShowEntity();
+ entity.setId(new SchoolMemberId(school.getId(), show.getId()));
+ entity.setSchool(school);
+ entity.setName(show.getName());
+ entity.setPreferredDayParts(show.getPreferredDayParts());
+ entity.setFinalized(show.isFinalized());
+ entity.setAppearances(new LinkedHashMap<>(show.getAppearances()));
+ for (int index = 0; index < show.getDayParts().size(); index++) {
+ entity.getDayParts().add(part(entity, index, show.getDayParts().get(index)));
+ }
+ return entity;
+ }
+
+ private static DayPartEntity part(final ShowEntity show, final int position, final DayPart part) {
+ final DayPartEntity entity = new DayPartEntity();
+ entity.setId(new DayPartId(show.getId().getSchoolId(), show.getId().getLocalId(), position));
+ entity.setShow(show);
+ entity.setDate(part.getDate());
+ entity.setType(part.getType());
+ part.getDanceGroups()
+ .forEach((final DanceGroup group) -> entity.getGroupIds().add(group.getId()));
+ return entity;
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/StorageException.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/StorageException.java
new file mode 100644
index 0000000..f962ed6
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/StorageException.java
@@ -0,0 +1,8 @@
+package nl.agilecc.agiledancers.repository;
+
+/** Storage failures never authorize an in-memory fallback. */
+public class StorageException extends RuntimeException {
+ public StorageException(final Exception cause) {
+ super("Database niet beschikbaar. De wijziging is niet opgeslagen; herlaad en probeer opnieuw.", cause);
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/CoachEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/CoachEntity.java
new file mode 100644
index 0000000..a3d7406
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/CoachEntity.java
@@ -0,0 +1,33 @@
+package nl.agilecc.agiledancers.repository.entity;
+
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Entity(name = "StoredCoach")
+@Table(name = "coach")
+public class CoachEntity extends SchoolMemberEntity {
+ @Column(nullable = false)
+ private String name;
+
+ // The shared school key is read from the owning row; only the linked member ID is writable.
+ @ManyToOne(
+ fetch = FetchType.LAZY,
+ cascade = {CascadeType.MERGE, CascadeType.PERSIST})
+ @org.hibernate.annotations.JoinColumnsOrFormulas({
+ @org.hibernate.annotations.JoinColumnOrFormula(
+ column = @JoinColumn(name = "dancer_id", referencedColumnName = "id")),
+ @org.hibernate.annotations.JoinColumnOrFormula(
+ formula =
+ @org.hibernate.annotations.JoinFormula(value = "school_id", referencedColumnName = "school_id"))
+ })
+ private DancerEntity dancer;
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DancerEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DancerEntity.java
new file mode 100644
index 0000000..1ad35ca
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DancerEntity.java
@@ -0,0 +1,20 @@
+package nl.agilecc.agiledancers.repository.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import java.time.LocalDate;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Entity(name = "StoredDancer")
+@Table(name = "dancer")
+public class DancerEntity extends SchoolMemberEntity {
+ @Column(nullable = false)
+ private String name;
+
+ @Column(name = "birth_date", nullable = false)
+ private LocalDate birthDate;
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DayPartEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DayPartEntity.java
new file mode 100644
index 0000000..f909208
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DayPartEntity.java
@@ -0,0 +1,56 @@
+package nl.agilecc.agiledancers.repository.entity;
+
+import jakarta.persistence.CollectionTable;
+import jakarta.persistence.Column;
+import jakarta.persistence.ElementCollection;
+import jakarta.persistence.EmbeddedId;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.JoinColumns;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OrderColumn;
+import jakarta.persistence.Table;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+import nl.agilecc.agiledancers.model.specifications.DayPartType;
+
+@Getter
+@Setter
+@Entity(name = "StoredDayPart")
+@Table(name = "day_part")
+public class DayPartEntity {
+ @EmbeddedId
+ private DayPartId id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumns({
+ @JoinColumn(name = "school_id", referencedColumnName = "school_id", insertable = false, updatable = false),
+ @JoinColumn(name = "show_id", referencedColumnName = "id", insertable = false, updatable = false)
+ })
+ private ShowEntity show;
+
+ @Column(name = "day", nullable = false)
+ private LocalDate date;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "part_type", nullable = false)
+ private DayPartType type;
+
+ @ElementCollection
+ @CollectionTable(
+ name = "assignment",
+ joinColumns = {
+ @JoinColumn(name = "school_id", referencedColumnName = "school_id"),
+ @JoinColumn(name = "show_id", referencedColumnName = "show_id"),
+ @JoinColumn(name = "part_position", referencedColumnName = "position")
+ })
+ @OrderColumn(name = "position")
+ @Column(name = "group_id", nullable = false)
+ private List groupIds = new ArrayList<>();
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DayPartId.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DayPartId.java
new file mode 100644
index 0000000..d394e26
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/DayPartId.java
@@ -0,0 +1,26 @@
+package nl.agilecc.agiledancers.repository.entity;
+
+import jakarta.persistence.Embeddable;
+import java.io.Serializable;
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Embeddable
+@AllArgsConstructor
+@NoArgsConstructor
+@EqualsAndHashCode
+public class DayPartId implements Serializable {
+ @jakarta.persistence.Column(name = "school_id")
+ private long schoolId;
+
+ @jakarta.persistence.Column(name = "show_id")
+ private long showId;
+
+ @jakarta.persistence.Column(name = "position")
+ private int position;
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/EntityIdentifiers.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/EntityIdentifiers.java
new file mode 100644
index 0000000..bf3af64
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/EntityIdentifiers.java
@@ -0,0 +1,24 @@
+package nl.agilecc.agiledancers.repository.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Entity(name = "EntityIdentifiers")
+@Table(name = "entity_identifiers")
+public class EntityIdentifiers {
+ @Id
+ private int id;
+
+ @Column(name = "next_value", nullable = false)
+ private long nextValue;
+
+ public long claim() {
+ return nextValue++;
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/GroupEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/GroupEntity.java
new file mode 100644
index 0000000..f2a99df
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/GroupEntity.java
@@ -0,0 +1,72 @@
+package nl.agilecc.agiledancers.repository.entity;
+
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.CollectionTable;
+import jakarta.persistence.Column;
+import jakarta.persistence.ElementCollection;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import lombok.Getter;
+import lombok.Setter;
+import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
+import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
+
+@Getter
+@Setter
+@Entity(name = "StoredGroup")
+@Table(name = "dance_group")
+public class GroupEntity extends SchoolMemberEntity {
+ @Column(nullable = false)
+ private String category;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "group_type", nullable = false)
+ private DanceGroupType type;
+
+ @Column(name = "start_age", nullable = false)
+ private int startAge;
+
+ @Column(name = "end_age", nullable = false)
+ private int endAge;
+
+ // The shared school key is read from the owning row; only the linked member ID is writable.
+ @ManyToOne(
+ fetch = FetchType.LAZY,
+ cascade = {CascadeType.MERGE, CascadeType.PERSIST})
+ @org.hibernate.annotations.JoinColumnsOrFormulas({
+ @org.hibernate.annotations.JoinColumnOrFormula(
+ column = @JoinColumn(name = "coach_id", referencedColumnName = "id")),
+ @org.hibernate.annotations.JoinColumnOrFormula(
+ formula =
+ @org.hibernate.annotations.JoinFormula(value = "school_id", referencedColumnName = "school_id"))
+ })
+ private CoachEntity coach;
+
+ @ElementCollection
+ @CollectionTable(
+ name = "group_style",
+ joinColumns = {
+ @JoinColumn(name = "school_id", referencedColumnName = "school_id"),
+ @JoinColumn(name = "group_id", referencedColumnName = "id")
+ })
+ @Enumerated(EnumType.STRING)
+ @Column(name = "style", nullable = false)
+ private Set styles = new LinkedHashSet<>();
+
+ @ElementCollection
+ @CollectionTable(
+ name = "group_dancer",
+ joinColumns = {
+ @JoinColumn(name = "school_id", referencedColumnName = "school_id"),
+ @JoinColumn(name = "group_id", referencedColumnName = "id")
+ })
+ @Column(name = "dancer_id", nullable = false)
+ private Set dancerIds = new LinkedHashSet<>();
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/RunEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/RunEntity.java
new file mode 100644
index 0000000..f8485c7
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/RunEntity.java
@@ -0,0 +1,59 @@
+package nl.agilecc.agiledancers.repository.entity;
+
+import jakarta.persistence.AttributeOverride;
+import jakarta.persistence.Column;
+import jakarta.persistence.EmbeddedId;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.Table;
+import java.time.Instant;
+import java.util.UUID;
+import lombok.Getter;
+import lombok.Setter;
+import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
+import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
+
+@Getter
+@Setter
+@Entity(name = "StoredRun")
+@Table(name = "scheduling_run")
+public class RunEntity {
+ @EmbeddedId
+ @AttributeOverride(name = "localId", column = @Column(name = "show_id"))
+ private SchoolMemberId id;
+
+ @Column(name = "run_id", nullable = false)
+ private UUID runId;
+
+ @Column(name = "show_name", nullable = false)
+ private String showName;
+
+ @Column(name = "started_at", nullable = false)
+ private Instant startedAt;
+
+ @Column(name = "finished_at")
+ private Instant finishedAt;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private SchedulingState state;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private SchedulingPhase phase;
+
+ private boolean feasible;
+
+ @Column(nullable = false)
+ private String message;
+
+ @Column(name = "attendance_excess")
+ private Long attendanceExcess;
+
+ @Column(name = "performance_spread")
+ private Integer performanceSpread;
+
+ @Column(name = "variety_penalty")
+ private Long varietyPenalty;
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolEntity.java
new file mode 100644
index 0000000..d5dd760
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolEntity.java
@@ -0,0 +1,52 @@
+package nl.agilecc.agiledancers.repository.entity;
+
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.OrderBy;
+import jakarta.persistence.Table;
+import jakarta.persistence.Version;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Entity(name = "StoredSchool")
+@Table(name = "school")
+public class SchoolEntity {
+ @Id
+ private long id;
+
+ @Column(name = "school_key", nullable = false)
+ private UUID key;
+
+ @Column(nullable = false)
+ private String name;
+
+ @Version
+ private long revision;
+ // Marks child-only changes so Hibernate advances the aggregate version exactly once.
+ @Column(name = "change_stamp", nullable = false)
+ private long changeStamp;
+
+ @OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
+ @OrderBy("id.localId")
+ private List dancers = new ArrayList<>();
+
+ @OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
+ @OrderBy("id.localId")
+ private List coaches = new ArrayList<>();
+
+ @OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
+ @OrderBy("id.localId")
+ private List groups = new ArrayList<>();
+
+ @OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
+ @OrderBy("id.localId")
+ private List shows = new ArrayList<>();
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolMemberEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolMemberEntity.java
new file mode 100644
index 0000000..79aa4c8
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolMemberEntity.java
@@ -0,0 +1,21 @@
+package nl.agilecc.agiledancers.repository.entity;
+
+import jakarta.persistence.EmbeddedId;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.MappedSuperclass;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@MappedSuperclass
+public abstract class SchoolMemberEntity {
+ @EmbeddedId
+ private SchoolMemberId id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "school_id", insertable = false, updatable = false)
+ private SchoolEntity school;
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolMemberId.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolMemberId.java
new file mode 100644
index 0000000..bc0a9f1
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/SchoolMemberId.java
@@ -0,0 +1,23 @@
+package nl.agilecc.agiledancers.repository.entity;
+
+import jakarta.persistence.Embeddable;
+import java.io.Serializable;
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Embeddable
+@AllArgsConstructor
+@NoArgsConstructor
+@EqualsAndHashCode
+public class SchoolMemberId implements Serializable {
+ @jakarta.persistence.Column(name = "school_id")
+ private long schoolId;
+
+ @jakarta.persistence.Column(name = "id")
+ private long localId;
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/ShowEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/ShowEntity.java
new file mode 100644
index 0000000..fbf6ef5
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/entity/ShowEntity.java
@@ -0,0 +1,51 @@
+package nl.agilecc.agiledancers.repository.entity;
+
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.CollectionTable;
+import jakarta.persistence.Column;
+import jakarta.persistence.ElementCollection;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.MapKeyColumn;
+import jakarta.persistence.MapKeyEnumerated;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.OrderBy;
+import jakarta.persistence.Table;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.Getter;
+import lombok.Setter;
+import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
+
+@Getter
+@Setter
+@Entity(name = "StoredShow")
+@Table(name = "show")
+public class ShowEntity extends SchoolMemberEntity {
+ @Column(nullable = false)
+ private String name;
+
+ @Column(name = "preferred_day_parts", nullable = false)
+ private int preferredDayParts;
+
+ private boolean finalized;
+
+ @ElementCollection
+ @CollectionTable(
+ name = "show_appearance",
+ joinColumns = {
+ @JoinColumn(name = "school_id", referencedColumnName = "school_id"),
+ @JoinColumn(name = "show_id", referencedColumnName = "id")
+ })
+ @MapKeyEnumerated(EnumType.STRING)
+ @MapKeyColumn(name = "group_type")
+ @Column(name = "appearances", nullable = false)
+ private Map appearances = new LinkedHashMap<>();
+
+ @OneToMany(mappedBy = "show", cascade = CascadeType.ALL, orphanRemoval = true)
+ @OrderBy("id.position")
+ private List dayParts = new ArrayList<>();
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/CoachResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/CoachResource.java
index 82fddae..502b7e3 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/CoachResource.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/CoachResource.java
@@ -15,29 +15,33 @@ import jakarta.ws.rs.core.MediaType;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.CoachRequest;
import nl.agilecc.agiledancers.rest.apimodel.CoachView;
+import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.CoachLinks;
+@SchoolTransactionBoundary
@Path("/api/dance-schools/{schoolId}/coaches")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CoachResource {
+ private @Inject DomainViewMapper views;
+
private @Inject CoachLinks links;
private @PathParam("schoolId") long schoolId;
@GET
public List list() {
- return links.list(schoolId).stream().map(CoachView::of).toList();
+ return links.list(schoolId).stream().map(views::coach).toList();
}
@POST
public CoachView create(final @NotNull @Valid CoachRequest request) {
- return CoachView.of(links.link(schoolId, null, request.dancerId()));
+ return views.coach(links.link(schoolId, null, request.dancerId()));
}
@PUT
@Path("/{id}")
public CoachView link(final @PathParam("id") long id, final @NotNull @Valid CoachRequest request) {
- return CoachView.of(links.link(schoolId, id, request.dancerId()));
+ return views.coach(links.link(schoolId, id, request.dancerId()));
}
@DELETE
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceGroupResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceGroupResource.java
index c79f423..3b985be 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceGroupResource.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceGroupResource.java
@@ -19,26 +19,30 @@ import java.util.List;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
+import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.model.GroupDetails;
+@SchoolTransactionBoundary
@Path("/api/dance-schools/{schoolId}/dance-groups")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DanceGroupResource {
+ private @Inject DomainViewMapper views;
+
private @Inject SchoolManagementService service;
private @PathParam("schoolId") long schoolId;
@GET
public List list() {
- return service.groups(schoolId).stream().map(GroupView::of).toList();
+ return service.groups(schoolId).stream().map(views::group).toList();
}
@GET
@Path("/{id}")
public GroupView get(final @PathParam("id") long id) {
- return GroupView.of(service.group(schoolId, id));
+ return views.group(service.group(schoolId, id));
}
@POST
@@ -50,14 +54,14 @@ public class DanceGroupResource {
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
- .entity(GroupView.of(entity))
+ .entity(views.group(entity))
.build();
}
@PUT
@Path("/{id}")
public GroupView update(final @PathParam("id") long id, final @NotNull @Valid GroupRequest request) {
- return GroupView.of(service.updateGroup(
+ return views.group(service.updateGroup(
schoolId,
id,
new GroupDetails(
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceSchoolResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceSchoolResource.java
index b69fea4..a55a505 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceSchoolResource.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceSchoolResource.java
@@ -19,23 +19,44 @@ import java.util.List;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
+import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.SchoolManagementService;
+@SchoolTransactionBoundary
@Path("/api/dance-schools")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DanceSchoolResource {
+ private @Inject DomainViewMapper views;
+
private @Inject SchoolManagementService service;
+ private @Inject nl.agilecc.agiledancers.service.ShowSchedules schedules;
+
+ @GET
+ @Path("/{id}/data")
+ public nl.agilecc.agiledancers.rest.apimodel.SchoolDataView data(final @PathParam("id") long id) {
+ final DanceSchool school = service.school(id);
+ return new nl.agilecc.agiledancers.rest.apimodel.SchoolDataView(
+ views.school(school),
+ school.getDancers().stream().map(views::dancer).toList(),
+ school.getDanceGroups().stream().map(views::group).toList(),
+ school.getShows().stream()
+ .map((final nl.agilecc.agiledancers.model.Show show) ->
+ views.show(show, schedules.violations(id, show.getId())))
+ .toList(),
+ school.getCoaches().stream().map(views::coach).toList());
+ }
+
@GET
public List list() {
- return service.schools().stream().map(SchoolView::of).toList();
+ return service.schools().stream().map(views::school).toList();
}
@GET
@Path("/{id}")
public SchoolView get(final @PathParam("id") long id) {
- return SchoolView.of(service.school(id));
+ return views.school(service.school(id));
}
@POST
@@ -44,14 +65,14 @@ public class DanceSchoolResource {
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
- .entity(SchoolView.of(entity))
+ .entity(views.school(entity))
.build();
}
@PUT
@Path("/{id}")
public SchoolView update(final @PathParam("id") long id, final @NotNull @Valid SchoolRequest request) {
- return SchoolView.of(service.updateSchool(id, request.name()));
+ return views.school(service.updateSchool(id, request.name()));
}
@DELETE
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DancerResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DancerResource.java
index 9d34588..4bbb895 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DancerResource.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DancerResource.java
@@ -19,25 +19,29 @@ import java.util.List;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
+import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.SchoolManagementService;
+@SchoolTransactionBoundary
@Path("/api/dance-schools/{schoolId}/dancers")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DancerResource {
+ private @Inject DomainViewMapper views;
+
private @Inject SchoolManagementService service;
private @PathParam("schoolId") long schoolId;
@GET
public List list() {
- return service.dancers(schoolId).stream().map(DancerView::of).toList();
+ return service.dancers(schoolId).stream().map(views::dancer).toList();
}
@GET
@Path("/{id}")
public DancerView get(final @PathParam("id") long id) {
- return DancerView.of(service.dancer(schoolId, id));
+ return views.dancer(service.dancer(schoolId, id));
}
@POST
@@ -46,14 +50,14 @@ public class DancerResource {
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
- .entity(DancerView.of(entity))
+ .entity(views.dancer(entity))
.build();
}
@PUT
@Path("/{id}")
public DancerView update(final @PathParam("id") long id, final @NotNull @Valid DancerRequest request) {
- return DancerView.of(service.updateDancer(schoolId, id, request.name(), request.birthDate()));
+ return views.dancer(service.updateDancer(schoolId, id, request.name(), request.birthDate()));
}
@DELETE
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ManagementExceptionMapper.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ManagementExceptionMapper.java
index 89183ec..57e37c5 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ManagementExceptionMapper.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ManagementExceptionMapper.java
@@ -13,6 +13,7 @@ public class ManagementExceptionMapper implements ExceptionMapper 404;
+ case STORAGE_UNAVAILABLE -> 503;
case INVALID_INPUT -> 400;
case CONFLICT -> 409;
};
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchedulingEventsResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchedulingEventsResource.java
index 7f90dfe..5357cd6 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchedulingEventsResource.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchedulingEventsResource.java
@@ -9,9 +9,9 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.Duration;
import java.util.List;
+import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.rest.apimodel.SchedulingEventView;
import nl.agilecc.agiledancers.service.SchedulingRuns;
-import nl.agilecc.agiledancers.service.model.SchedulingRun;
import org.jboss.resteasy.reactive.RestStreamElementType;
@Path("/api/scheduling/events")
@@ -36,6 +36,9 @@ public class SchedulingEventsResource {
.every(Duration.ofSeconds(15))
.onItem()
.transform((final Long tick) -> new SchedulingEventView(null));
- return Multi.createBy().merging().streams(updates, heartbeat);
+ return Multi.createBy()
+ .merging()
+ .streams(updates, heartbeat)
+ .runSubscriptionOn(io.smallrye.mutiny.infrastructure.Infrastructure.getDefaultWorkerPool());
}
}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolRequestInterceptor.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolRequestInterceptor.java
new file mode 100644
index 0000000..0aa77bf
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolRequestInterceptor.java
@@ -0,0 +1,50 @@
+package nl.agilecc.agiledancers.rest;
+
+import io.vertx.ext.web.RoutingContext;
+import jakarta.annotation.Priority;
+import jakarta.inject.Inject;
+import jakarta.interceptor.AroundInvoke;
+import jakarta.interceptor.Interceptor;
+import jakarta.interceptor.InvocationContext;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import nl.agilecc.agiledancers.service.ManagementException;
+import nl.agilecc.agiledancers.service.ManagementExceptionKind;
+import nl.agilecc.agiledancers.service.SchoolTransactions;
+import nl.agilecc.agiledancers.service.model.SchoolChange;
+
+@SchoolTransactionBoundary
+@Interceptor
+@Priority(Interceptor.Priority.APPLICATION)
+public class SchoolRequestInterceptor {
+ private static final Pattern SCHOOL_PATH = Pattern.compile("/api/dance-schools/(\\d+)(?:/|$)");
+ private @Inject RoutingContext context;
+ private @Inject SchoolTransactions transactions;
+
+ @AroundInvoke
+ public Object invoke(final InvocationContext invocation) throws Exception {
+ final String path = context.request().path();
+ final String method = context.request().method().name();
+ final boolean mutation = !method.equals("GET") && !path.endsWith("/preview");
+ final Matcher matcher = SCHOOL_PATH.matcher(path);
+ final Long schoolId = matcher.find()
+ ? Long.valueOf(matcher.group(1))
+ : number(context.request().getParam("replaceId"));
+ final Long revision = mutation ? number(context.request().getHeader("X-School-Revision")) : null;
+ return transactions.request(
+ new SchoolChange(schoolId, revision, mutation, mutation && !path.endsWith("/schedule")),
+ invocation::proceed);
+ }
+
+ private static Long number(final String value) {
+ if (value == null) {
+ return null;
+ }
+ try {
+ return Long.valueOf(value);
+ } catch (final NumberFormatException failure) {
+ throw new ManagementException(
+ ManagementExceptionKind.INVALID_INPUT, "Ongeldige schoolversie of school-ID.");
+ }
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolSnapshotResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolSnapshotResource.java
index 7416e6b..be58524 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolSnapshotResource.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolSnapshotResource.java
@@ -15,15 +15,19 @@ import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
+import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.model.ImportPreview;
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
+@SchoolTransactionBoundary
@Path("/api/dance-schools")
@Produces(MediaType.APPLICATION_JSON)
public class SchoolSnapshotResource {
+ private @Inject DomainViewMapper views;
+
private @Inject SchoolManagementService service;
private @Inject ObjectMapper mapper;
@@ -48,7 +52,7 @@ public class SchoolSnapshotResource {
@Path("/import")
@Consumes(MediaType.APPLICATION_JSON)
public SchoolView apply(final String json, final @QueryParam("replaceId") Long replaceId) {
- return SchoolView.of(service.importSchool(parse(json), replaceId));
+ return views.school(service.importSchool(parse(json), replaceId));
}
private SchoolSnapshot parse(final String json) {
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolTransactionBoundary.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolTransactionBoundary.java
new file mode 100644
index 0000000..a6448ba
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolTransactionBoundary.java
@@ -0,0 +1,13 @@
+package nl.agilecc.agiledancers.rest;
+
+import jakarta.interceptor.InterceptorBinding;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Bounds school HTTP operations to one durable transaction and optimistic revision check. */
+@InterceptorBinding
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE, ElementType.METHOD})
+public @interface SchoolTransactionBoundary {}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java
index 3f6bf57..eb9d186 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java
@@ -18,19 +18,23 @@ import jakarta.ws.rs.core.UriInfo;
import java.util.List;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
+import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest;
import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
+import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.SchedulingRuns;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.ShowSchedules;
-import nl.agilecc.agiledancers.service.model.SchedulingRun;
+@SchoolTransactionBoundary
@Path("/api/dance-schools/{schoolId}/shows")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ShowResource {
+ private @Inject DomainViewMapper views;
+
private @Inject SchoolManagementService service;
private @Inject SchedulingRuns scheduling;
@@ -67,7 +71,7 @@ public class ShowResource {
}
private ShowView view(final Show show) {
- return ShowView.of(show, schedules.violations(schoolId, show.getId()));
+ return views.show(show, schedules.violations(schoolId, show.getId()));
}
private static Show settings(final ShowRequest request) {
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/CoachView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/CoachView.java
index 8eb94af..cf49796 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/CoachView.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/CoachView.java
@@ -1,12 +1,3 @@
package nl.agilecc.agiledancers.rest.apimodel;
-import nl.agilecc.agiledancers.model.Coach;
-
-public record CoachView(long id, String name, Long dancerId) {
- public static CoachView of(final Coach coach) {
- return new CoachView(
- coach.getId(),
- coach.getName(),
- coach.getDancer() == null ? null : coach.getDancer().getId());
- }
-}
+public record CoachView(long id, String name, Long dancerId) {}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerView.java
index 0631a37..3acd525 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerView.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerView.java
@@ -1,10 +1,5 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.time.LocalDate;
-import nl.agilecc.agiledancers.model.Dancer;
-public record DancerView(long id, String name, LocalDate birthDate) {
- public static DancerView of(final Dancer dancer) {
- return new DancerView(dancer.getId(), dancer.getName(), dancer.getBirthDate());
- }
-}
+public record DancerView(long id, String name, LocalDate birthDate) {}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupView.java
index bf5f84d..29ff36c 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupView.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupView.java
@@ -5,8 +5,6 @@ import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
-import nl.agilecc.agiledancers.model.DanceGroup;
-import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@@ -22,15 +20,4 @@ public record GroupView(
styles = styles == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(styles));
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
}
-
- public static GroupView of(final DanceGroup group) {
- return new GroupView(
- group.getId(),
- group.getCategory(),
- group.getType(),
- group.getStartAge(),
- group.getEndAge(),
- Set.copyOf(group.getStyles()),
- group.getDancers().stream().map(Dancer::getId).toList());
- }
}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingEventView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingEventView.java
index f9f1ed4..b791fec 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingEventView.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingEventView.java
@@ -1,7 +1,7 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.util.List;
-import nl.agilecc.agiledancers.service.model.SchedulingRun;
+import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
/** A null runs field is a heartbeat, not a replacement of the current snapshot. */
public record SchedulingEventView(List runs) {
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolDataView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolDataView.java
new file mode 100644
index 0000000..be5a33c
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolDataView.java
@@ -0,0 +1,18 @@
+package nl.agilecc.agiledancers.rest.apimodel;
+
+import java.util.List;
+
+/** One school and all editor data from the same revision and database snapshot. */
+public record SchoolDataView(
+ SchoolView school,
+ List dancers,
+ List groups,
+ List shows,
+ List coaches) {
+ public SchoolDataView {
+ dancers = List.copyOf(dancers);
+ groups = List.copyOf(groups);
+ shows = List.copyOf(shows);
+ coaches = List.copyOf(coaches);
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolView.java
index 1f1b5ea..7d2a498 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolView.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolView.java
@@ -3,24 +3,12 @@ package nl.agilecc.agiledancers.rest.apimodel;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
-import nl.agilecc.agiledancers.model.DanceGroup;
-import nl.agilecc.agiledancers.model.DanceSchool;
-import nl.agilecc.agiledancers.model.Dancer;
-import nl.agilecc.agiledancers.model.Show;
-public record SchoolView(long id, String name, List dancerIds, List danceGroupIds, List showIds) {
+public record SchoolView(
+ long id, long revision, String name, List dancerIds, List danceGroupIds, List showIds) {
public SchoolView {
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
danceGroupIds = danceGroupIds == null ? null : Collections.unmodifiableList(new ArrayList<>(danceGroupIds));
showIds = showIds == null ? null : Collections.unmodifiableList(new ArrayList<>(showIds));
}
-
- public static SchoolView of(final DanceSchool school) {
- return new SchoolView(
- school.getId(),
- school.getName(),
- school.getDancers().stream().map(Dancer::getId).toList(),
- school.getDanceGroups().stream().map(DanceGroup::getId).toList(),
- school.getShows().stream().map(Show::getId).toList());
- }
}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java
index 3fc54a3..68d45de 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java
@@ -5,9 +5,6 @@ import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
-import nl.agilecc.agiledancers.model.DanceGroup;
-import nl.agilecc.agiledancers.model.DayPart;
-import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record ShowView(
@@ -23,26 +20,4 @@ public record ShowView(
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
}
-
- public static ShowView of(final Show show) {
- return of(show, List.of());
- }
-
- public static ShowView of(final Show show, final List violations) {
- return new ShowView(
- show.getId(),
- show.getName(),
- show.getDayParts().stream()
- .map((final DayPart p) -> new DayPartView(
- p.getDate(),
- p.getType(),
- p.getDanceGroups().stream()
- .map(DanceGroup::getId)
- .toList()))
- .toList(),
- show.getAppearances(),
- nl.agilecc.agiledancers.service.SchedulingInputs.preferred(show),
- show.isFinalized(),
- violations);
- }
}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/mapping/DomainViewMapper.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/mapping/DomainViewMapper.java
new file mode 100644
index 0000000..f34a74e
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/mapping/DomainViewMapper.java
@@ -0,0 +1,59 @@
+package nl.agilecc.agiledancers.rest.mapping;
+
+import java.util.List;
+import nl.agilecc.agiledancers.model.AbstractEntity;
+import nl.agilecc.agiledancers.model.Coach;
+import nl.agilecc.agiledancers.model.DanceGroup;
+import nl.agilecc.agiledancers.model.DanceSchool;
+import nl.agilecc.agiledancers.model.Dancer;
+import nl.agilecc.agiledancers.model.DayPart;
+import nl.agilecc.agiledancers.model.Show;
+import nl.agilecc.agiledancers.rest.apimodel.CoachView;
+import nl.agilecc.agiledancers.rest.apimodel.DancerView;
+import nl.agilecc.agiledancers.rest.apimodel.DayPartView;
+import nl.agilecc.agiledancers.rest.apimodel.GroupView;
+import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
+import nl.agilecc.agiledancers.rest.apimodel.ShowView;
+import nl.agilecc.agiledancers.service.SchedulingInputs;
+import org.mapstruct.AnnotateWith;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.MappingConstants;
+import org.mapstruct.Named;
+import org.mapstruct.ReportingPolicy;
+
+/** Generates API projections only; domain graph identity and validation remain application-owned. */
+// Mark generated implementations so coverage and architecture checks retain their handwritten-code scope.
+@AnnotateWith(lombok.Generated.class)
+@Mapper(componentModel = MappingConstants.ComponentModel.JAKARTA_CDI, unmappedTargetPolicy = ReportingPolicy.ERROR)
+public interface DomainViewMapper {
+ @Mapping(target = "dancerIds", source = "dancers")
+ @Mapping(target = "danceGroupIds", source = "danceGroups")
+ @Mapping(target = "showIds", source = "shows")
+ public SchoolView school(final DanceSchool school);
+
+ public DancerView dancer(final Dancer dancer);
+
+ @Mapping(target = "dancerIds", source = "dancers")
+ public GroupView group(final DanceGroup group);
+
+ @Mapping(target = "dancerId", source = "dancer")
+ public CoachView coach(final Coach coach);
+
+ @Mapping(target = "danceGroupIds", source = "danceGroups")
+ public DayPartView dayPart(final DayPart part);
+
+ /** Violations are calculated by the use case, never inferred by the mapper. */
+ @Mapping(target = "violations", source = "violations")
+ @Mapping(target = "preferredDayParts", source = "show", qualifiedByName = "preferredDayParts")
+ public ShowView show(final Show show, final List violations);
+
+ public default Long id(final AbstractEntity entity) {
+ return entity == null ? null : entity.getId();
+ }
+
+ @Named("preferredDayParts")
+ public default int preferredDayParts(final Show show) {
+ return SchedulingInputs.preferred(show);
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementExceptionKind.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementExceptionKind.java
index 1e3a6ea..a98ea3b 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementExceptionKind.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementExceptionKind.java
@@ -2,6 +2,7 @@ package nl.agilecc.agiledancers.service;
public enum ManagementExceptionKind {
NOT_FOUND,
+ STORAGE_UNAVAILABLE,
INVALID_INPUT,
CONFLICT
}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingPersistence.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingPersistence.java
new file mode 100644
index 0000000..71d51dd
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingPersistence.java
@@ -0,0 +1,54 @@
+package nl.agilecc.agiledancers.service;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+import java.util.List;
+import java.util.concurrent.Callable;
+import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
+import nl.agilecc.agiledancers.repository.Database;
+import nl.agilecc.agiledancers.repository.RunRepository;
+
+/** Coordinates scheduling status with the same unit of work as domain changes. */
+@Singleton
+public class SchedulingPersistence {
+ private final SchoolTransactions transactions;
+ private final Database database;
+ private final RunRepository runs;
+
+ @Inject
+ public SchedulingPersistence(
+ final SchoolTransactions transactions, final Database database, final RunRepository runs) {
+ this.transactions = transactions;
+ this.database = database;
+ this.runs = runs;
+ }
+
+ public List recover() {
+ return transactions.background(() -> {
+ runs.recover();
+ return runs.load();
+ });
+ }
+
+ public List load() {
+ return transactions.background(runs::load);
+ }
+
+ public T work(final Callable action) {
+ return transactions.background(action);
+ }
+
+ public void save(final SchedulingRun run, final Runnable publication) {
+ runs.save(run);
+ database.afterCommit(publication);
+ }
+
+ public void accepted(final SchedulingRun run, final Runnable launch, final Runnable rollback) {
+ database.afterRollback(rollback);
+ save(run, launch);
+ }
+
+ public void result(final long schoolId) {
+ transactions.saveResult(schoolId);
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingRuns.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingRuns.java
index 2687d0d..0bbb62b 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingRuns.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingRuns.java
@@ -1,23 +1,28 @@
package nl.agilecc.agiledancers.service;
+import io.quarkus.runtime.Startup;
+import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
+import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
+import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
-import nl.agilecc.agiledancers.service.model.SchedulingRun;
-import nl.agilecc.agiledancers.service.model.SchedulingState;
+import nl.agilecc.agiledancers.repository.StorageException;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
-/** Owns background tasks and publishes current state independently of any HTTP connection. */
+/** Owns background tasks; the school monitor orders short commits and publication, never solver computation. */
+@Startup
@Singleton
public class SchedulingRuns {
private final SchoolManagementService schools;
@@ -25,6 +30,7 @@ public class SchedulingRuns {
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
private final Map runs = new HashMap<>();
private final Map