S16_database #15
@@ -3,7 +3,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
mode:
|
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
|
required: true
|
||||||
type: choice
|
type: choice
|
||||||
default: new
|
default: new
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Identify workflow attempt
|
- name: Identify workflow attempt
|
||||||
run: java ci/Ci.java 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:
|
env:
|
||||||
RELEASE_MODE: ${{ inputs.mode }}
|
RELEASE_MODE: ${{ inputs.mode }}
|
||||||
RELEASE_INCREMENT: ${{ inputs.increment }}
|
RELEASE_INCREMENT: ${{ inputs.increment }}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Identify workflow attempt
|
- name: Identify workflow attempt
|
||||||
run: java ci/Ci.java 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:
|
env:
|
||||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ COPY tooling tooling
|
|||||||
COPY ci ci
|
COPY ci ci
|
||||||
COPY agiledancers-be/pom.xml agiledancers-be/pom.xml
|
COPY agiledancers-be/pom.xml agiledancers-be/pom.xml
|
||||||
COPY agiledancers-be/src agiledancers-be/src
|
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
|
FROM eclipse-temurin:25-jre AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
43
README.md
43
README.md
@@ -19,14 +19,19 @@ mvn -pl agiledancers-be quarkus:dev
|
|||||||
Run the packaged application after building:
|
Run the packaged application after building:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
# Set DATABASE_URL, DATABASE_USER and DATABASE_PASSWORD first.
|
||||||
java -jar agiledancers-be/target/quarkus-app/quarkus-run.jar
|
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
|
```sh
|
||||||
docker build -t agiledancers .
|
docker build --network=host \
|
||||||
docker run --rm --name agiledancers -p 8080:8080 agiledancers
|
--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,
|
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
|
Quarkus configuration can be overridden with environment variables, for example
|
||||||
`docker run --rm -e QUARKUS_HTTP_PORT=9090 -p 9090:9090 agiledancers`.
|
`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:
|
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 |
|
| `model` | Domain entities and specifications | Model |
|
||||||
| `service` | Application use cases and future ports | Model, repositories |
|
| `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 |
|
| `rest` | Future REST services | Services, model |
|
||||||
| `solver` | Future OR-Tools calculations | Services (ports), 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
|
production proxy disables buffering. The UI shows phase, elapsed time, solution
|
||||||
discovery and final objective scores, and reports completion in an application-wide
|
discovery and final objective scores, and reports completion in an application-wide
|
||||||
snackbar. Reloading recovers the latest state without replaying old notifications.
|
snackbar. Reloading recovers the latest state without replaying old notifications.
|
||||||
Run state currently shares the application's in-memory lifetime; durable restart
|
The latest run and saved assignments survive restart. Startup marks formerly
|
||||||
recovery requires the PostgreSQL integration specified in S16.
|
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
|
`PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an
|
||||||
ordered list of group IDs per daypart. Incomplete or invalid drafts can be saved;
|
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
|
Production releases and rollback use separate manual Gitea workflows. See the
|
||||||
[deployment runbook](docs/production-deployment.md) for Synology provisioning,
|
[deployment runbook](docs/production-deployment.md) for Synology provisioning,
|
||||||
semantic version tags, release/retry/rollback and the accepted data-loss limitation
|
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)
|
### 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
|
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
|
and merges coverage from Playwright before publication. Reports are retained with
|
||||||
CI diagnostics. See [S7 validation](specs/S7-validation.md) for current migration status.
|
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.
|
||||||
|
|||||||
@@ -14,6 +14,11 @@
|
|||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
<dependency><groupId>org.mapstruct</groupId><artifactId>mapstruct</artifactId><version>${mapstruct.version}</version></dependency>
|
||||||
|
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-hibernate-orm</artifactId></dependency>
|
||||||
|
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-jdbc-postgresql</artifactId></dependency>
|
||||||
|
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-flyway</artifactId></dependency>
|
||||||
|
<dependency><groupId>org.flywaydb</groupId><artifactId>flyway-database-postgresql</artifactId></dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-rest-jackson</artifactId>
|
<artifactId>quarkus-rest-jackson</artifactId>
|
||||||
@@ -63,6 +68,16 @@
|
|||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<annotationProcessorPaths combine.children="append">
|
||||||
|
<path><groupId>org.mapstruct</groupId><artifactId>mapstruct-processor</artifactId><version>${mapstruct.version}</version></path>
|
||||||
|
<path><groupId>org.projectlombok</groupId><artifactId>lombok-mapstruct-binding</artifactId><version>${lombok-mapstruct-binding.version}</version></path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
<compilerArgs><arg>-Amapstruct.suppressGeneratorTimestamp=true</arg></compilerArgs>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.8.1</version>
|
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.8.1</version>
|
||||||
<executions><execution><id>standards-classpath</id><phase>prepare-package</phase><goals><goal>build-classpath</goal></goals>
|
<executions><execution><id>standards-classpath</id><phase>prepare-package</phase><goals><goal>build-classpath</goal></goals>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ public class DanceSchool extends AbstractEntity {
|
|||||||
@Setter
|
@Setter
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Setter
|
||||||
|
private long revision;
|
||||||
|
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private final Set<Dancer> dancers = new LinkedHashSet<>();
|
private final Set<Dancer> dancers = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
package nl.agilecc.agiledancers.service.model;
|
package nl.agilecc.agiledancers.model.scheduling;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
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. */
|
/** Latest immutable state for one show's calculation; runId identifies notifications across reconnects. */
|
||||||
public record SchedulingRun(
|
public record SchedulingRun(
|
||||||
@@ -24,7 +21,7 @@ public record SchedulingRun(
|
|||||||
schoolId,
|
schoolId,
|
||||||
showId,
|
showId,
|
||||||
name,
|
name,
|
||||||
Instant.now(),
|
Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS),
|
||||||
null,
|
null,
|
||||||
SchedulingState.RUNNING,
|
SchedulingState.RUNNING,
|
||||||
new SchedulingProgress(SchedulingPhase.PREPARING, false),
|
new SchedulingProgress(SchedulingPhase.PREPARING, false),
|
||||||
@@ -54,7 +51,7 @@ public record SchedulingRun(
|
|||||||
showId,
|
showId,
|
||||||
showName,
|
showName,
|
||||||
startedAt,
|
startedAt,
|
||||||
Instant.now(),
|
Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS),
|
||||||
outcome,
|
outcome,
|
||||||
new SchedulingProgress(progress.phase(), progress.feasible() || scores != null),
|
new SchedulingProgress(progress.phase(), progress.feasible() || scores != null),
|
||||||
explanation,
|
explanation,
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
package nl.agilecc.agiledancers.service.model;
|
package nl.agilecc.agiledancers.model.scheduling;
|
||||||
|
|
||||||
public enum SchedulingState {
|
public enum SchedulingState {
|
||||||
RUNNING,
|
RUNNING,
|
||||||
|
INTERRUPTED,
|
||||||
OPTIMAL,
|
OPTIMAL,
|
||||||
VALID,
|
VALID,
|
||||||
STALE,
|
STALE,
|
||||||
@@ -7,11 +7,19 @@ import nl.agilecc.agiledancers.model.AbstractEntity;
|
|||||||
|
|
||||||
public abstract class AbstractRepository<T extends AbstractEntity> {
|
public abstract class AbstractRepository<T extends AbstractEntity> {
|
||||||
private long sequence = 0L;
|
private long sequence = 0L;
|
||||||
|
private java.util.function.LongSupplier identifiers;
|
||||||
|
|
||||||
protected final Map<Long, T> entities = new LinkedHashMap<>();
|
protected final Map<Long, T> entities = new LinkedHashMap<>();
|
||||||
|
|
||||||
public long nextId() {
|
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<T> 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) {
|
public T add(final T entity) {
|
||||||
|
|||||||
@@ -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<List<Runnable>> committed = new ThreadLocal<>();
|
||||||
|
private final ThreadLocal<List<Runnable>> 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> T transaction(final Callable<T> 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> T outerTransaction(final Callable<T> work) throws Exception {
|
||||||
|
final List<Runnable> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Long, DanceGroup> groups(
|
||||||
|
final SchoolEntity stored,
|
||||||
|
final DanceSchool school,
|
||||||
|
final Map<Long, Coach> coaches,
|
||||||
|
final Map<Long, Dancer> dancers) {
|
||||||
|
final Map<Long, DanceGroup> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Long, Dancer> dancers(final SchoolEntity stored, final DanceSchool school) {
|
||||||
|
final Map<Long, Dancer> 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<Long, Coach> coaches(
|
||||||
|
final SchoolEntity stored, final DanceSchool school, final Map<Long, Dancer> dancers) {
|
||||||
|
final Map<Long, Coach> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SchedulingRun> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DanceSchool> 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<Long, Dancer> dancers = PeopleMapping.dancers(entity, school);
|
||||||
|
final Map<Long, Coach> coaches = PeopleMapping.coaches(entity, school, dancers);
|
||||||
|
final Map<Long, DanceGroup> groups = GroupMapping.groups(entity, school, coaches, dancers);
|
||||||
|
entity.getShows().forEach((final ShowEntity show) -> school.addShow(ShowMapping.show(show, groups)));
|
||||||
|
return school;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Long> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Long, DanceGroup> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Long> groupIds = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DanceGroupStyle> 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<Long> dancerIds = new LinkedHashSet<>();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<DancerEntity> dancers = new ArrayList<>();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@OrderBy("id.localId")
|
||||||
|
private List<CoachEntity> coaches = new ArrayList<>();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@OrderBy("id.localId")
|
||||||
|
private List<GroupEntity> groups = new ArrayList<>();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@OrderBy("id.localId")
|
||||||
|
private List<ShowEntity> shows = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<DanceGroupType, Integer> appearances = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "show", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@OrderBy("id.position")
|
||||||
|
private List<DayPartEntity> dayParts = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -15,29 +15,33 @@ import jakarta.ws.rs.core.MediaType;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.CoachRequest;
|
import nl.agilecc.agiledancers.rest.apimodel.CoachRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.CoachView;
|
import nl.agilecc.agiledancers.rest.apimodel.CoachView;
|
||||||
|
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
|
||||||
import nl.agilecc.agiledancers.service.CoachLinks;
|
import nl.agilecc.agiledancers.service.CoachLinks;
|
||||||
|
|
||||||
|
@SchoolTransactionBoundary
|
||||||
@Path("/api/dance-schools/{schoolId}/coaches")
|
@Path("/api/dance-schools/{schoolId}/coaches")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
public class CoachResource {
|
public class CoachResource {
|
||||||
|
private @Inject DomainViewMapper views;
|
||||||
|
|
||||||
private @Inject CoachLinks links;
|
private @Inject CoachLinks links;
|
||||||
private @PathParam("schoolId") long schoolId;
|
private @PathParam("schoolId") long schoolId;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
public List<CoachView> list() {
|
public List<CoachView> list() {
|
||||||
return links.list(schoolId).stream().map(CoachView::of).toList();
|
return links.list(schoolId).stream().map(views::coach).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
public CoachView create(final @NotNull @Valid CoachRequest request) {
|
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
|
@PUT
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public CoachView link(final @PathParam("id") long id, final @NotNull @Valid CoachRequest request) {
|
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
|
@DELETE
|
||||||
|
|||||||
@@ -19,26 +19,30 @@ import java.util.List;
|
|||||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
|
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
|
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.SchoolManagementService;
|
||||||
import nl.agilecc.agiledancers.service.model.GroupDetails;
|
import nl.agilecc.agiledancers.service.model.GroupDetails;
|
||||||
|
|
||||||
|
@SchoolTransactionBoundary
|
||||||
@Path("/api/dance-schools/{schoolId}/dance-groups")
|
@Path("/api/dance-schools/{schoolId}/dance-groups")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
public class DanceGroupResource {
|
public class DanceGroupResource {
|
||||||
|
private @Inject DomainViewMapper views;
|
||||||
|
|
||||||
private @Inject SchoolManagementService service;
|
private @Inject SchoolManagementService service;
|
||||||
|
|
||||||
private @PathParam("schoolId") long schoolId;
|
private @PathParam("schoolId") long schoolId;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
public List<GroupView> list() {
|
public List<GroupView> list() {
|
||||||
return service.groups(schoolId).stream().map(GroupView::of).toList();
|
return service.groups(schoolId).stream().map(views::group).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public GroupView get(final @PathParam("id") long id) {
|
public GroupView get(final @PathParam("id") long id) {
|
||||||
return GroupView.of(service.group(schoolId, id));
|
return views.group(service.group(schoolId, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -50,14 +54,14 @@ public class DanceGroupResource {
|
|||||||
return Response.created(uriInfo.getAbsolutePathBuilder()
|
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||||
.path(entity.getId().toString())
|
.path(entity.getId().toString())
|
||||||
.build())
|
.build())
|
||||||
.entity(GroupView.of(entity))
|
.entity(views.group(entity))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public GroupView update(final @PathParam("id") long id, final @NotNull @Valid GroupRequest request) {
|
public GroupView update(final @PathParam("id") long id, final @NotNull @Valid GroupRequest request) {
|
||||||
return GroupView.of(service.updateGroup(
|
return views.group(service.updateGroup(
|
||||||
schoolId,
|
schoolId,
|
||||||
id,
|
id,
|
||||||
new GroupDetails(
|
new GroupDetails(
|
||||||
|
|||||||
@@ -19,23 +19,44 @@ import java.util.List;
|
|||||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest;
|
import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
|
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
|
||||||
|
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
|
||||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||||
|
|
||||||
|
@SchoolTransactionBoundary
|
||||||
@Path("/api/dance-schools")
|
@Path("/api/dance-schools")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
public class DanceSchoolResource {
|
public class DanceSchoolResource {
|
||||||
|
private @Inject DomainViewMapper views;
|
||||||
|
|
||||||
private @Inject SchoolManagementService service;
|
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
|
@GET
|
||||||
public List<SchoolView> list() {
|
public List<SchoolView> list() {
|
||||||
return service.schools().stream().map(SchoolView::of).toList();
|
return service.schools().stream().map(views::school).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public SchoolView get(final @PathParam("id") long id) {
|
public SchoolView get(final @PathParam("id") long id) {
|
||||||
return SchoolView.of(service.school(id));
|
return views.school(service.school(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -44,14 +65,14 @@ public class DanceSchoolResource {
|
|||||||
return Response.created(uriInfo.getAbsolutePathBuilder()
|
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||||
.path(entity.getId().toString())
|
.path(entity.getId().toString())
|
||||||
.build())
|
.build())
|
||||||
.entity(SchoolView.of(entity))
|
.entity(views.school(entity))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public SchoolView update(final @PathParam("id") long id, final @NotNull @Valid SchoolRequest request) {
|
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
|
@DELETE
|
||||||
|
|||||||
@@ -19,25 +19,29 @@ import java.util.List;
|
|||||||
import nl.agilecc.agiledancers.model.Dancer;
|
import nl.agilecc.agiledancers.model.Dancer;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
|
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
|
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
|
||||||
|
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
|
||||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||||
|
|
||||||
|
@SchoolTransactionBoundary
|
||||||
@Path("/api/dance-schools/{schoolId}/dancers")
|
@Path("/api/dance-schools/{schoolId}/dancers")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
public class DancerResource {
|
public class DancerResource {
|
||||||
|
private @Inject DomainViewMapper views;
|
||||||
|
|
||||||
private @Inject SchoolManagementService service;
|
private @Inject SchoolManagementService service;
|
||||||
|
|
||||||
private @PathParam("schoolId") long schoolId;
|
private @PathParam("schoolId") long schoolId;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
public List<DancerView> list() {
|
public List<DancerView> list() {
|
||||||
return service.dancers(schoolId).stream().map(DancerView::of).toList();
|
return service.dancers(schoolId).stream().map(views::dancer).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public DancerView get(final @PathParam("id") long id) {
|
public DancerView get(final @PathParam("id") long id) {
|
||||||
return DancerView.of(service.dancer(schoolId, id));
|
return views.dancer(service.dancer(schoolId, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -46,14 +50,14 @@ public class DancerResource {
|
|||||||
return Response.created(uriInfo.getAbsolutePathBuilder()
|
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||||
.path(entity.getId().toString())
|
.path(entity.getId().toString())
|
||||||
.build())
|
.build())
|
||||||
.entity(DancerView.of(entity))
|
.entity(views.dancer(entity))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public DancerView update(final @PathParam("id") long id, final @NotNull @Valid DancerRequest request) {
|
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
|
@DELETE
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class ManagementExceptionMapper implements ExceptionMapper<ManagementExce
|
|||||||
public Response toResponse(final ManagementException exception) {
|
public Response toResponse(final ManagementException exception) {
|
||||||
final int status = switch (exception.getKind()) {
|
final int status = switch (exception.getKind()) {
|
||||||
case NOT_FOUND -> 404;
|
case NOT_FOUND -> 404;
|
||||||
|
case STORAGE_UNAVAILABLE -> 503;
|
||||||
case INVALID_INPUT -> 400;
|
case INVALID_INPUT -> 400;
|
||||||
case CONFLICT -> 409;
|
case CONFLICT -> 409;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import jakarta.ws.rs.Produces;
|
|||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.SchedulingEventView;
|
import nl.agilecc.agiledancers.rest.apimodel.SchedulingEventView;
|
||||||
import nl.agilecc.agiledancers.service.SchedulingRuns;
|
import nl.agilecc.agiledancers.service.SchedulingRuns;
|
||||||
import nl.agilecc.agiledancers.service.model.SchedulingRun;
|
|
||||||
import org.jboss.resteasy.reactive.RestStreamElementType;
|
import org.jboss.resteasy.reactive.RestStreamElementType;
|
||||||
|
|
||||||
@Path("/api/scheduling/events")
|
@Path("/api/scheduling/events")
|
||||||
@@ -36,6 +36,9 @@ public class SchedulingEventsResource {
|
|||||||
.every(Duration.ofSeconds(15))
|
.every(Duration.ofSeconds(15))
|
||||||
.onItem()
|
.onItem()
|
||||||
.transform((final Long tick) -> new SchedulingEventView(null));
|
.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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,15 +15,19 @@ import jakarta.ws.rs.QueryParam;
|
|||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
|
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.ManagementException;
|
||||||
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
|
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
|
||||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||||
import nl.agilecc.agiledancers.service.model.ImportPreview;
|
import nl.agilecc.agiledancers.service.model.ImportPreview;
|
||||||
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
|
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
|
||||||
|
|
||||||
|
@SchoolTransactionBoundary
|
||||||
@Path("/api/dance-schools")
|
@Path("/api/dance-schools")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public class SchoolSnapshotResource {
|
public class SchoolSnapshotResource {
|
||||||
|
private @Inject DomainViewMapper views;
|
||||||
|
|
||||||
private @Inject SchoolManagementService service;
|
private @Inject SchoolManagementService service;
|
||||||
|
|
||||||
private @Inject ObjectMapper mapper;
|
private @Inject ObjectMapper mapper;
|
||||||
@@ -48,7 +52,7 @@ public class SchoolSnapshotResource {
|
|||||||
@Path("/import")
|
@Path("/import")
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
public SchoolView apply(final String json, final @QueryParam("replaceId") Long replaceId) {
|
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) {
|
private SchoolSnapshot parse(final String json) {
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -18,19 +18,23 @@ import jakarta.ws.rs.core.UriInfo;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import nl.agilecc.agiledancers.model.DayPart;
|
import nl.agilecc.agiledancers.model.DayPart;
|
||||||
import nl.agilecc.agiledancers.model.Show;
|
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.DayPartRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest;
|
import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
|
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
|
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.SchedulingRuns;
|
||||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||||
import nl.agilecc.agiledancers.service.ShowSchedules;
|
import nl.agilecc.agiledancers.service.ShowSchedules;
|
||||||
import nl.agilecc.agiledancers.service.model.SchedulingRun;
|
|
||||||
|
|
||||||
|
@SchoolTransactionBoundary
|
||||||
@Path("/api/dance-schools/{schoolId}/shows")
|
@Path("/api/dance-schools/{schoolId}/shows")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
public class ShowResource {
|
public class ShowResource {
|
||||||
|
private @Inject DomainViewMapper views;
|
||||||
|
|
||||||
private @Inject SchoolManagementService service;
|
private @Inject SchoolManagementService service;
|
||||||
|
|
||||||
private @Inject SchedulingRuns scheduling;
|
private @Inject SchedulingRuns scheduling;
|
||||||
@@ -67,7 +71,7 @@ public class ShowResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ShowView view(final Show show) {
|
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) {
|
private static Show settings(final ShowRequest request) {
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
package nl.agilecc.agiledancers.rest.apimodel;
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
|
|
||||||
import nl.agilecc.agiledancers.model.Coach;
|
public record CoachView(long id, String name, Long dancerId) {}
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
package nl.agilecc.agiledancers.rest.apimodel;
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import nl.agilecc.agiledancers.model.Dancer;
|
|
||||||
|
|
||||||
public record DancerView(long id, String name, LocalDate birthDate) {
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import java.util.Collections;
|
|||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
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.DanceGroupStyle;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
|
|
||||||
@@ -22,15 +20,4 @@ public record GroupView(
|
|||||||
styles = styles == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(styles));
|
styles = styles == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(styles));
|
||||||
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package nl.agilecc.agiledancers.rest.apimodel;
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
|
|
||||||
import java.util.List;
|
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. */
|
/** A null runs field is a heartbeat, not a replacement of the current snapshot. */
|
||||||
public record SchedulingEventView(List<SchedulingRun> runs) {
|
public record SchedulingEventView(List<SchedulingRun> runs) {
|
||||||
|
|||||||
@@ -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<DancerView> dancers,
|
||||||
|
List<GroupView> groups,
|
||||||
|
List<ShowView> shows,
|
||||||
|
List<CoachView> coaches) {
|
||||||
|
public SchoolDataView {
|
||||||
|
dancers = List.copyOf(dancers);
|
||||||
|
groups = List.copyOf(groups);
|
||||||
|
shows = List.copyOf(shows);
|
||||||
|
coaches = List.copyOf(coaches);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,24 +3,12 @@ package nl.agilecc.agiledancers.rest.apimodel;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
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<Long> dancerIds, List<Long> danceGroupIds, List<Long> showIds) {
|
public record SchoolView(
|
||||||
|
long id, long revision, String name, List<Long> dancerIds, List<Long> danceGroupIds, List<Long> showIds) {
|
||||||
public SchoolView {
|
public SchoolView {
|
||||||
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
|
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
|
||||||
danceGroupIds = danceGroupIds == null ? null : Collections.unmodifiableList(new ArrayList<>(danceGroupIds));
|
danceGroupIds = danceGroupIds == null ? null : Collections.unmodifiableList(new ArrayList<>(danceGroupIds));
|
||||||
showIds = showIds == null ? null : Collections.unmodifiableList(new ArrayList<>(showIds));
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import java.util.Collections;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
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;
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
|
|
||||||
public record ShowView(
|
public record ShowView(
|
||||||
@@ -23,26 +20,4 @@ public record ShowView(
|
|||||||
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
||||||
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
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<String> 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package nl.agilecc.agiledancers.service;
|
|||||||
|
|
||||||
public enum ManagementExceptionKind {
|
public enum ManagementExceptionKind {
|
||||||
NOT_FOUND,
|
NOT_FOUND,
|
||||||
|
STORAGE_UNAVAILABLE,
|
||||||
INVALID_INPUT,
|
INVALID_INPUT,
|
||||||
CONFLICT
|
CONFLICT
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<SchedulingRun> recover() {
|
||||||
|
return transactions.background(() -> {
|
||||||
|
runs.recover();
|
||||||
|
return runs.load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SchedulingRun> load() {
|
||||||
|
return transactions.background(runs::load);
|
||||||
|
}
|
||||||
|
|
||||||
|
public <T> T work(final Callable<T> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,28 @@
|
|||||||
package nl.agilecc.agiledancers.service;
|
package nl.agilecc.agiledancers.service;
|
||||||
|
|
||||||
|
import io.quarkus.runtime.Startup;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
import jakarta.annotation.PreDestroy;
|
import jakarta.annotation.PreDestroy;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.inject.Singleton;
|
import jakarta.inject.Singleton;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.Callable;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
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.model.scheduling.SchedulingSummary;
|
||||||
import nl.agilecc.agiledancers.service.model.SchedulingRun;
|
import nl.agilecc.agiledancers.repository.StorageException;
|
||||||
import nl.agilecc.agiledancers.service.model.SchedulingState;
|
|
||||||
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
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
|
@Singleton
|
||||||
public class SchedulingRuns {
|
public class SchedulingRuns {
|
||||||
private final SchoolManagementService schools;
|
private final SchoolManagementService schools;
|
||||||
@@ -25,6 +30,7 @@ public class SchedulingRuns {
|
|||||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||||
private final Map<Long, SchedulingRun> runs = new HashMap<>();
|
private final Map<Long, SchedulingRun> runs = new HashMap<>();
|
||||||
private final Map<Object, Consumer<List<SchedulingRun>>> listeners = new HashMap<>();
|
private final Map<Object, Consumer<List<SchedulingRun>>> listeners = new HashMap<>();
|
||||||
|
private @Inject SchedulingPersistence persistence;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public SchedulingRuns(final SchoolManagementService schools, final SchedulingEngine engine) {
|
public SchedulingRuns(final SchoolManagementService schools, final SchedulingEngine engine) {
|
||||||
@@ -32,74 +38,119 @@ public class SchedulingRuns {
|
|||||||
this.engine = engine;
|
this.engine = engine;
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized SchedulingRun start(final long schoolId, final long showId) {
|
@PostConstruct
|
||||||
final SchedulingInput input;
|
public void recover() {
|
||||||
final SchedulingRun run;
|
persistence.recover().forEach((final SchedulingRun run) -> runs.put(run.showId(), run));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SchedulingRun start(final long schoolId, final long showId) {
|
||||||
synchronized (schools) {
|
synchronized (schools) {
|
||||||
final String name = schools.show(schoolId, showId).getName();
|
final SchedulingInput input = schools.beginScheduling(schoolId, showId);
|
||||||
input = schools.beginScheduling(schoolId, showId);
|
final SchedulingRun run = SchedulingRun.started(
|
||||||
run = SchedulingRun.started(schoolId, showId, name);
|
schoolId, showId, schools.show(schoolId, showId).getName());
|
||||||
|
final Runnable launch = () -> launch(run, input);
|
||||||
|
if (persistence == null) {
|
||||||
|
launch.run();
|
||||||
|
} else {
|
||||||
|
persistence.accepted(run, launch, () -> schools.endScheduling(showId));
|
||||||
|
}
|
||||||
|
return run;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void launch(final SchedulingRun run, final SchedulingInput input) {
|
||||||
publish(run);
|
publish(run);
|
||||||
try {
|
try {
|
||||||
executor.submit(() -> calculate(run, input));
|
executor.submit(() -> calculate(run, input));
|
||||||
} catch (final RuntimeException failure) {
|
} catch (final RuntimeException failure) {
|
||||||
schools.endScheduling(showId);
|
finish(run.completed(
|
||||||
publish(run.completed(
|
|
||||||
SchedulingState.SOLVER_FAILED, "Berekening kon niet starten. Bestaande indeling behouden.", null));
|
SchedulingState.SOLVER_FAILED, "Berekening kon niet starten. Bestaande indeling behouden.", null));
|
||||||
throw failure;
|
if (persistence == null) {
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return run;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized SchedulingRun status(final long schoolId, final long showId) {
|
public SchedulingRun status(final long schoolId, final long showId) {
|
||||||
schools.show(schoolId, showId);
|
synchronized (schools) {
|
||||||
return runs.get(showId);
|
schools.show(schoolId, showId);
|
||||||
|
return runs.get(showId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Registration and initial snapshot share the publication lock, preventing a missed update. */
|
/** Registration and initial snapshot share the publication lock, preventing a missed update. */
|
||||||
public synchronized Runnable subscribe(final Consumer<List<SchedulingRun>> listener) {
|
public Runnable subscribe(final Consumer<List<SchedulingRun>> listener) {
|
||||||
final Object key = new Object();
|
synchronized (schools) {
|
||||||
listeners.put(key, listener);
|
if (persistence != null) {
|
||||||
listener.accept(List.copyOf(runs.values()));
|
runs.clear();
|
||||||
return () -> unsubscribe(key);
|
persistence.load().forEach((final SchedulingRun run) -> runs.put(run.showId(), run));
|
||||||
|
}
|
||||||
|
final Object key = new Object();
|
||||||
|
listeners.put(key, listener);
|
||||||
|
listener.accept(List.copyOf(runs.values()));
|
||||||
|
return () -> unsubscribe(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private synchronized void unsubscribe(final Object key) {
|
private void unsubscribe(final Object key) {
|
||||||
listeners.remove(key);
|
synchronized (schools) {
|
||||||
|
listeners.remove(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private synchronized void publish(final SchedulingRun run) {
|
private void publish(final SchedulingRun run) {
|
||||||
runs.put(run.showId(), run);
|
synchronized (schools) {
|
||||||
final List<SchedulingRun> snapshot = List.copyOf(runs.values());
|
runs.put(run.showId(), run);
|
||||||
listeners.values().forEach((final Consumer<List<SchedulingRun>> listener) -> listener.accept(snapshot));
|
final List<SchedulingRun> snapshot = List.copyOf(runs.values());
|
||||||
|
for (final Consumer<List<SchedulingRun>> listener : List.copyOf(listeners.values())) {
|
||||||
|
notifyListener(listener, snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private synchronized void progress(final SchedulingRun run, final SchedulingProgress update) {
|
private void notifyListener(final Consumer<List<SchedulingRun>> listener, final List<SchedulingRun> snapshot) {
|
||||||
publish(runs.get(run.showId()).progressed(update));
|
try {
|
||||||
|
listener.accept(snapshot);
|
||||||
|
} catch (final RuntimeException disconnected) {
|
||||||
|
// A disconnected transport must not turn a committed solver outcome into a failure.
|
||||||
|
listeners.values().remove(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void progress(final SchedulingRun run, final SchedulingProgress update) {
|
||||||
|
work(() -> {
|
||||||
|
save(latest(run).progressed(update));
|
||||||
|
return null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void calculate(final SchedulingRun run, final SchedulingInput input) {
|
private void calculate(final SchedulingRun run, final SchedulingInput input) {
|
||||||
SchedulingRun completed;
|
|
||||||
try {
|
try {
|
||||||
final SchedulingResult result =
|
final SchedulingResult result =
|
||||||
engine.solve(input, (final SchedulingProgress update) -> progress(run, update));
|
engine.solve(input, (final SchedulingProgress update) -> progress(run, update));
|
||||||
final SchedulingSummary summary = SchedulingSummary.of(input, result);
|
final SchedulingSummary summary = SchedulingSummary.of(input, result);
|
||||||
schools.finishScheduling(run.schoolId(), input, result);
|
work(() -> {
|
||||||
completed = latest(run)
|
schools.finishScheduling(run.schoolId(), input, result);
|
||||||
.completed(
|
if (persistence != null) {
|
||||||
result.optimal() ? SchedulingState.OPTIMAL : SchedulingState.VALID,
|
persistence.result(run.schoolId());
|
||||||
result.optimal()
|
}
|
||||||
? "Optimaal conceptrooster."
|
save(latest(run)
|
||||||
: "Geldig conceptrooster; optimaliteit niet bewezen.",
|
.completed(
|
||||||
summary);
|
result.optimal() ? SchedulingState.OPTIMAL : SchedulingState.VALID,
|
||||||
|
result.optimal()
|
||||||
|
? "Optimaal conceptrooster."
|
||||||
|
: "Geldig conceptrooster; optimaliteit niet bewezen.",
|
||||||
|
summary));
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
schools.endScheduling(run.showId());
|
||||||
} catch (final ManagementException failure) {
|
} catch (final ManagementException failure) {
|
||||||
completed = failed(run, failure);
|
finish(failed(run, failure));
|
||||||
} catch (final RuntimeException | LinkageError failure) {
|
} catch (final RuntimeException | LinkageError failure) {
|
||||||
completed = latest(run)
|
finish(latest(run)
|
||||||
.completed(SchedulingState.SOLVER_FAILED, "Berekening mislukt. Bestaande indeling behouden.", null);
|
.completed(
|
||||||
|
SchedulingState.SOLVER_FAILED, "Berekening mislukt. Bestaande indeling behouden.", null));
|
||||||
}
|
}
|
||||||
finish(completed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private SchedulingRun failed(final SchedulingRun run, final ManagementException failure) {
|
private SchedulingRun failed(final SchedulingRun run, final ManagementException failure) {
|
||||||
@@ -115,13 +166,71 @@ public class SchedulingRuns {
|
|||||||
return latest(run).completed(outcome, failure.getMessage() + " Bestaande indeling behouden.", null);
|
return latest(run).completed(outcome, failure.getMessage() + " Bestaande indeling behouden.", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private synchronized SchedulingRun latest(final SchedulingRun run) {
|
private SchedulingRun latest(final SchedulingRun run) {
|
||||||
return runs.get(run.showId());
|
synchronized (schools) {
|
||||||
|
return runs.get(run.showId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private synchronized void finish(final SchedulingRun completed) {
|
private void finish(final SchedulingRun completed) {
|
||||||
schools.endScheduling(completed.showId());
|
try {
|
||||||
publish(completed);
|
while (true) {
|
||||||
|
try {
|
||||||
|
work(() -> {
|
||||||
|
schools.show(completed.schoolId(), completed.showId());
|
||||||
|
save(completed);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (final StorageException unavailable) {
|
||||||
|
// Keep the run guard and terminal outcome until storage recovers; never publish an uncommitted
|
||||||
|
// status.
|
||||||
|
if (executor.isShutdown() || !waitForStorage()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (final ManagementException removed) {
|
||||||
|
if (removed.getKind() != ManagementExceptionKind.NOT_FOUND) {
|
||||||
|
throw removed;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
schools.endScheduling(completed.showId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean waitForStorage() {
|
||||||
|
try {
|
||||||
|
Thread.sleep(1000);
|
||||||
|
return true;
|
||||||
|
} catch (final InterruptedException stopping) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void save(final SchedulingRun run) {
|
||||||
|
if (persistence == null) {
|
||||||
|
publish(run);
|
||||||
|
} else {
|
||||||
|
persistence.save(run, () -> publish(run));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T work(final Callable<T> action) {
|
||||||
|
synchronized (schools) {
|
||||||
|
if (persistence != null) {
|
||||||
|
return persistence.work(action);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return action.call();
|
||||||
|
} catch (final RuntimeException failure) {
|
||||||
|
throw failure;
|
||||||
|
} catch (final Exception failure) {
|
||||||
|
throw new IllegalStateException(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import nl.agilecc.agiledancers.service.model.GroupDetails;
|
|||||||
import nl.agilecc.agiledancers.service.model.ImportPreview;
|
import nl.agilecc.agiledancers.service.model.ImportPreview;
|
||||||
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
|
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
|
||||||
|
|
||||||
/** Owns the in-memory school aggregates; all writes go through this shared service. */
|
/** Owns detached school aggregates within the shared transaction monitor; all domain writes pass through here. */
|
||||||
@Singleton
|
@Singleton
|
||||||
public class SchoolManagementService {
|
public class SchoolManagementService {
|
||||||
private final DanceSchoolRepository schools = new DanceSchoolRepository();
|
private final DanceSchoolRepository schools = new DanceSchoolRepository();
|
||||||
@@ -41,6 +41,27 @@ public class SchoolManagementService {
|
|||||||
private final Map<Long, Long> revisions = new HashMap<>();
|
private final Map<Long, Long> revisions = new HashMap<>();
|
||||||
private final Set<Long> running = new HashSet<>();
|
private final Set<Long> running = new HashSet<>();
|
||||||
|
|
||||||
|
/** Installs a detached relational snapshot under the service's existing aggregate lock. */
|
||||||
|
public synchronized void load(final List<DanceSchool> state, final java.util.function.LongSupplier identifiers) {
|
||||||
|
revisions.clear();
|
||||||
|
schools.load(state, identifiers);
|
||||||
|
dancers.load(
|
||||||
|
state.stream()
|
||||||
|
.flatMap((final DanceSchool school) -> school.getDancers().stream())
|
||||||
|
.toList(),
|
||||||
|
identifiers);
|
||||||
|
groups.load(
|
||||||
|
state.stream()
|
||||||
|
.flatMap((final DanceSchool school) -> school.getDanceGroups().stream())
|
||||||
|
.toList(),
|
||||||
|
identifiers);
|
||||||
|
shows.load(
|
||||||
|
state.stream()
|
||||||
|
.flatMap((final DanceSchool school) -> school.getShows().stream())
|
||||||
|
.toList(),
|
||||||
|
identifiers);
|
||||||
|
}
|
||||||
|
|
||||||
public synchronized void touch(final Show show) {
|
public synchronized void touch(final Show show) {
|
||||||
show.setFinalized(false);
|
show.setFinalized(false);
|
||||||
revisions.merge(show.getId(), 1L, Long::sum);
|
revisions.merge(show.getId(), 1L, Long::sum);
|
||||||
@@ -64,7 +85,8 @@ public class SchoolManagementService {
|
|||||||
ManagementExceptionKind.INVALID_INPUT, "Correct preferredDayParts before scheduling.");
|
ManagementExceptionKind.INVALID_INPUT, "Correct preferredDayParts before scheduling.");
|
||||||
}
|
}
|
||||||
running.add(showId);
|
running.add(showId);
|
||||||
return SchedulingInputs.of(school(schoolId), show, revisions.getOrDefault(showId, 0L));
|
return SchedulingInputs.of(
|
||||||
|
school(schoolId), show, school(schoolId).getRevision() + revisions.getOrDefault(showId, 0L));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validates the complete result and input revision before replacing any saved assignments. */
|
/** Validates the complete result and input revision before replacing any saved assignments. */
|
||||||
@@ -77,7 +99,7 @@ public class SchoolManagementService {
|
|||||||
throw new ManagementException(
|
throw new ManagementException(
|
||||||
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
|
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
|
||||||
}
|
}
|
||||||
if (revisions.getOrDefault(input.showId(), 0L) != input.revision()) {
|
if (school(schoolId).getRevision() + revisions.getOrDefault(input.showId(), 0L) != input.revision()) {
|
||||||
throw new ManagementException(
|
throw new ManagementException(
|
||||||
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
|
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
|
||||||
}
|
}
|
||||||
@@ -320,12 +342,14 @@ public class SchoolManagementService {
|
|||||||
snapshot.groups().size(),
|
snapshot.groups().size(),
|
||||||
snapshot.shows().size(),
|
snapshot.shows().size(),
|
||||||
existing == null ? null : existing.getId(),
|
existing == null ? null : existing.getId(),
|
||||||
existing == null ? null : existing.getName());
|
existing == null ? null : existing.getName(),
|
||||||
|
existing == null ? null : existing.getRevision());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validates and stages the complete graph before replacing repositories; file IDs become runtime IDs. */
|
/** Validates and stages the complete graph before replacing repositories; file IDs become runtime IDs. */
|
||||||
public synchronized DanceSchool importSchool(final SchoolSnapshot snapshot, final Long replaceId) {
|
public synchronized DanceSchool importSchool(final SchoolSnapshot snapshot, final Long replaceId) {
|
||||||
final DanceSchool staged = snapshot.restore();
|
final DanceSchool staged = snapshot.restore();
|
||||||
|
final long revision = replaceId == null ? 0 : school(replaceId).getRevision();
|
||||||
final ImportPreview preview = preview(snapshot);
|
final ImportPreview preview = preview(snapshot);
|
||||||
if (!Objects.equals(preview.existingId(), replaceId)) {
|
if (!Objects.equals(preview.existingId(), replaceId)) {
|
||||||
throw new ManagementException(
|
throw new ManagementException(
|
||||||
@@ -347,6 +371,7 @@ public class SchoolManagementService {
|
|||||||
final List<DanceGroup> rebuiltGroups = rebuildGroups(importedGroups, memberships, replacements);
|
final List<DanceGroup> rebuiltGroups = rebuildGroups(importedGroups, memberships, replacements);
|
||||||
final List<Show> rebuiltShows = rebuildShows(importedShows, replacements);
|
final List<Show> rebuiltShows = rebuildShows(importedShows, replacements);
|
||||||
final DanceSchool imported = DanceSchool.builder()
|
final DanceSchool imported = DanceSchool.builder()
|
||||||
|
.revision(revision)
|
||||||
.key(staged.getKey())
|
.key(staged.getKey())
|
||||||
.name(staged.getName())
|
.name(staged.getName())
|
||||||
.dancers(new LinkedHashSet<>(importedDancers))
|
.dancers(new LinkedHashSet<>(importedDancers))
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
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.DanceSchool;
|
||||||
|
import nl.agilecc.agiledancers.repository.Database;
|
||||||
|
import nl.agilecc.agiledancers.repository.RevisionConflictException;
|
||||||
|
import nl.agilecc.agiledancers.repository.SchoolReader;
|
||||||
|
import nl.agilecc.agiledancers.repository.SchoolWriter;
|
||||||
|
import nl.agilecc.agiledancers.repository.StorageException;
|
||||||
|
import nl.agilecc.agiledancers.service.model.SchoolChange;
|
||||||
|
|
||||||
|
/** Serializes short domain units of work; solver computation never holds this lock or a database connection. */
|
||||||
|
@Singleton
|
||||||
|
public class SchoolTransactions {
|
||||||
|
private final SchoolManagementService schools;
|
||||||
|
private final Database database;
|
||||||
|
private final SchoolReader reader;
|
||||||
|
private final SchoolWriter writer;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public SchoolTransactions(
|
||||||
|
final SchoolManagementService schools,
|
||||||
|
final Database database,
|
||||||
|
final SchoolReader reader,
|
||||||
|
final SchoolWriter writer) {
|
||||||
|
this.schools = schools;
|
||||||
|
this.database = database;
|
||||||
|
this.reader = reader;
|
||||||
|
this.writer = writer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An expected revision is mandatory for existing-school writes; null school identifies a new-school operation. */
|
||||||
|
public Object request(final Long schoolId, final Long revision, final boolean mutation, final Callable<Object> work)
|
||||||
|
throws Exception {
|
||||||
|
return request(new SchoolChange(schoolId, revision, mutation, mutation), work);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Object request(final SchoolChange change, final Callable<Object> work) throws Exception {
|
||||||
|
synchronized (schools) {
|
||||||
|
try {
|
||||||
|
return database.transaction(() -> {
|
||||||
|
final List<DanceSchool> before = reader.load();
|
||||||
|
schools.load(before, database::nextId);
|
||||||
|
if (change.mutation() && change.schoolId() != null) {
|
||||||
|
check(change);
|
||||||
|
schools.school(change.schoolId())
|
||||||
|
.setRevision(change.revision() + (change.changesInputs() ? 1 : 0));
|
||||||
|
}
|
||||||
|
final Object result = work.call();
|
||||||
|
if (change.changesInputs()) {
|
||||||
|
store(before, change.schoolId());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
} catch (final RevisionConflictException failure) {
|
||||||
|
throw new ManagementException(ManagementExceptionKind.CONFLICT, failure.getMessage());
|
||||||
|
} catch (final StorageException failure) {
|
||||||
|
throw new ManagementException(ManagementExceptionKind.STORAGE_UNAVAILABLE, failure.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void check(final SchoolChange change) {
|
||||||
|
final long schoolId = change.schoolId();
|
||||||
|
final Long expected = change.revision();
|
||||||
|
final long actual = schools.school(schoolId).getRevision();
|
||||||
|
if (expected == null || actual != expected) {
|
||||||
|
throw new ManagementException(
|
||||||
|
ManagementExceptionKind.CONFLICT,
|
||||||
|
"Deze school is gewijzigd of de schoolversie ontbreekt. Herlaad de gegevens en probeer opnieuw.");
|
||||||
|
}
|
||||||
|
writer.checkRevision(schoolId, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void store(final List<DanceSchool> before, final Long schoolId) {
|
||||||
|
final List<DanceSchool> after = schools.schools();
|
||||||
|
if (schoolId != null) {
|
||||||
|
final DanceSchool retained = after.stream()
|
||||||
|
.filter((final DanceSchool school) -> school.getId().equals(schoolId))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (retained == null) {
|
||||||
|
writer.delete(schoolId);
|
||||||
|
} else {
|
||||||
|
writer.save(retained);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final List<Long> known = before.stream().map(DanceSchool::getId).toList();
|
||||||
|
after.stream()
|
||||||
|
.filter((final DanceSchool school) -> !known.contains(school.getId()))
|
||||||
|
.forEach(writer::save);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refreshes current domain state and commits a worker's atomic result/status operation. */
|
||||||
|
public <T> T background(final Callable<T> work) {
|
||||||
|
synchronized (schools) {
|
||||||
|
try {
|
||||||
|
return database.transaction(() -> {
|
||||||
|
schools.load(reader.load(), database::nextId);
|
||||||
|
return work.call();
|
||||||
|
});
|
||||||
|
} catch (final RuntimeException failure) {
|
||||||
|
throw failure;
|
||||||
|
} catch (final Exception failure) {
|
||||||
|
throw new IllegalStateException(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void saveResult(final long schoolId) {
|
||||||
|
final DanceSchool school = schools.school(schoolId);
|
||||||
|
school.setRevision(school.getRevision() + 1);
|
||||||
|
writer.save(school);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
package nl.agilecc.agiledancers.service.model;
|
package nl.agilecc.agiledancers.service.model;
|
||||||
|
|
||||||
public record ImportPreview(String name, int dancers, int groups, int shows, Long existingId, String existingName) {}
|
public record ImportPreview(
|
||||||
|
String name, int dancers, int groups, int shows, Long existingId, String existingName, Long existingRevision) {}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package nl.agilecc.agiledancers.service.model;
|
||||||
|
|
||||||
|
/** Run acceptance checks the loaded revision but does not itself change scheduling inputs. */
|
||||||
|
public record SchoolChange(Long schoolId, Long revision, boolean mutation, boolean changesInputs) {}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
quarkus.application.name=agiledancers
|
quarkus.application.name=agiledancers
|
||||||
quarkus.http.host=0.0.0.0
|
quarkus.http.host=0.0.0.0
|
||||||
|
|
||||||
# Scheduling is synchronous with a 30-minute solver limit.
|
# Keep idle SSE connections alive alongside the 30-minute solver limit.
|
||||||
quarkus.http.idle-timeout=31M
|
quarkus.http.idle-timeout=31M
|
||||||
quarkus.jackson.fail-on-unknown-properties=true
|
quarkus.jackson.fail-on-unknown-properties=true
|
||||||
|
|
||||||
@@ -9,3 +9,17 @@ quarkus.jackson.fail-on-unknown-properties=true
|
|||||||
%test.quarkus.jacoco.data-file=target/jacoco.exec
|
%test.quarkus.jacoco.data-file=target/jacoco.exec
|
||||||
%test.quarkus.jacoco.reuse-data-file=true
|
%test.quarkus.jacoco.reuse-data-file=true
|
||||||
%test.quarkus.jacoco.report=false
|
%test.quarkus.jacoco.report=false
|
||||||
|
|
||||||
|
# Production requires an explicitly configured database; initialization failures prevent startup.
|
||||||
|
quarkus.datasource.db-kind=postgresql
|
||||||
|
%prod.quarkus.datasource.jdbc.url=${DATABASE_URL}
|
||||||
|
%prod.quarkus.datasource.username=${DATABASE_USER}
|
||||||
|
%prod.quarkus.datasource.password=${DATABASE_PASSWORD}
|
||||||
|
quarkus.datasource.jdbc.acquisition-timeout=5S
|
||||||
|
quarkus.flyway.migrate-at-start=true
|
||||||
|
quarkus.datasource.devservices.image-name=postgres:17.6
|
||||||
|
|
||||||
|
# Flyway owns DDL; Hibernate validates its JPA mappings against the migrated schema.
|
||||||
|
quarkus.hibernate-orm.schema-management.strategy=validate
|
||||||
|
quarkus.hibernate-orm.request-scoped.enabled=false
|
||||||
|
quarkus.datasource.jdbc.transaction-isolation-level=repeatable-read
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
CREATE SEQUENCE entity_id START 1;
|
||||||
|
CREATE TABLE school (
|
||||||
|
id bigint PRIMARY KEY, school_key uuid NOT NULL UNIQUE, name text NOT NULL,
|
||||||
|
revision bigint NOT NULL DEFAULT 0 CHECK (revision >= 0)
|
||||||
|
);
|
||||||
|
CREATE TABLE dancer (
|
||||||
|
school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE,
|
||||||
|
id bigint NOT NULL, name text NOT NULL, birth_date date NOT NULL,
|
||||||
|
PRIMARY KEY (school_id, id)
|
||||||
|
);
|
||||||
|
CREATE TABLE coach (
|
||||||
|
school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE,
|
||||||
|
id bigint NOT NULL, name text NOT NULL, dancer_id bigint,
|
||||||
|
PRIMARY KEY (school_id, id),
|
||||||
|
FOREIGN KEY (school_id, dancer_id) REFERENCES dancer DEFERRABLE INITIALLY DEFERRED
|
||||||
|
);
|
||||||
|
CREATE TABLE dance_group (
|
||||||
|
school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE,
|
||||||
|
id bigint NOT NULL, category text NOT NULL, group_type text NOT NULL,
|
||||||
|
start_age integer NOT NULL CHECK (start_age >= 0), end_age integer NOT NULL CHECK (end_age >= start_age),
|
||||||
|
coach_id bigint, PRIMARY KEY (school_id, id),
|
||||||
|
FOREIGN KEY (school_id, coach_id) REFERENCES coach DEFERRABLE INITIALLY DEFERRED
|
||||||
|
);
|
||||||
|
CREATE TABLE group_style (
|
||||||
|
school_id bigint NOT NULL, group_id bigint NOT NULL, style text NOT NULL,
|
||||||
|
PRIMARY KEY (school_id, group_id, style),
|
||||||
|
FOREIGN KEY (school_id, group_id) REFERENCES dance_group ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE group_dancer (
|
||||||
|
school_id bigint NOT NULL, group_id bigint NOT NULL, dancer_id bigint NOT NULL,
|
||||||
|
PRIMARY KEY (school_id, group_id, dancer_id),
|
||||||
|
FOREIGN KEY (school_id, group_id) REFERENCES dance_group ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (school_id, dancer_id) REFERENCES dancer DEFERRABLE INITIALLY DEFERRED
|
||||||
|
);
|
||||||
|
CREATE TABLE show (
|
||||||
|
school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE,
|
||||||
|
id bigint NOT NULL, name text NOT NULL, preferred_day_parts integer NOT NULL CHECK (preferred_day_parts BETWEEN 1 AND 6),
|
||||||
|
finalized boolean NOT NULL, PRIMARY KEY (school_id, id)
|
||||||
|
);
|
||||||
|
CREATE TABLE show_appearance (
|
||||||
|
school_id bigint NOT NULL, show_id bigint NOT NULL, group_type text NOT NULL, appearances integer NOT NULL CHECK (appearances >= 0),
|
||||||
|
PRIMARY KEY (school_id, show_id, group_type),
|
||||||
|
FOREIGN KEY (school_id, show_id) REFERENCES show ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE day_part (
|
||||||
|
school_id bigint NOT NULL, show_id bigint NOT NULL, position integer NOT NULL CHECK (position >= 0),
|
||||||
|
day date NOT NULL, part_type text NOT NULL,
|
||||||
|
PRIMARY KEY (school_id, show_id, position), UNIQUE (school_id, show_id, day, part_type),
|
||||||
|
FOREIGN KEY (school_id, show_id) REFERENCES show ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE assignment (
|
||||||
|
school_id bigint NOT NULL, show_id bigint NOT NULL, part_position integer NOT NULL,
|
||||||
|
position integer NOT NULL CHECK (position >= 0), group_id bigint NOT NULL,
|
||||||
|
PRIMARY KEY (school_id, show_id, part_position, position),
|
||||||
|
FOREIGN KEY (school_id, show_id, part_position) REFERENCES day_part ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (school_id, group_id) REFERENCES dance_group DEFERRABLE INITIALLY DEFERRED
|
||||||
|
);
|
||||||
|
CREATE TABLE scheduling_run (
|
||||||
|
school_id bigint NOT NULL REFERENCES school ON DELETE CASCADE,
|
||||||
|
show_id bigint NOT NULL, run_id uuid NOT NULL UNIQUE, show_name text NOT NULL,
|
||||||
|
started_at timestamptz NOT NULL, finished_at timestamptz,
|
||||||
|
state text NOT NULL CHECK (state IN ('RUNNING','OPTIMAL','VALID','STALE','INVALID_INPUT','INFEASIBLE','NO_SOLUTION','SOLVER_FAILED','INTERRUPTED')),
|
||||||
|
phase text NOT NULL CHECK (phase IN ('PREPARING','ATTENDANCE','SPREAD','VARIETY')),
|
||||||
|
feasible boolean NOT NULL, message text NOT NULL,
|
||||||
|
attendance_excess bigint, performance_spread integer, variety_penalty bigint,
|
||||||
|
PRIMARY KEY (school_id, show_id),
|
||||||
|
FOREIGN KEY (school_id, show_id) REFERENCES show DEFERRABLE INITIALLY DEFERRED,
|
||||||
|
CHECK ((state = 'RUNNING') = (finished_at IS NULL)),
|
||||||
|
CHECK ((attendance_excess IS NULL AND performance_spread IS NULL AND variety_penalty IS NULL)
|
||||||
|
OR (attendance_excess IS NOT NULL AND performance_spread IS NOT NULL AND variety_penalty IS NOT NULL))
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Child-only edits must dirty the school entity so @Version advances exactly once.
|
||||||
|
ALTER TABLE school ADD COLUMN change_stamp bigint NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Keep existing identifiers and start above every value already allocated by V1.
|
||||||
|
-- JPA allocates identifiers under a row lock, including when a transaction creates a whole import graph.
|
||||||
|
CREATE TABLE entity_identifiers (
|
||||||
|
id integer PRIMARY KEY CHECK (id = 1),
|
||||||
|
next_value bigint NOT NULL CHECK (next_value > 0)
|
||||||
|
);
|
||||||
|
INSERT INTO entity_identifiers (id, next_value) VALUES (1, nextval('entity_id'));
|
||||||
|
|
||||||
|
-- Hibernate updates retained positions in place; date/type swaps must be checked on the final graph.
|
||||||
|
ALTER TABLE day_part DROP CONSTRAINT day_part_school_id_show_id_day_part_type_key;
|
||||||
|
ALTER TABLE day_part ADD CONSTRAINT day_part_date_type_unique
|
||||||
|
UNIQUE (school_id, show_id, day, part_type) DEFERRABLE INITIALLY DEFERRED;
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package nl.agilecc.agiledancers;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
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.model.specifications.DanceGroupStyle;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||||
|
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.rest.mapping.DomainViewMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
@QuarkusTest
|
||||||
|
class DomainViewMapperTest {
|
||||||
|
private @Inject DomainViewMapper views;
|
||||||
|
|
||||||
|
public @Test void relationshipsBecomeOrderedIdsAndUnlinkedCoachesRetainNull() {
|
||||||
|
final DanceSchool school =
|
||||||
|
DanceSchool.builder().name("School").revision(4).build();
|
||||||
|
school.setId(7L);
|
||||||
|
final Dancer first = dancer(9L);
|
||||||
|
final Dancer second = dancer(3L);
|
||||||
|
school.addDancer(first);
|
||||||
|
school.addDancer(second);
|
||||||
|
final SchoolView mapped = views.school(school);
|
||||||
|
assertEquals(List.of(9L, 3L), mapped.dancerIds());
|
||||||
|
assertEquals(4, mapped.revision());
|
||||||
|
final Coach coach = Coach.builder().name("Coach").dancer(first).build();
|
||||||
|
coach.setId(2L);
|
||||||
|
assertEquals(9L, views.coach(coach).dancerId());
|
||||||
|
coach.setDancer(null);
|
||||||
|
assertNull(views.coach(coach).dancerId());
|
||||||
|
school.removeDancer(first);
|
||||||
|
assertEquals(List.of(9L, 3L), mapped.dancerIds());
|
||||||
|
assertThrows(
|
||||||
|
UnsupportedOperationException.class, () -> mapped.dancerIds().clear());
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void nestedViewsKeepAssignmentOrderAndExplicitValidationWithoutSharingCollections() {
|
||||||
|
final DanceGroup first = DanceGroup.builder().category("First").build();
|
||||||
|
first.setId(8L);
|
||||||
|
final DanceGroup second = DanceGroup.builder().category("Second").build();
|
||||||
|
second.setId(2L);
|
||||||
|
final List<DanceGroup> assigned = new ArrayList<>(List.of(first, second));
|
||||||
|
final DayPart part = DayPart.builder()
|
||||||
|
.date(LocalDate.of(2027, 1, 1))
|
||||||
|
.type(DayPartType.MORNING)
|
||||||
|
.danceGroups(assigned)
|
||||||
|
.build();
|
||||||
|
final List<String> violations = new ArrayList<>(List.of("Draft validation"));
|
||||||
|
final Show show = Show.builder()
|
||||||
|
.name("Show")
|
||||||
|
.dayParts(new ArrayList<>(List.of(part)))
|
||||||
|
.appearances(Map.of())
|
||||||
|
.build();
|
||||||
|
show.setId(1L);
|
||||||
|
final ShowView mapped = views.show(show, violations);
|
||||||
|
assertEquals(1, mapped.preferredDayParts());
|
||||||
|
assertEquals(List.of(8L, 2L), mapped.dayParts().getFirst().danceGroupIds());
|
||||||
|
assigned.clear();
|
||||||
|
violations.clear();
|
||||||
|
show.getDayParts().clear();
|
||||||
|
assertEquals(List.of(8L, 2L), mapped.dayParts().getFirst().danceGroupIds());
|
||||||
|
assertEquals(List.of("Draft validation"), mapped.violations());
|
||||||
|
assertThrows(
|
||||||
|
UnsupportedOperationException.class,
|
||||||
|
() -> mapped.dayParts().getFirst().danceGroupIds().clear());
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void preferredDefaultsUseTheExistingRuleAndStylesAreSnapshotValues() {
|
||||||
|
final DayPart part = DayPart.builder()
|
||||||
|
.date(LocalDate.of(2027, 1, 1))
|
||||||
|
.type(DayPartType.MORNING)
|
||||||
|
.danceGroups(List.of())
|
||||||
|
.build();
|
||||||
|
final Show show = Show.builder()
|
||||||
|
.dayParts(List.of(part, part))
|
||||||
|
.appearances(Map.of())
|
||||||
|
.build();
|
||||||
|
show.setId(1L);
|
||||||
|
assertEquals(2, views.show(show, List.of()).preferredDayParts());
|
||||||
|
show.setPreferredDayParts(1);
|
||||||
|
assertEquals(1, views.show(show, List.of()).preferredDayParts());
|
||||||
|
final DanceGroup group = DanceGroup.builder().category("Group").build();
|
||||||
|
group.setId(1L);
|
||||||
|
group.addStyle(DanceGroupStyle.JAZZ);
|
||||||
|
final Dancer dancer = dancer(4L);
|
||||||
|
group.addDancer(dancer);
|
||||||
|
final GroupView mapped = views.group(group);
|
||||||
|
group.replaceStyles(Set.of(DanceGroupStyle.MODERN));
|
||||||
|
group.removeDancer(dancer);
|
||||||
|
assertEquals(Set.of(DanceGroupStyle.JAZZ), mapped.styles());
|
||||||
|
assertEquals(List.of(4L), mapped.dancerIds());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dancer dancer(final long id) {
|
||||||
|
final Dancer dancer = Dancer.builder()
|
||||||
|
.name("Dancer")
|
||||||
|
.birthDate(LocalDate.of(2000, 1, 1))
|
||||||
|
.build();
|
||||||
|
dancer.setId(id);
|
||||||
|
return dancer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
package nl.agilecc.agiledancers;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||||
|
import nl.agilecc.agiledancers.model.DayPart;
|
||||||
|
import nl.agilecc.agiledancers.model.Show;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
|
||||||
|
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.model.specifications.DanceGroupStyle;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||||
|
import nl.agilecc.agiledancers.repository.Database;
|
||||||
|
import nl.agilecc.agiledancers.repository.RunRepository;
|
||||||
|
import nl.agilecc.agiledancers.repository.SchoolReader;
|
||||||
|
import nl.agilecc.agiledancers.repository.SchoolWriter;
|
||||||
|
import nl.agilecc.agiledancers.repository.StorageException;
|
||||||
|
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||||
|
import nl.agilecc.agiledancers.service.SchoolTransactions;
|
||||||
|
import nl.agilecc.agiledancers.service.model.GroupDetails;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
@QuarkusTest
|
||||||
|
class PersistenceTest {
|
||||||
|
private @Inject SchoolTransactions transactions;
|
||||||
|
private @Inject SchoolManagementService schools;
|
||||||
|
private @Inject Database database;
|
||||||
|
private @Inject SchoolReader reader;
|
||||||
|
private @Inject SchoolWriter writer;
|
||||||
|
private @Inject RunRepository runs;
|
||||||
|
private @Inject jakarta.persistence.EntityManager entities;
|
||||||
|
|
||||||
|
public @Test void simultaneousEditsRequireTheLoadedRevision() throws Exception {
|
||||||
|
final long id = create();
|
||||||
|
final String path = "/api/dance-schools/" + id;
|
||||||
|
try (final java.util.concurrent.ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||||
|
final java.util.concurrent.Future<Integer> first = executor.submit(() -> rename(path, "First", 0));
|
||||||
|
final java.util.concurrent.Future<Integer> second = executor.submit(() -> rename(path, "Second", 0));
|
||||||
|
final List<Integer> statuses = List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS));
|
||||||
|
assertTrue(statuses.containsAll(List.of(200, 409)));
|
||||||
|
given().contentType("application/json")
|
||||||
|
.body("{\"name\":\"Missing revision\"}")
|
||||||
|
.put(path)
|
||||||
|
.then()
|
||||||
|
.statusCode(409);
|
||||||
|
given().get(path + "/data").then().statusCode(200);
|
||||||
|
assertEquals(1, given().get(path).jsonPath().getInt("revision"));
|
||||||
|
} finally {
|
||||||
|
delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void detachedRelationalGraphRetainsIdsReferencesDraftAndFinalState() throws Exception {
|
||||||
|
final long id = create();
|
||||||
|
try {
|
||||||
|
final Show show = fixture(id);
|
||||||
|
transactions.background(() -> {
|
||||||
|
final DanceSchool loaded = schools.school(id);
|
||||||
|
assertEquals(1, loaded.getDancers().size());
|
||||||
|
assertEquals(
|
||||||
|
Set.of(DanceGroupStyle.JAZZ),
|
||||||
|
loaded.getDanceGroups().iterator().next().getStyles());
|
||||||
|
assertEquals(show.getId(), loaded.getShows().iterator().next().getId());
|
||||||
|
assertTrue(loaded.getShows().iterator().next().isFinalized());
|
||||||
|
assertEquals(
|
||||||
|
loaded.getDanceGroups().iterator().next(),
|
||||||
|
loaded.getShows()
|
||||||
|
.iterator()
|
||||||
|
.next()
|
||||||
|
.getDayParts()
|
||||||
|
.getFirst()
|
||||||
|
.getDanceGroups()
|
||||||
|
.getFirst());
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
given().get("/api/dance-schools/" + id + "/export").then().statusCode(200);
|
||||||
|
} finally {
|
||||||
|
delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void deferredConstraintFailureRollsBackDomainAndCompletedStatusTogether() throws Exception {
|
||||||
|
final long id = create();
|
||||||
|
try {
|
||||||
|
final Show show = fixture(id);
|
||||||
|
final SchedulingRun run = SchedulingRun.started(id, show.getId(), show.getName());
|
||||||
|
database.transaction(() -> {
|
||||||
|
runs.save(run);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
final java.util.concurrent.atomic.AtomicBoolean published = new java.util.concurrent.atomic.AtomicBoolean();
|
||||||
|
assertThrows(StorageException.class, () -> damageResult(id, show, run, published));
|
||||||
|
assertFalse(published.get());
|
||||||
|
database.transaction(() -> {
|
||||||
|
assertEquals(
|
||||||
|
"Persistent school",
|
||||||
|
reader.load().stream()
|
||||||
|
.filter((final DanceSchool school) -> school.getId() == id)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow()
|
||||||
|
.getName());
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
entities.find(
|
||||||
|
nl.agilecc.agiledancers.repository.entity.DayPartEntity.class,
|
||||||
|
new nl.agilecc.agiledancers.repository.entity.DayPartId(id, show.getId(), 0))
|
||||||
|
.getGroupIds()
|
||||||
|
.size());
|
||||||
|
assertEquals(
|
||||||
|
SchedulingState.RUNNING,
|
||||||
|
runs.load().stream()
|
||||||
|
.filter((final SchedulingRun item) ->
|
||||||
|
item.runId().equals(run.runId()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow()
|
||||||
|
.state());
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void recoveryRetainsRunIdentityProgressAndExistingAssignments() throws Exception {
|
||||||
|
final long id = create();
|
||||||
|
try {
|
||||||
|
final Show show = fixture(id);
|
||||||
|
final SchedulingRun run = SchedulingRun.started(id, show.getId(), show.getName())
|
||||||
|
.progressed(new SchedulingProgress(SchedulingPhase.SPREAD, true));
|
||||||
|
database.transaction(() -> {
|
||||||
|
runs.save(run);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
database.transaction(() -> {
|
||||||
|
runs.recover();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
database.transaction(() -> {
|
||||||
|
final SchedulingRun recovered = runs.load().stream()
|
||||||
|
.filter((final SchedulingRun item) -> item.runId().equals(run.runId()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
assertEquals(SchedulingState.INTERRUPTED, recovered.state());
|
||||||
|
assertEquals(run.progress(), recovered.progress());
|
||||||
|
assertTrue(recovered.finishedAt() != null);
|
||||||
|
assertTrue(recovered.message().contains("behouden"));
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void aFailedReplacementImportRetainsThePreviousGraphAndRevision() throws Exception {
|
||||||
|
final long id = create();
|
||||||
|
try {
|
||||||
|
fixture(id);
|
||||||
|
final nl.agilecc.agiledancers.service.model.SchoolSnapshot before =
|
||||||
|
transactions.background(() -> schools.exportSchool(id));
|
||||||
|
assertThrows(
|
||||||
|
nl.agilecc.agiledancers.service.ManagementException.class,
|
||||||
|
() -> transactions.request(id, 1L, true, () -> {
|
||||||
|
final DanceSchool imported = schools.importSchool(before, id);
|
||||||
|
imported.getDancers().iterator().next().setBirthDate(null);
|
||||||
|
return imported;
|
||||||
|
}));
|
||||||
|
transactions.background(() -> {
|
||||||
|
assertEquals(before, schools.exportSchool(id));
|
||||||
|
assertEquals(1, schools.school(id).getRevision());
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void changedSchoolInputsRejectAnOlderSolverSnapshot() throws Exception {
|
||||||
|
final long id = create();
|
||||||
|
try {
|
||||||
|
final Show show = fixture(id);
|
||||||
|
final nl.agilecc.agiledancers.model.scheduling.SchedulingInput input =
|
||||||
|
transactions.background(() -> schools.beginScheduling(id, show.getId()));
|
||||||
|
transactions.request(id, 1L, true, () -> schools.updateSchool(id, "Changed"));
|
||||||
|
final nl.agilecc.agiledancers.service.ManagementException failure = assertThrows(
|
||||||
|
nl.agilecc.agiledancers.service.ManagementException.class,
|
||||||
|
() -> transactions.background(() -> {
|
||||||
|
schools.finishScheduling(
|
||||||
|
id,
|
||||||
|
input,
|
||||||
|
new nl.agilecc.agiledancers.model.scheduling.SchedulingResult(
|
||||||
|
true, List.of(List.of())));
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
assertEquals(nl.agilecc.agiledancers.service.ManagementExceptionKind.CONFLICT, failure.getKind());
|
||||||
|
transactions.background(() -> {
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
schools.show(id, show.getId())
|
||||||
|
.getDayParts()
|
||||||
|
.getFirst()
|
||||||
|
.getDanceGroups()
|
||||||
|
.size());
|
||||||
|
schools.endScheduling(show.getId());
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void hibernateVersionRejectsDetachedStaleEntities() throws Exception {
|
||||||
|
final long id = create();
|
||||||
|
try {
|
||||||
|
final nl.agilecc.agiledancers.repository.entity.SchoolEntity stale = database.transaction(
|
||||||
|
() -> entities.find(nl.agilecc.agiledancers.repository.entity.SchoolEntity.class, id));
|
||||||
|
database.transaction(() -> {
|
||||||
|
entities.find(nl.agilecc.agiledancers.repository.entity.SchoolEntity.class, id)
|
||||||
|
.setName("Confirmed");
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
stale.setName("Stale");
|
||||||
|
assertThrows(
|
||||||
|
nl.agilecc.agiledancers.repository.RevisionConflictException.class,
|
||||||
|
() -> database.transaction(() -> entities.merge(stale)));
|
||||||
|
transactions.background(() -> {
|
||||||
|
assertEquals("Confirmed", schools.school(id).getName());
|
||||||
|
assertEquals(1, schools.school(id).getRevision());
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void childOnlyEditsAdvanceTheAggregateVersionOnce() throws Exception {
|
||||||
|
final long id = create();
|
||||||
|
try {
|
||||||
|
fixture(id);
|
||||||
|
transactions.request(id, 1L, true, () -> {
|
||||||
|
final long dancerId = schools.dancers(id).getFirst().getId();
|
||||||
|
return schools.updateDancer(id, dancerId, "Updated dancer", LocalDate.of(2000, 1, 1));
|
||||||
|
});
|
||||||
|
transactions.background(() -> {
|
||||||
|
assertEquals(2, schools.school(id).getRevision());
|
||||||
|
assertEquals("Updated dancer", schools.dancers(id).getFirst().getName());
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void hibernateRetainsAssignmentsWhenDayPartsAreReordered() throws Exception {
|
||||||
|
final long id = create();
|
||||||
|
try {
|
||||||
|
final Show show = fixture(id);
|
||||||
|
final DayPart added = DayPart.builder()
|
||||||
|
.date(LocalDate.of(2027, 1, 2))
|
||||||
|
.type(DayPartType.EVENING)
|
||||||
|
.danceGroups(List.of())
|
||||||
|
.build();
|
||||||
|
transactions.request(
|
||||||
|
id,
|
||||||
|
1L,
|
||||||
|
true,
|
||||||
|
() -> schools.updateShow(
|
||||||
|
id,
|
||||||
|
show.getId(),
|
||||||
|
"Two parts",
|
||||||
|
List.of(show.getDayParts().getFirst(), added),
|
||||||
|
null));
|
||||||
|
transactions.request(id, 2L, true, () -> {
|
||||||
|
final Show current = schools.show(id, show.getId());
|
||||||
|
return schools.updateShow(
|
||||||
|
id,
|
||||||
|
show.getId(),
|
||||||
|
"Swapped",
|
||||||
|
List.of(
|
||||||
|
current.getDayParts().getLast(),
|
||||||
|
current.getDayParts().getFirst()),
|
||||||
|
null);
|
||||||
|
});
|
||||||
|
transactions.background(() -> {
|
||||||
|
final Show restored = schools.show(id, show.getId());
|
||||||
|
assertEquals(added.getDate(), restored.getDayParts().getFirst().getDate());
|
||||||
|
assertEquals(
|
||||||
|
1, restored.getDayParts().getLast().getDanceGroups().size());
|
||||||
|
assertEquals(3, schools.school(id).getRevision());
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void damageResult(
|
||||||
|
final long id,
|
||||||
|
final Show show,
|
||||||
|
final SchedulingRun run,
|
||||||
|
final java.util.concurrent.atomic.AtomicBoolean published)
|
||||||
|
throws Exception {
|
||||||
|
database.transaction(() -> {
|
||||||
|
entities.find(nl.agilecc.agiledancers.repository.entity.SchoolEntity.class, id)
|
||||||
|
.setName("Uncommitted");
|
||||||
|
entities.find(
|
||||||
|
nl.agilecc.agiledancers.repository.entity.DayPartEntity.class,
|
||||||
|
new nl.agilecc.agiledancers.repository.entity.DayPartId(id, show.getId(), 0))
|
||||||
|
.getGroupIds()
|
||||||
|
.clear();
|
||||||
|
runs.save(run.completed(SchedulingState.OPTIMAL, "Uncommitted", new SchedulingSummary(0, 0, 0)));
|
||||||
|
database.afterCommit(() -> published.set(true));
|
||||||
|
// This dancer FK is deferred: work returns normally, and commit itself must fail.
|
||||||
|
entities.find(
|
||||||
|
nl.agilecc.agiledancers.repository.entity.GroupEntity.class,
|
||||||
|
new nl.agilecc.agiledancers.repository.entity.SchoolMemberId(
|
||||||
|
id,
|
||||||
|
show.getDayParts()
|
||||||
|
.getFirst()
|
||||||
|
.getDanceGroups()
|
||||||
|
.getFirst()
|
||||||
|
.getId()))
|
||||||
|
.getDancerIds()
|
||||||
|
.add(999999L);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private long create() throws Exception {
|
||||||
|
return (Long) transactions.request(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
() -> schools.createSchool("Persistent school").getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Show fixture(final long id) throws Exception {
|
||||||
|
return (Show) transactions.request(id, 0L, true, () -> {
|
||||||
|
final long dancer =
|
||||||
|
schools.createDancer(id, "Dancer", LocalDate.of(2000, 1, 1)).getId();
|
||||||
|
final long group = schools.createGroup(
|
||||||
|
id, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of(DanceGroupStyle.JAZZ)))
|
||||||
|
.getId();
|
||||||
|
schools.addGroupDancer(id, group, dancer);
|
||||||
|
final Show show = schools.createShow(
|
||||||
|
id,
|
||||||
|
"Saved show",
|
||||||
|
List.of(DayPart.builder()
|
||||||
|
.date(LocalDate.of(2027, 1, 1))
|
||||||
|
.type(DayPartType.MORNING)
|
||||||
|
.danceGroups(List.of(schools.group(id, group)))
|
||||||
|
.build()),
|
||||||
|
null);
|
||||||
|
show.setFinalized(true);
|
||||||
|
return show;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private int rename(final String path, final String name, final long revision) {
|
||||||
|
return given().header("X-School-Revision", revision)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(java.util.Map.of("name", name))
|
||||||
|
.put(path)
|
||||||
|
.statusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void delete(final long id) {
|
||||||
|
transactions.background(() -> {
|
||||||
|
writer.delete(id);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package nl.agilecc.agiledancers;
|
||||||
|
|
||||||
|
import io.restassured.RestAssured;
|
||||||
|
import io.restassured.response.Response;
|
||||||
|
import io.restassured.specification.FilterableRequestSpecification;
|
||||||
|
import io.restassured.specification.RequestSpecification;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/** Existing endpoint scenarios use fresh revisions; concurrency tests deliberately send explicit stale revisions. */
|
||||||
|
public class RevisionRequests {
|
||||||
|
private static final Pattern SCHOOL = Pattern.compile("/api/dance-schools/(\\d+)(?:/|$)");
|
||||||
|
private static final Pattern REPLACEMENT = Pattern.compile("[?&]replaceId=(\\d+)");
|
||||||
|
|
||||||
|
public static RequestSpecification given() {
|
||||||
|
return RestAssured.given()
|
||||||
|
.filter(
|
||||||
|
(final FilterableRequestSpecification request,
|
||||||
|
final io.restassured.specification.FilterableResponseSpecification response,
|
||||||
|
final io.restassured.filter.FilterContext context) -> {
|
||||||
|
addRevision(request);
|
||||||
|
return context.next(request, response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addRevision(final FilterableRequestSpecification request) {
|
||||||
|
final String id = schoolId(request);
|
||||||
|
if (!request.getMethod().equals("GET")
|
||||||
|
&& id != null
|
||||||
|
&& !request.getHeaders().hasHeaderWithName("X-School-Revision")) {
|
||||||
|
final Response school = RestAssured.given().get("/api/dance-schools/" + id);
|
||||||
|
if (school.statusCode() == 200) {
|
||||||
|
request.header("X-School-Revision", school.jsonPath().getLong("revision"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String schoolId(final FilterableRequestSpecification request) {
|
||||||
|
final Matcher school = SCHOOL.matcher(request.getURI());
|
||||||
|
if (school.find()) {
|
||||||
|
return school.group(1);
|
||||||
|
}
|
||||||
|
final Matcher replacement = REPLACEMENT.matcher(request.getURI());
|
||||||
|
if (replacement.find()) {
|
||||||
|
return replacement.group(1);
|
||||||
|
}
|
||||||
|
final Object parameter = request.getQueryParams().get("replaceId");
|
||||||
|
return parameter == null ? null : parameter.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package nl.agilecc.agiledancers;
|
package nl.agilecc.agiledancers;
|
||||||
|
|
||||||
import static io.restassured.RestAssured.given;
|
import static nl.agilecc.agiledancers.RevisionRequests.given;
|
||||||
import static org.hamcrest.Matchers.empty;
|
import static org.hamcrest.Matchers.empty;
|
||||||
import static org.hamcrest.Matchers.equalTo;
|
import static org.hamcrest.Matchers.equalTo;
|
||||||
import static org.hamcrest.Matchers.hasSize;
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package nl.agilecc.agiledancers;
|
package nl.agilecc.agiledancers;
|
||||||
|
|
||||||
import static io.restassured.RestAssured.given;
|
import static nl.agilecc.agiledancers.RevisionRequests.given;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
@@ -36,12 +36,14 @@ import org.junit.jupiter.api.Test;
|
|||||||
class SchedulingEventsTest {
|
class SchedulingEventsTest {
|
||||||
private @Inject SchoolManagementService schools;
|
private @Inject SchoolManagementService schools;
|
||||||
private @Inject ObjectMapper json;
|
private @Inject ObjectMapper json;
|
||||||
|
private @Inject nl.agilecc.agiledancers.service.SchoolTransactions transactions;
|
||||||
private @TestHTTPResource URI base;
|
private @TestHTTPResource URI base;
|
||||||
|
|
||||||
public @Test void streamStartsWithSnapshotAndPushesCompletionAndReconnectSnapshot() throws Exception {
|
public @Test void streamStartsWithSnapshotAndPushesCompletionAndReconnectSnapshot() throws Exception {
|
||||||
final long school = schools.createSchool("SSE school").getId();
|
final long school = (Long) transactions.request(
|
||||||
|
null, null, true, () -> schools.createSchool("SSE school").getId());
|
||||||
try {
|
try {
|
||||||
final Show show = show(school);
|
final Show show = (Show) transactions.request(school, 0L, true, () -> show(school));
|
||||||
final String path = "/api/dance-schools/" + school + "/shows/" + show.getId() + "/schedule";
|
final String path = "/api/dance-schools/" + school + "/shows/" + show.getId() + "/schedule";
|
||||||
given().get(path).then().statusCode(204);
|
given().get(path).then().statusCode(204);
|
||||||
try (final HttpClient client = HttpClient.newHttpClient();
|
try (final HttpClient client = HttpClient.newHttpClient();
|
||||||
@@ -64,7 +66,7 @@ class SchedulingEventsTest {
|
|||||||
}
|
}
|
||||||
assertReconnectContains(show.getId());
|
assertReconnectContains(show.getId());
|
||||||
} finally {
|
} finally {
|
||||||
schools.deleteSchool(school);
|
given().delete("/api/dance-schools/" + school);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package nl.agilecc.agiledancers;
|
package nl.agilecc.agiledancers;
|
||||||
|
|
||||||
import static io.restassured.RestAssured.given;
|
import static nl.agilecc.agiledancers.RevisionRequests.given;
|
||||||
import static org.hamcrest.Matchers.contains;
|
import static org.hamcrest.Matchers.contains;
|
||||||
import static org.hamcrest.Matchers.containsInAnyOrder;
|
import static org.hamcrest.Matchers.containsInAnyOrder;
|
||||||
import static org.hamcrest.Matchers.empty;
|
import static org.hamcrest.Matchers.empty;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package nl.agilecc.agiledancers;
|
package nl.agilecc.agiledancers;
|
||||||
|
|
||||||
import static io.restassured.RestAssured.given;
|
import static nl.agilecc.agiledancers.RevisionRequests.given;
|
||||||
import static org.hamcrest.Matchers.containsString;
|
import static org.hamcrest.Matchers.containsString;
|
||||||
import static org.hamcrest.Matchers.emptyOrNullString;
|
import static org.hamcrest.Matchers.emptyOrNullString;
|
||||||
import static org.hamcrest.Matchers.equalTo;
|
import static org.hamcrest.Matchers.equalTo;
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ import nl.agilecc.agiledancers.model.Show;
|
|||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
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.specifications.DanceGroupType;
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||||
import nl.agilecc.agiledancers.service.model.GroupDetails;
|
import nl.agilecc.agiledancers.service.model.GroupDetails;
|
||||||
import nl.agilecc.agiledancers.service.model.SchedulingRun;
|
|
||||||
import nl.agilecc.agiledancers.service.model.SchedulingState;
|
|
||||||
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
||||||
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
|
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -136,6 +136,71 @@ class SchedulingRunsTest {
|
|||||||
schools.endScheduling(schools.beginScheduling(school, show.getId()).showId());
|
schools.endScheduling(schools.beginScheduling(school, show.getId()).showId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public @Test void databaseRecoveryPersistsPendingFailureWithoutRestart() throws Exception {
|
||||||
|
final Show show = show();
|
||||||
|
final var entered = new CountDownLatch(1);
|
||||||
|
final var release = new CountDownLatch(1);
|
||||||
|
final var persistence = new OutagePersistence();
|
||||||
|
final var runs = new SchedulingRuns(schools, blocking(entered, release));
|
||||||
|
final java.lang.reflect.Field field = SchedulingRuns.class.getDeclaredField("persistence");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(runs, persistence);
|
||||||
|
try {
|
||||||
|
final SchedulingRun started = runs.start(school, show.getId());
|
||||||
|
assertTrue(entered.await(10, TimeUnit.SECONDS));
|
||||||
|
persistence.available.set(false);
|
||||||
|
release.countDown();
|
||||||
|
assertTrue(persistence.unavailable.await(10, TimeUnit.SECONDS));
|
||||||
|
assertEquals(started, persistence.stored.get());
|
||||||
|
assertEquals(started, runs.status(school, show.getId()));
|
||||||
|
assertThrows(ManagementException.class, () -> runs.start(school, show.getId()));
|
||||||
|
persistence.available.set(true);
|
||||||
|
final SchedulingRun completed = await(runs, show);
|
||||||
|
assertEquals(SchedulingState.SOLVER_FAILED, completed.state());
|
||||||
|
assertEquals(started.runId(), completed.runId());
|
||||||
|
assertEquals(completed, persistence.stored.get());
|
||||||
|
schools.endScheduling(schools.beginScheduling(school, show.getId()).showId());
|
||||||
|
} finally {
|
||||||
|
persistence.available.set(true);
|
||||||
|
release.countDown();
|
||||||
|
runs.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class OutagePersistence extends SchedulingPersistence {
|
||||||
|
private final CountDownLatch unavailable = new CountDownLatch(2);
|
||||||
|
private final java.util.concurrent.atomic.AtomicBoolean available =
|
||||||
|
new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||||
|
private final AtomicReference<SchedulingRun> stored = new AtomicReference<>();
|
||||||
|
|
||||||
|
private OutagePersistence() {
|
||||||
|
super(null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Override <T> T work(final java.util.concurrent.Callable<T> action) {
|
||||||
|
if (!available.get()) {
|
||||||
|
unavailable.countDown();
|
||||||
|
throw new nl.agilecc.agiledancers.repository.StorageException(new IllegalStateException("offline"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return action.call();
|
||||||
|
} catch (final RuntimeException failure) {
|
||||||
|
throw failure;
|
||||||
|
} catch (final Exception failure) {
|
||||||
|
throw new IllegalStateException(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Override void accepted(final SchedulingRun run, final Runnable launch, final Runnable rollback) {
|
||||||
|
save(run, launch);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Override void save(final SchedulingRun run, final Runnable publication) {
|
||||||
|
stored.set(run);
|
||||||
|
publication.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Show show() {
|
private Show show() {
|
||||||
final long group = schools.createGroup(
|
final long group = schools.createGroup(
|
||||||
school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()))
|
school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, Page } from '@playwright/test';
|
import { test, expect, Page, APIRequestContext } from '@playwright/test';
|
||||||
import type { School } from '../src/app/core/api-models';
|
import type { School } from '../src/app/core/api-models';
|
||||||
|
|
||||||
const backend = process.env['PLAYWRIGHT_BASE_URL'] || 'http://127.0.0.1:8080';
|
const backend = process.env['PLAYWRIGHT_BASE_URL'] || 'http://127.0.0.1:8080';
|
||||||
@@ -6,11 +6,23 @@ let schoolToDelete: number | undefined;
|
|||||||
|
|
||||||
test.afterEach(async ({ request }) => {
|
test.afterEach(async ({ request }) => {
|
||||||
if (schoolToDelete !== undefined) {
|
if (schoolToDelete !== undefined) {
|
||||||
await request.delete(`${backend}/api/dance-schools/${schoolToDelete}`);
|
await request.delete(`${backend}/api/dance-schools/${schoolToDelete}`, {
|
||||||
|
headers: await revision(request, schoolToDelete),
|
||||||
|
});
|
||||||
schoolToDelete = undefined;
|
schoolToDelete = undefined;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function revision(
|
||||||
|
request: APIRequestContext,
|
||||||
|
schoolId: number,
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
const school = (await (
|
||||||
|
await request.get(`${backend}/api/dance-schools/${schoolId}`)
|
||||||
|
).json()) as School;
|
||||||
|
return { 'X-School-Revision': String(school.revision) };
|
||||||
|
}
|
||||||
|
|
||||||
async function tab(page: Page, label: string) {
|
async function tab(page: Page, label: string) {
|
||||||
await page
|
await page
|
||||||
.getByRole('navigation', { name: 'Dansschool onderdelen' })
|
.getByRole('navigation', { name: 'Dansschool onderdelen' })
|
||||||
@@ -113,6 +125,7 @@ test('manages a school, shared memberships and shows through the real REST API',
|
|||||||
const dancerResponse = await request.post(
|
const dancerResponse = await request.post(
|
||||||
`${backend}/api/dance-schools/${schoolToDelete}/dancers`,
|
`${backend}/api/dance-schools/${schoolToDelete}/dancers`,
|
||||||
{
|
{
|
||||||
|
headers: await revision(request, schoolToDelete),
|
||||||
data: { name, birthDate: '1990-01-01' },
|
data: { name, birthDate: '1990-01-01' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -120,14 +133,17 @@ test('manages a school, shared memberships and shows through the real REST API',
|
|||||||
const groupResponse = await request.post(
|
const groupResponse = await request.post(
|
||||||
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups`,
|
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups`,
|
||||||
{
|
{
|
||||||
|
headers: await revision(request, schoolToDelete),
|
||||||
data: { category: name, type: 'REGULAR', startAge: 0, endAge: 99, styles: ['MODERN'] },
|
data: { category: name, type: 'REGULAR', startAge: 0, endAge: 99, styles: ['MODERN'] },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const group = (await groupResponse.json()) as { id: number };
|
const group = (await groupResponse.json()) as { id: number };
|
||||||
await request.put(
|
await request.put(
|
||||||
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups/${group.id}/dancers/${dancer.id}`,
|
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups/${group.id}/dancers/${dancer.id}`,
|
||||||
|
{ headers: await revision(request, schoolToDelete) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await page.reload();
|
||||||
await tab(page, 'Shows');
|
await tab(page, 'Shows');
|
||||||
await page.getByRole('button', { name: 'Show toevoegen' }).click();
|
await page.getByRole('button', { name: 'Show toevoegen' }).click();
|
||||||
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow');
|
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow');
|
||||||
@@ -214,12 +230,14 @@ test('links a coach and finalizes performances spread across multiple dates', as
|
|||||||
schoolToDelete = school.id;
|
schoolToDelete = school.id;
|
||||||
const base = `${backend}/api/dance-schools/${school.id}`;
|
const base = `${backend}/api/dance-schools/${school.id}`;
|
||||||
const dancerResponse = await request.post(`${base}/dancers`, {
|
const dancerResponse = await request.post(`${base}/dancers`, {
|
||||||
|
headers: await revision(request, school.id),
|
||||||
data: { name: 'Dancing coach', birthDate: '1990-01-01' },
|
data: { name: 'Dancing coach', birthDate: '1990-01-01' },
|
||||||
});
|
});
|
||||||
const dancer = (await dancerResponse.json()) as { id: number };
|
const dancer = (await dancerResponse.json()) as { id: number };
|
||||||
const groupIds: number[] = [];
|
const groupIds: number[] = [];
|
||||||
for (const name of ['Preschool', 'Adults']) {
|
for (const name of ['Preschool', 'Adults']) {
|
||||||
const response = await request.post(`${base}/dance-groups`, {
|
const response = await request.post(`${base}/dance-groups`, {
|
||||||
|
headers: await revision(request, school.id),
|
||||||
data: {
|
data: {
|
||||||
category: name,
|
category: name,
|
||||||
type: name === 'Preschool' ? 'PRE_SCHOOLER' : 'REGULAR',
|
type: name === 'Preschool' ? 'PRE_SCHOOLER' : 'REGULAR',
|
||||||
@@ -230,9 +248,12 @@ test('links a coach and finalizes performances spread across multiple dates', as
|
|||||||
});
|
});
|
||||||
const group = (await response.json()) as { id: number };
|
const group = (await response.json()) as { id: number };
|
||||||
groupIds.push(group.id);
|
groupIds.push(group.id);
|
||||||
await request.put(`${base}/dance-groups/${group.id}/dancers/${dancer.id}`);
|
await request.put(`${base}/dance-groups/${group.id}/dancers/${dancer.id}`, {
|
||||||
|
headers: await revision(request, school.id),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const showResponse = await request.post(`${base}/shows`, {
|
const showResponse = await request.post(`${base}/shows`, {
|
||||||
|
headers: await revision(request, school.id),
|
||||||
data: {
|
data: {
|
||||||
name: 'Coach across dates',
|
name: 'Coach across dates',
|
||||||
preferredDayParts: 1,
|
preferredDayParts: 1,
|
||||||
@@ -244,6 +265,7 @@ test('links a coach and finalizes performances spread across multiple dates', as
|
|||||||
});
|
});
|
||||||
const show = (await showResponse.json()) as { id: number };
|
const show = (await showResponse.json()) as { id: number };
|
||||||
await request.put(`${base}/shows/${show.id}/assignments`, {
|
await request.put(`${base}/shows/${show.id}/assignments`, {
|
||||||
|
headers: await revision(request, school.id),
|
||||||
data: { assignments: groupIds.map((id) => [id]) },
|
data: { assignments: groupIds.map((id) => [id]) },
|
||||||
});
|
});
|
||||||
await page.goto(`/schools/${school.id}/shows`);
|
await page.goto(`/schools/${school.id}/shows`);
|
||||||
@@ -271,3 +293,39 @@ test('shows a useful connection error and recovers on retry', async ({ page }) =
|
|||||||
await page.getByRole('button', { name: 'Opnieuw proberen' }).click();
|
await page.getByRole('button', { name: 'Opnieuw proberen' }).click();
|
||||||
await expect(page.getByRole('heading', { name: /Mijn dansscholen/ })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /Mijn dansscholen/ })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects a stale editor in another screen and allows reload and retry', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.post(`${backend}/api/dance-schools`, {
|
||||||
|
data: { name: `Concurrent school ${Date.now()}` },
|
||||||
|
});
|
||||||
|
const school = (await response.json()) as School;
|
||||||
|
schoolToDelete = school.id;
|
||||||
|
const other = await page.context().newPage();
|
||||||
|
try {
|
||||||
|
for (const screen of [page, other]) {
|
||||||
|
await screen.goto(`/schools/${school.id}/overview`);
|
||||||
|
await screen.getByRole('button', { name: 'School bewerken' }).click();
|
||||||
|
}
|
||||||
|
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('First confirmed edit');
|
||||||
|
await other.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Stale edit');
|
||||||
|
await save(page);
|
||||||
|
await other.getByRole('dialog').getByRole('button', { name: 'Opslaan', exact: true }).click();
|
||||||
|
await expect(other.getByRole('dialog').getByRole('alert')).toContainText('Herlaad');
|
||||||
|
await expect(other.getByRole('dialog').getByLabel('Naam', { exact: true })).toHaveValue(
|
||||||
|
'Stale edit',
|
||||||
|
);
|
||||||
|
await other.reload();
|
||||||
|
await expect(
|
||||||
|
other.getByRole('heading', { name: 'First confirmed edit', exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await other.getByRole('button', { name: 'School bewerken' }).click();
|
||||||
|
await other.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Reloaded edit');
|
||||||
|
await save(other);
|
||||||
|
await expect(other.getByRole('heading', { name: 'Reloaded edit', exact: true })).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await other.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export interface School {
|
export interface School {
|
||||||
id: number;
|
id: number;
|
||||||
|
revision: number;
|
||||||
name: string;
|
name: string;
|
||||||
dancerIds: number[];
|
dancerIds: number[];
|
||||||
danceGroupIds: number[];
|
danceGroupIds: number[];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { DestroyRef, inject, Injectable, signal } from '@angular/core';
|
|||||||
|
|
||||||
export type SchedulingState =
|
export type SchedulingState =
|
||||||
| 'RUNNING'
|
| 'RUNNING'
|
||||||
|
| 'INTERRUPTED'
|
||||||
| 'OPTIMAL'
|
| 'OPTIMAL'
|
||||||
| 'VALID'
|
| 'VALID'
|
||||||
| 'STALE'
|
| 'STALE'
|
||||||
|
|||||||
@@ -18,13 +18,17 @@ describe('SchoolApi contract', () => {
|
|||||||
it('loads all resources from the same school', () => {
|
it('loads all resources from the same school', () => {
|
||||||
const received = vi.fn();
|
const received = vi.fn();
|
||||||
api.schoolData(7).subscribe(received);
|
api.schoolData(7).subscribe(received);
|
||||||
const school = { id: 7, name: 'Studio', dancerIds: [], danceGroupIds: [], showIds: [] };
|
const school = {
|
||||||
http.expectOne('/api/dance-schools/7').flush(school);
|
id: 7,
|
||||||
http.expectOne('/api/dance-schools/7/dancers').flush([]);
|
name: 'Studio',
|
||||||
http.expectOne('/api/dance-schools/7/dance-groups').flush([]);
|
revision: 0,
|
||||||
expect(received).not.toHaveBeenCalled();
|
dancerIds: [],
|
||||||
http.expectOne('/api/dance-schools/7/shows').flush([]);
|
danceGroupIds: [],
|
||||||
http.expectOne('/api/dance-schools/7/coaches').flush([]);
|
showIds: [],
|
||||||
|
};
|
||||||
|
http
|
||||||
|
.expectOne('/api/dance-schools/7/data')
|
||||||
|
.flush({ school, dancers: [], groups: [], shows: [], coaches: [] });
|
||||||
expect(received).toHaveBeenCalledWith({
|
expect(received).toHaveBeenCalledWith({
|
||||||
school,
|
school,
|
||||||
dancers: [],
|
dancers: [],
|
||||||
@@ -34,12 +38,32 @@ describe('SchoolApi contract', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends the loaded school revision without retrying a stale mutation', () => {
|
||||||
|
api.schoolData(7).subscribe();
|
||||||
|
http
|
||||||
|
.expectOne('/api/dance-schools/7/data')
|
||||||
|
.flush({ school: { id: 7, revision: 4 }, dancers: [], groups: [], shows: [], coaches: [] });
|
||||||
|
const failure = vi.fn();
|
||||||
|
api.saveDancer(7, { name: 'Sam', birthDate: '2010-05-12' }).subscribe({ error: failure });
|
||||||
|
const request = http.expectOne('/api/dance-schools/7/dancers');
|
||||||
|
expect(request.request.headers.get('X-School-Revision')).toBe('4');
|
||||||
|
request.flush({ message: 'Herlaad de gegevens' }, { status: 409, statusText: 'Conflict' });
|
||||||
|
expect(failure).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it('updates an existing entity even when its id is zero', () => {
|
it('updates an existing entity even when its id is zero', () => {
|
||||||
api.saveSchool({ name: 'Renamed' }, 0).subscribe();
|
api.saveSchool({ name: 'Renamed' }, 0).subscribe();
|
||||||
const request = http.expectOne('/api/dance-schools/0');
|
const request = http.expectOne('/api/dance-schools/0');
|
||||||
expect(request.request.method).toBe('PUT');
|
expect(request.request.method).toBe('PUT');
|
||||||
expect(request.request.body).toEqual({ name: 'Renamed' });
|
expect(request.request.body).toEqual({ name: 'Renamed' });
|
||||||
request.flush({ id: 0, name: 'Renamed', dancerIds: [], danceGroupIds: [], showIds: [] });
|
request.flush({
|
||||||
|
id: 0,
|
||||||
|
name: 'Renamed',
|
||||||
|
revision: 0,
|
||||||
|
dancerIds: [],
|
||||||
|
danceGroupIds: [],
|
||||||
|
showIds: [],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps group details separate from memberships', () => {
|
it('keeps group details separate from memberships', () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SchedulingRun } from './scheduling-status';
|
import { SchedulingRun } from './scheduling-status';
|
||||||
import { inject, Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { forkJoin, Observable } from 'rxjs';
|
import { Observable, tap } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
Coach,
|
Coach,
|
||||||
DanceGroup,
|
DanceGroup,
|
||||||
@@ -20,64 +20,114 @@ import {
|
|||||||
export class SchoolApi {
|
export class SchoolApi {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly base = '/api/dance-schools';
|
private readonly base = '/api/dance-schools';
|
||||||
|
private readonly revisions = new Map<number, number>();
|
||||||
|
|
||||||
schools(): Observable<School[]> {
|
schools(): Observable<School[]> {
|
||||||
return this.http.get<School[]>(this.base);
|
return this.http.get<School[]>(this.base).pipe(
|
||||||
|
tap((schools) => {
|
||||||
|
for (const school of schools) this.revisions.set(school.id, school.revision);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
schoolData(schoolId: number): Observable<SchoolData> {
|
schoolData(schoolId: number): Observable<SchoolData> {
|
||||||
const base = `${this.base}/${schoolId}`;
|
return this.http
|
||||||
return forkJoin({
|
.get<SchoolData>(`${this.base}/${schoolId}/data`)
|
||||||
school: this.http.get<School>(base),
|
.pipe(tap((data) => this.revisions.set(schoolId, data.school.revision)));
|
||||||
dancers: this.http.get<Dancer[]>(`${base}/dancers`),
|
|
||||||
groups: this.http.get<DanceGroup[]>(`${base}/dance-groups`),
|
|
||||||
shows: this.http.get<Show[]>(`${base}/shows`),
|
|
||||||
coaches: this.http.get<Coach[]>(`${base}/coaches`),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
saveSchool(body: SchoolRequest, id?: number): Observable<School> {
|
private options(schoolId: number, expected?: number): { headers: Record<string, string> } {
|
||||||
|
const revision = expected ?? this.revisions.get(schoolId);
|
||||||
|
return { headers: revision === undefined ? {} : { 'X-School-Revision': String(revision) } };
|
||||||
|
}
|
||||||
|
saveSchool(body: SchoolRequest, id?: number, revision?: number): Observable<School> {
|
||||||
return id === undefined
|
return id === undefined
|
||||||
? this.http.post<School>(this.base, body)
|
? this.http.post<School>(this.base, body)
|
||||||
: this.http.put<School>(`${this.base}/${id}`, body);
|
: this.http.put<School>(`${this.base}/${id}`, body, this.options(id, revision));
|
||||||
}
|
}
|
||||||
saveDancer(schoolId: number, body: DancerRequest, id?: number): Observable<Dancer> {
|
saveDancer(
|
||||||
return this.save(`${this.base}/${schoolId}/dancers`, body, id);
|
schoolId: number,
|
||||||
|
body: DancerRequest,
|
||||||
|
id?: number,
|
||||||
|
revision?: number,
|
||||||
|
): Observable<Dancer> {
|
||||||
|
return this.save(`${this.base}/${schoolId}/dancers`, body, schoolId, id, revision);
|
||||||
}
|
}
|
||||||
saveGroup(schoolId: number, body: GroupRequest, id?: number): Observable<DanceGroup> {
|
saveGroup(
|
||||||
return this.save(`${this.base}/${schoolId}/dance-groups`, body, id);
|
schoolId: number,
|
||||||
|
body: GroupRequest,
|
||||||
|
id?: number,
|
||||||
|
revision?: number,
|
||||||
|
): Observable<DanceGroup> {
|
||||||
|
return this.save(`${this.base}/${schoolId}/dance-groups`, body, schoolId, id, revision);
|
||||||
}
|
}
|
||||||
saveShow(schoolId: number, body: ShowRequest, id?: number): Observable<Show> {
|
saveShow(schoolId: number, body: ShowRequest, id?: number, revision?: number): Observable<Show> {
|
||||||
return this.save(`${this.base}/${schoolId}/shows`, body, id);
|
return this.save(`${this.base}/${schoolId}/shows`, body, schoolId, id, revision);
|
||||||
}
|
}
|
||||||
schedule(schoolId: number, showId: number): Observable<SchedulingRun> {
|
schedule(schoolId: number, showId: number): Observable<SchedulingRun> {
|
||||||
return this.http.post<SchedulingRun>(`${this.base}/${schoolId}/shows/${showId}/schedule`, null);
|
return this.http.post<SchedulingRun>(
|
||||||
|
`${this.base}/${schoolId}/shows/${showId}/schedule`,
|
||||||
|
null,
|
||||||
|
this.options(schoolId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
saveAssignments(schoolId: number, showId: number, assignments: number[][]): Observable<Show> {
|
saveAssignments(
|
||||||
return this.http.put<Show>(`${this.base}/${schoolId}/shows/${showId}/assignments`, {
|
schoolId: number,
|
||||||
assignments,
|
showId: number,
|
||||||
});
|
assignments: number[][],
|
||||||
|
revision?: number,
|
||||||
|
): Observable<Show> {
|
||||||
|
return this.http.put<Show>(
|
||||||
|
`${this.base}/${schoolId}/shows/${showId}/assignments`,
|
||||||
|
{
|
||||||
|
assignments,
|
||||||
|
},
|
||||||
|
this.options(schoolId, revision),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
finalize(schoolId: number, showId: number): Observable<Show> {
|
finalize(schoolId: number, showId: number, revision?: number): Observable<Show> {
|
||||||
return this.http.post<Show>(`${this.base}/${schoolId}/shows/${showId}/finalize`, null);
|
return this.http.post<Show>(
|
||||||
|
`${this.base}/${schoolId}/shows/${showId}/finalize`,
|
||||||
|
null,
|
||||||
|
this.options(schoolId, revision),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
linkCoach(schoolId: number, dancerId: number, coachId?: number): Observable<Coach> {
|
linkCoach(schoolId: number, dancerId: number, coachId?: number): Observable<Coach> {
|
||||||
return this.save<Coach>(`${this.base}/${schoolId}/coaches`, { dancerId }, coachId);
|
return this.save<Coach>(`${this.base}/${schoolId}/coaches`, { dancerId }, schoolId, coachId);
|
||||||
}
|
}
|
||||||
unlinkCoach(schoolId: number, coachId: number): Observable<void> {
|
unlinkCoach(schoolId: number, coachId: number): Observable<void> {
|
||||||
return this.http.delete<void>(`${this.base}/${schoolId}/coaches/${coachId}/dancer`);
|
return this.http.delete<void>(
|
||||||
|
`${this.base}/${schoolId}/coaches/${coachId}/dancer`,
|
||||||
|
this.options(schoolId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
delete(kind: ResourceKind, schoolId: number, id: number): Observable<void> {
|
delete(kind: ResourceKind, schoolId: number, id: number, revision?: number): Observable<void> {
|
||||||
const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[
|
const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[
|
||||||
kind
|
kind
|
||||||
];
|
];
|
||||||
const url =
|
const url =
|
||||||
kind === 'school' ? `${this.base}/${id}` : `${this.base}/${schoolId}${collection}/${id}`;
|
kind === 'school' ? `${this.base}/${id}` : `${this.base}/${schoolId}${collection}/${id}`;
|
||||||
return this.http.delete<void>(url);
|
return this.http.delete<void>(url, this.options(schoolId, revision));
|
||||||
}
|
}
|
||||||
membership(schoolId: number, groupId: number, dancerId: number, add: boolean): Observable<void> {
|
membership(
|
||||||
|
schoolId: number,
|
||||||
|
groupId: number,
|
||||||
|
dancerId: number,
|
||||||
|
add: boolean,
|
||||||
|
revision?: number,
|
||||||
|
): Observable<void> {
|
||||||
const url = `${this.base}/${schoolId}/dance-groups/${groupId}/dancers/${dancerId}`;
|
const url = `${this.base}/${schoolId}/dance-groups/${groupId}/dancers/${dancerId}`;
|
||||||
return add ? this.http.put<void>(url, null) : this.http.delete<void>(url);
|
return add
|
||||||
|
? this.http.put<void>(url, null, this.options(schoolId, revision))
|
||||||
|
: this.http.delete<void>(url, this.options(schoolId, revision));
|
||||||
}
|
}
|
||||||
private save<T>(url: string, body: unknown, id?: number): Observable<T> {
|
private save<T>(
|
||||||
return id === undefined ? this.http.post<T>(url, body) : this.http.put<T>(`${url}/${id}`, body);
|
url: string,
|
||||||
|
body: unknown,
|
||||||
|
schoolId: number,
|
||||||
|
id?: number,
|
||||||
|
revision?: number,
|
||||||
|
): Observable<T> {
|
||||||
|
return id === undefined
|
||||||
|
? this.http.post<T>(url, body, this.options(schoolId, revision))
|
||||||
|
: this.http.put<T>(`${url}/${id}`, body, this.options(schoolId, revision));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -477,6 +477,7 @@
|
|||||||
<app-entity-editor
|
<app-entity-editor
|
||||||
[target]="target"
|
[target]="target"
|
||||||
[schoolId]="context.school.id"
|
[schoolId]="context.school.id"
|
||||||
|
[revision]="context.school.revision"
|
||||||
[shows]="context.shows"
|
[shows]="context.shows"
|
||||||
(closed)="editor.set(null)"
|
(closed)="editor.set(null)"
|
||||||
(saved)="saved()"
|
(saved)="saved()"
|
||||||
@@ -487,6 +488,7 @@
|
|||||||
[show]="show"
|
[show]="show"
|
||||||
[groups]="context.groups"
|
[groups]="context.groups"
|
||||||
[schoolId]="context.school.id"
|
[schoolId]="context.school.id"
|
||||||
|
[revision]="context.school.revision"
|
||||||
(closed)="scheduleEditing.set(null)"
|
(closed)="scheduleEditing.set(null)"
|
||||||
(saved)="reload()"
|
(saved)="reload()"
|
||||||
/>
|
/>
|
||||||
@@ -495,6 +497,7 @@
|
|||||||
<app-group-members
|
<app-group-members
|
||||||
[group]="group"
|
[group]="group"
|
||||||
[schoolId]="context.school.id"
|
[schoolId]="context.school.id"
|
||||||
|
[revision]="context.school.revision"
|
||||||
[dancers]="context.dancers"
|
[dancers]="context.dancers"
|
||||||
(closed)="members.set(null)"
|
(closed)="members.set(null)"
|
||||||
(changed)="membershipChanged($event)"
|
(changed)="membershipChanged($event)"
|
||||||
|
|||||||
@@ -219,7 +219,10 @@ export class SchoolPage implements OnInit {
|
|||||||
.join(', ') || 'Nog geen groep'
|
.join(', ') || 'Nog geen groep'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
private deletionRevision?: number;
|
||||||
|
|
||||||
askDelete(target: DeleteTarget): void {
|
askDelete(target: DeleteTarget): void {
|
||||||
|
this.deletionRevision = this.data()?.school.revision;
|
||||||
this.deleteError.set('');
|
this.deleteError.set('');
|
||||||
if (target.kind === 'group') {
|
if (target.kind === 'group') {
|
||||||
const names =
|
const names =
|
||||||
@@ -255,7 +258,9 @@ export class SchoolPage implements OnInit {
|
|||||||
this.deleteBusy.set(true);
|
this.deleteBusy.set(true);
|
||||||
this.deleteError.set('');
|
this.deleteError.set('');
|
||||||
try {
|
try {
|
||||||
await firstValueFrom(this.api.delete(target.kind, schoolId, target.id));
|
await firstValueFrom(
|
||||||
|
this.api.delete(target.kind, schoolId, target.id, this.deletionRevision),
|
||||||
|
);
|
||||||
this.deleting.set(null);
|
this.deleting.set(null);
|
||||||
if (target.kind === 'school') await this.router.navigate(['/schools']);
|
if (target.kind === 'school') await this.router.navigate(['/schools']);
|
||||||
else {
|
else {
|
||||||
|
|||||||
@@ -50,11 +50,33 @@ describe('EntityEditor', () => {
|
|||||||
const saved = vi.fn();
|
const saved = vi.fn();
|
||||||
component.saved.subscribe(saved);
|
component.saved.subscribe(saved);
|
||||||
await component.save();
|
await component.save();
|
||||||
expect(api.saveSchool).toHaveBeenCalledWith({ name: 'Studio' }, undefined);
|
expect(api.saveSchool).toHaveBeenCalledWith({ name: 'Studio' }, undefined, undefined);
|
||||||
expect(saved).toHaveBeenCalledOnce();
|
expect(saved).toHaveBeenCalledOnce();
|
||||||
expect(component.busy()).toBe(false);
|
expect(component.busy()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retains the revision from opening the editor across background refreshes', async () => {
|
||||||
|
api.saveDancer.mockReturnValue(of({ id: 3 }));
|
||||||
|
const fixture = TestBed.createComponent(EntityEditor);
|
||||||
|
fixture.componentRef.setInput('target', {
|
||||||
|
kind: 'dancer',
|
||||||
|
value: { id: 3, name: 'Sam', birthDate: '2010-01-01' },
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('schoolId', 7);
|
||||||
|
fixture.componentRef.setInput('revision', 4);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.ngOnInit();
|
||||||
|
fixture.componentRef.setInput('revision', 5);
|
||||||
|
component.name = 'Sam edited';
|
||||||
|
await component.save();
|
||||||
|
expect(api.saveDancer).toHaveBeenCalledWith(
|
||||||
|
7,
|
||||||
|
{ name: 'Sam edited', birthDate: '2010-01-01' },
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the editor open and preserves input when saving fails', async () => {
|
it('keeps the editor open and preserves input when saving fails', async () => {
|
||||||
api.saveSchool.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 })));
|
api.saveSchool.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 })));
|
||||||
const component = editor({ kind: 'school' });
|
const component = editor({ kind: 'school' });
|
||||||
@@ -116,6 +138,7 @@ describe('EntityEditor', () => {
|
|||||||
7,
|
7,
|
||||||
expect.objectContaining({ preferredDayParts: 1 }),
|
expect.objectContaining({ preferredDayParts: 1 }),
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ import { DialogFrame } from './dialog-frame';
|
|||||||
})
|
})
|
||||||
export class EntityEditor implements OnInit {
|
export class EntityEditor implements OnInit {
|
||||||
readonly target = input.required<EditorTarget>();
|
readonly target = input.required<EditorTarget>();
|
||||||
|
readonly revision = input<number>();
|
||||||
|
private originalRevision?: number;
|
||||||
readonly schoolId = input<number>();
|
readonly schoolId = input<number>();
|
||||||
readonly shows = input<Show[]>([]);
|
readonly shows = input<Show[]>([]);
|
||||||
readonly closed = output<void>();
|
readonly closed = output<void>();
|
||||||
@@ -207,6 +209,7 @@ export class EntityEditor implements OnInit {
|
|||||||
return `${labels[this.target().kind]} ${this.target().value ? 'bewerken' : 'toevoegen'}`;
|
return `${labels[this.target().kind]} ${this.target().value ? 'bewerken' : 'toevoegen'}`;
|
||||||
}
|
}
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.originalRevision = this.revision();
|
||||||
const target = this.target();
|
const target = this.target();
|
||||||
if (!target.value) return;
|
if (!target.value) return;
|
||||||
if (target.kind === 'group') {
|
if (target.kind === 'group') {
|
||||||
@@ -298,13 +301,14 @@ export class EntityEditor implements OnInit {
|
|||||||
let request: Observable<unknown>;
|
let request: Observable<unknown>;
|
||||||
switch (target.kind) {
|
switch (target.kind) {
|
||||||
case 'school':
|
case 'school':
|
||||||
request = this.api.saveSchool({ name: this.name.trim() }, id);
|
request = this.api.saveSchool({ name: this.name.trim() }, id, this.originalRevision);
|
||||||
break;
|
break;
|
||||||
case 'dancer':
|
case 'dancer':
|
||||||
request = this.api.saveDancer(
|
request = this.api.saveDancer(
|
||||||
schoolId!,
|
schoolId!,
|
||||||
{ name: this.name.trim(), birthDate: this.date },
|
{ name: this.name.trim(), birthDate: this.date },
|
||||||
id,
|
id,
|
||||||
|
this.originalRevision,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'group':
|
case 'group':
|
||||||
@@ -318,6 +322,7 @@ export class EntityEditor implements OnInit {
|
|||||||
styles: this.selectedStyles,
|
styles: this.selectedStyles,
|
||||||
},
|
},
|
||||||
id,
|
id,
|
||||||
|
this.originalRevision,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'show':
|
case 'show':
|
||||||
@@ -329,6 +334,7 @@ export class EntityEditor implements OnInit {
|
|||||||
preferredDayParts: this.preferredDayParts,
|
preferredDayParts: this.preferredDayParts,
|
||||||
},
|
},
|
||||||
id,
|
id,
|
||||||
|
this.originalRevision,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ import { DialogFrame } from './dialog-frame';
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class GroupMembers implements OnInit {
|
export class GroupMembers implements OnInit {
|
||||||
|
readonly revision = input<number>();
|
||||||
|
private originalRevision?: number;
|
||||||
readonly schoolId = input.required<number>();
|
readonly schoolId = input.required<number>();
|
||||||
readonly group = input.required<DanceGroup>();
|
readonly group = input.required<DanceGroup>();
|
||||||
readonly dancers = input.required<Dancer[]>();
|
readonly dancers = input.required<Dancer[]>();
|
||||||
@@ -70,6 +72,7 @@ export class GroupMembers implements OnInit {
|
|||||||
);
|
);
|
||||||
private readonly api = inject(SchoolApi);
|
private readonly api = inject(SchoolApi);
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.originalRevision = this.revision();
|
||||||
this.memberIds.set([...this.group().dancerIds]);
|
this.memberIds.set([...this.group().dancerIds]);
|
||||||
}
|
}
|
||||||
async toggle(dancerId: number, event: Event): Promise<void> {
|
async toggle(dancerId: number, event: Event): Promise<void> {
|
||||||
@@ -82,7 +85,10 @@ export class GroupMembers implements OnInit {
|
|||||||
this.busy.set(true);
|
this.busy.set(true);
|
||||||
this.error.set('');
|
this.error.set('');
|
||||||
try {
|
try {
|
||||||
await firstValueFrom(this.api.membership(this.schoolId(), this.group().id, dancerId, add));
|
await firstValueFrom(
|
||||||
|
this.api.membership(this.schoolId(), this.group().id, dancerId, add, this.originalRevision),
|
||||||
|
);
|
||||||
|
if (this.originalRevision !== undefined) this.originalRevision++;
|
||||||
this.memberIds.update((ids) =>
|
this.memberIds.update((ids) =>
|
||||||
add ? [...ids, dancerId] : ids.filter((id) => id !== dancerId),
|
add ? [...ids, dancerId] : ids.filter((id) => id !== dancerId),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ describe('ScheduleEditor', () => {
|
|||||||
const draft = { ...show, finalized: false, violations: ['Group 1 requires 1 dayparts.'] };
|
const draft = { ...show, finalized: false, violations: ['Group 1 requires 1 dayparts.'] };
|
||||||
api.saveAssignments.mockReturnValue(of(draft));
|
api.saveAssignments.mockReturnValue(of(draft));
|
||||||
await component.save();
|
await component.save();
|
||||||
expect(api.saveAssignments).toHaveBeenCalledWith(7, 4, [[2], [3]]);
|
expect(api.saveAssignments).toHaveBeenCalledWith(7, 4, [[2], [3]], undefined);
|
||||||
expect(component.current()).toEqual(draft);
|
expect(component.current()).toEqual(draft);
|
||||||
expect(component.dirty()).toBe(false);
|
expect(component.dirty()).toBe(false);
|
||||||
expect(api.finalize).not.toHaveBeenCalled();
|
expect(api.finalize).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ import { DialogFrame } from './dialog-frame';
|
|||||||
export class ScheduleEditor implements OnInit {
|
export class ScheduleEditor implements OnInit {
|
||||||
readonly show = input.required<Show>();
|
readonly show = input.required<Show>();
|
||||||
readonly groups = input.required<DanceGroup[]>();
|
readonly groups = input.required<DanceGroup[]>();
|
||||||
|
readonly revision = input<number>();
|
||||||
|
private originalRevision?: number;
|
||||||
readonly schoolId = input.required<number>();
|
readonly schoolId = input.required<number>();
|
||||||
readonly closed = output<void>();
|
readonly closed = output<void>();
|
||||||
readonly saved = output<void>();
|
readonly saved = output<void>();
|
||||||
@@ -124,6 +126,7 @@ export class ScheduleEditor implements OnInit {
|
|||||||
assignments: number[][] = [];
|
assignments: number[][] = [];
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.originalRevision = this.revision();
|
||||||
this.current.set(this.show());
|
this.current.set(this.show());
|
||||||
this.assignments = this.show().dayParts.map((p) => [...p.danceGroupIds]);
|
this.assignments = this.show().dayParts.map((p) => [...p.danceGroupIds]);
|
||||||
}
|
}
|
||||||
@@ -162,9 +165,15 @@ export class ScheduleEditor implements OnInit {
|
|||||||
try {
|
try {
|
||||||
const result = await firstValueFrom(
|
const result = await firstValueFrom(
|
||||||
finalize
|
finalize
|
||||||
? this.api.finalize(this.schoolId(), this.show().id)
|
? this.api.finalize(this.schoolId(), this.show().id, this.originalRevision)
|
||||||
: this.api.saveAssignments(this.schoolId(), this.show().id, this.assignments),
|
: this.api.saveAssignments(
|
||||||
|
this.schoolId(),
|
||||||
|
this.show().id,
|
||||||
|
this.assignments,
|
||||||
|
this.originalRevision,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
if (this.originalRevision !== undefined) this.originalRevision++;
|
||||||
this.current.set(result);
|
this.current.set(result);
|
||||||
this.assignments = result.dayParts.map((p) => [...p.danceGroupIds]);
|
this.assignments = result.dayParts.map((p) => [...p.danceGroupIds]);
|
||||||
this.dirty.set(false);
|
this.dirty.set(false);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface Preview {
|
|||||||
shows: number;
|
shows: number;
|
||||||
existingId: number | null;
|
existingId: number | null;
|
||||||
existingName: string | null;
|
existingName: string | null;
|
||||||
|
existingRevision: number | null;
|
||||||
}
|
}
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-school-transfer',
|
selector: 'app-school-transfer',
|
||||||
@@ -117,7 +118,12 @@ export class SchoolTransfer {
|
|||||||
const query = p.existingId === null ? '' : `?replaceId=${p.existingId}`;
|
const query = p.existingId === null ? '' : `?replaceId=${p.existingId}`;
|
||||||
await firstValueFrom(
|
await firstValueFrom(
|
||||||
this.http.post(`${this.base}/import${query}`, this.json, {
|
this.http.post(`${this.base}/import${query}`, this.json, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(p.existingRevision === null
|
||||||
|
? {}
|
||||||
|
: { 'X-School-Revision': String(p.existingRevision) }),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
this.cancel();
|
this.cancel();
|
||||||
|
|||||||
@@ -131,6 +131,29 @@ class Pipeline {
|
|||||||
"agiledancers-fe");
|
"agiledancers-fe");
|
||||||
Files.writeString(artifacts.resolve("containers-started"), "");
|
Files.writeString(artifacts.resolve("containers-started"), "");
|
||||||
run("docker", "network", "create", "--label", "agiledancers.ci.run=" + runName, runName);
|
run("docker", "network", "create", "--label", "agiledancers.ci.run=" + runName, runName);
|
||||||
|
run(
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
runName + "-database",
|
||||||
|
"--network",
|
||||||
|
runName,
|
||||||
|
"--network-alias",
|
||||||
|
"database",
|
||||||
|
"--env",
|
||||||
|
"POSTGRES_USER=agiledancers",
|
||||||
|
"--env",
|
||||||
|
"POSTGRES_PASSWORD=ci-only",
|
||||||
|
"--env",
|
||||||
|
"POSTGRES_DB=agiledancers",
|
||||||
|
"postgres:17.6");
|
||||||
|
for (int attempt = 0; attempt < 60; attempt++) {
|
||||||
|
if (result(false, "docker", "exec", runName + "-database", "pg_isready", "-U", "agiledancers")
|
||||||
|
.code()
|
||||||
|
== 0) break;
|
||||||
|
Thread.sleep(1000);
|
||||||
|
}
|
||||||
run(
|
run(
|
||||||
"docker",
|
"docker",
|
||||||
"run",
|
"run",
|
||||||
@@ -153,6 +176,12 @@ class Pipeline {
|
|||||||
root.resolve("agiledancers-be/target/coverage-tools/agent.jar") + ":/jacoco-agent.jar:ro",
|
root.resolve("agiledancers-be/target/coverage-tools/agent.jar") + ":/jacoco-agent.jar:ro",
|
||||||
"--env",
|
"--env",
|
||||||
"JAVA_TOOL_OPTIONS=-javaagent:/jacoco-agent.jar=output=tcpserver,address=*,port=6300",
|
"JAVA_TOOL_OPTIONS=-javaagent:/jacoco-agent.jar=output=tcpserver,address=*,port=6300",
|
||||||
|
"--env",
|
||||||
|
"DATABASE_URL=jdbc:postgresql://database:5432/agiledancers",
|
||||||
|
"--env",
|
||||||
|
"DATABASE_USER=agiledancers",
|
||||||
|
"--env",
|
||||||
|
"DATABASE_PASSWORD=ci-only",
|
||||||
backendImage);
|
backendImage);
|
||||||
run(
|
run(
|
||||||
"docker",
|
"docker",
|
||||||
@@ -328,13 +357,14 @@ class Pipeline {
|
|||||||
Files.copy(source, artifacts.resolve(data), StandardCopyOption.REPLACE_EXISTING);
|
Files.copy(source, artifacts.resolve(data), StandardCopyOption.REPLACE_EXISTING);
|
||||||
}
|
}
|
||||||
if (result(false, "docker", "info").code() == 0) {
|
if (result(false, "docker", "info").code() == 0) {
|
||||||
for (String role : List.of("backend", "frontend", "browser")) {
|
for (String role : List.of("backend", "frontend", "browser", "database")) {
|
||||||
String container = runName + "-" + role;
|
String container = runName + "-" + role;
|
||||||
if (result(false, "docker", "container", "inspect", container).code() == 0) {
|
if (result(false, "docker", "container", "inspect", container).code() == 0) {
|
||||||
var logs = result(false, "docker", "logs", container);
|
var logs = result(false, "docker", "logs", container);
|
||||||
Files.writeString(artifacts.resolve(role + ".log"), logs.output());
|
Files.writeString(artifacts.resolve(role + ".log"), logs.output());
|
||||||
if (logs.code() != 0) failures.add(role + " logs");
|
if (logs.code() != 0) failures.add(role + " logs");
|
||||||
if (result(false, "docker", "rm", "-f", container).code() != 0) failures.add(role + " cleanup");
|
if (result(false, "docker", "rm", "-f", "-v", container).code() != 0)
|
||||||
|
failures.add(role + " cleanup");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (result(false, "docker", "network", "inspect", runName).code() == 0
|
if (result(false, "docker", "network", "inspect", runName).code() == 0
|
||||||
|
|||||||
@@ -1,6 +1,27 @@
|
|||||||
# Images are resolved to registry digests before replacement. Never use :main here.
|
# Images are resolved to registry digests before replacement. Never use :main here.
|
||||||
services:
|
services:
|
||||||
|
database:
|
||||||
|
image: postgres:17.6
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: agiledancers
|
||||||
|
POSTGRES_USER: agiledancers
|
||||||
|
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:?Required database password}
|
||||||
|
volumes:
|
||||||
|
- database-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U agiledancers -d agiledancers']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
backend:
|
backend:
|
||||||
|
depends_on:
|
||||||
|
database:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: jdbc:postgresql://database:5432/agiledancers
|
||||||
|
DATABASE_USER: agiledancers
|
||||||
|
DATABASE_PASSWORD: ${DATABASE_PASSWORD:?Required database password}
|
||||||
image: ${BACKEND_IMAGE:?Required verified backend digest}
|
image: ${BACKEND_IMAGE:?Required verified backend digest}
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -32,3 +53,7 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: '10m'
|
max-size: '10m'
|
||||||
max-file: '3'
|
max-file: '3'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
database-data:
|
||||||
|
name: agiledancers-production-database
|
||||||
|
|||||||
@@ -7,3 +7,6 @@ DOCKER_BIN=/usr/local/bin/docker
|
|||||||
DOCKER_SUDO=1
|
DOCKER_SUDO=1
|
||||||
# The pull-only registry login is provisioned separately using docker login.
|
# The pull-only registry login is provisioned separately using docker login.
|
||||||
DOCKER_CONFIG=/volume1/docker/agiledancers-production/docker-auth
|
DOCKER_CONFIG=/volume1/docker/agiledancers-production/docker-auth
|
||||||
|
|
||||||
|
# Set a long random password before the first S16 deployment. Never commit its real value.
|
||||||
|
DATABASE_PASSWORD=replace-with-a-long-random-password
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ test -f production.env || die 'Missing production.env; follow provisioning runbo
|
|||||||
. "$root/production.env"
|
. "$root/production.env"
|
||||||
: "${BIND_IP:?}" "${FRONTEND_PORT:?}" "${DOCKER_BIN:?}" "${DOCKER_CONFIG:?}"
|
: "${BIND_IP:?}" "${FRONTEND_PORT:?}" "${DOCKER_BIN:?}" "${DOCKER_CONFIG:?}"
|
||||||
export DOCKER_CONFIG
|
export DOCKER_CONFIG
|
||||||
|
# Compose expands database credentials from trusted production.env, never release metadata.
|
||||||
|
if [ -n "${DATABASE_PASSWORD:-}" ]; then export DATABASE_PASSWORD; fi
|
||||||
docker_cmd() {
|
docker_cmd() {
|
||||||
if [ "${DOCKER_SUDO:-1}" = 1 ]; then
|
if [ "${DOCKER_SUDO:-1}" = 1 ]; then
|
||||||
sudo -n "$DOCKER_BIN" --config "$DOCKER_CONFIG" "$@"
|
sudo -n "$DOCKER_BIN" --config "$DOCKER_CONFIG" "$@"
|
||||||
@@ -170,7 +172,9 @@ case "$action" in
|
|||||||
test "$health_seconds" -ge 1 && test "$health_seconds" -le 280 || die 'Invalid readiness window'
|
test "$health_seconds" -ge 1 && test "$health_seconds" -le 280 || die 'Invalid readiness window'
|
||||||
end=$(( $(date +%s) + health_seconds ))
|
end=$(( $(date +%s) + health_seconds ))
|
||||||
# Always recreate the frontend too: nginx must resolve the new backend IP.
|
# Always recreate the frontend too: nginx must resolve the new backend IP.
|
||||||
compose up -d --no-build --pull never --force-recreate --timeout 20
|
# Recreate application containers only; the database and volume survive releases.
|
||||||
|
compose up -d --no-build --pull missing --timeout 20 database
|
||||||
|
compose up -d --no-deps --no-build --pull never --force-recreate --timeout 20 backend frontend
|
||||||
while ! health; do
|
while ! health; do
|
||||||
test "$(date +%s)" -lt "$end" || die 'Readiness checks timed out'
|
test "$(date +%s)" -lt "$end" || die 'Readiness checks timed out'
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|||||||
135
docs/persistent-storage.md
Normal file
135
docs/persistent-storage.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# PostgreSQL storage (S16)
|
||||||
|
|
||||||
|
The backend requires PostgreSQL 17. Flyway applies the versioned SQL under
|
||||||
|
`agiledancers-be/src/main/resources/db/migration` before startup recovery and
|
||||||
|
readiness. Never edit an already deployed migration; add the next version instead.
|
||||||
|
The database owns domain identifiers and a monotonically increasing school
|
||||||
|
revision. Application persistence uses JPA/Hibernate ORM: mapped entities and
|
||||||
|
collections live in `repository.entity`, and repositories use `EntityManager`.
|
||||||
|
The existing domain objects remain independent of JPA. Database constraints enforce
|
||||||
|
school ownership of every relationship.
|
||||||
|
|
||||||
|
Production uses `deploy/compose.yaml`: PostgreSQL has no published port and keeps
|
||||||
|
its data in the named volume `agiledancers-production-database`. Set a long random
|
||||||
|
`DATABASE_PASSWORD` in the mode-600 `production.env` on the NAS before the first
|
||||||
|
S16 deployment. The release helper exports it for Compose without writing it to
|
||||||
|
release metadata. The backend receives:
|
||||||
|
|
||||||
|
- `DATABASE_URL=jdbc:postgresql://database:5432/agiledancers`
|
||||||
|
- `DATABASE_USER=agiledancers`
|
||||||
|
- `DATABASE_PASSWORD` from `production.env`
|
||||||
|
|
||||||
|
Database health gates backend startup, and the backend readiness check includes
|
||||||
|
the datasource. Acquisition failures reject requests with `503` and a message that
|
||||||
|
the mutation was not stored. There is no volatile fallback. PostgreSQL's normal
|
||||||
|
WAL, fsync and synchronous-commit defaults must remain enabled. Application writes
|
||||||
|
are acknowledged only after commit. A network loss during acknowledgement can
|
||||||
|
leave the client uncertain; reload current data before deciding to retry.
|
||||||
|
|
||||||
|
## Transition from the previous release
|
||||||
|
|
||||||
|
1. Export **every school** through JSON while the old backend is still running.
|
||||||
|
Its data exists only in memory and cannot be recovered after it stops.
|
||||||
|
2. Keep those exports, configure the database password, then deploy S16. The
|
||||||
|
first startup creates an empty relational database through Flyway.
|
||||||
|
3. Import the JSON files through the existing preview and confirmation workflow.
|
||||||
|
School keys and file-local relationship semantics remain unchanged; runtime
|
||||||
|
IDs are assigned by PostgreSQL. A replacement retains the target school ID.
|
||||||
|
4. Verify schools and saved assignments after a restart before removing exports.
|
||||||
|
Import retains the existing behavior of importing schedules as drafts.
|
||||||
|
|
||||||
|
Deployments recreate only the application containers. Never run `compose down -v`
|
||||||
|
or delete the named database volume. Rollback replaces application images, not
|
||||||
|
schema migrations; only roll back to a database-compatible release. An image from
|
||||||
|
before S16 cannot read the new database. Database/volume loss recovery and backup
|
||||||
|
operations are deferred to S17.
|
||||||
|
|
||||||
|
## Local development and verification
|
||||||
|
|
||||||
|
Quarkus Dev Services starts disposable PostgreSQL for development and tests when
|
||||||
|
no datasource URL is configured; Docker must be accessible. For a durable local
|
||||||
|
instance, create a PostgreSQL service with a persistent volume and pass
|
||||||
|
`QUARKUS_DATASOURCE_JDBC_URL`, `QUARKUS_DATASOURCE_USERNAME`, and
|
||||||
|
`QUARKUS_DATASOURCE_PASSWORD` to dev/test execution. Production uses the three
|
||||||
|
`DATABASE_*` variables above. Never reuse a production database for tests.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 mvn -Dformat.mode=check clean verify
|
||||||
|
cd agiledancers-fe
|
||||||
|
npm test
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Standalone Docker builds run the complete backend tests against a reachable test
|
||||||
|
database. Supply its configuration through BuildKit secrets (not image build
|
||||||
|
arguments), for example with shell variables `TEST_DATABASE_URL`,
|
||||||
|
`TEST_DATABASE_USER` and `TEST_DATABASE_PASSWORD` already set:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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 .
|
||||||
|
```
|
||||||
|
|
||||||
|
CI runs verification against disposable PostgreSQL, packages the exact verified
|
||||||
|
output using the `prebuilt` Docker target, and starts a separate database on its
|
||||||
|
isolated browser-test network. Cleanup removes those test containers only.
|
||||||
|
|
||||||
|
The packaged-process regression test below checks graceful and abrupt backend
|
||||||
|
termination, stable recovered run IDs through HTTP and SSE, committed results,
|
||||||
|
interrupted calculations, database outages, and failed startup/migrations. It
|
||||||
|
requires a built backend and an **isolated disposable database container**: the
|
||||||
|
test deliberately stops and restarts the named container. It does not remove its
|
||||||
|
volume. Pass matching connection options if they differ from the defaults.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tooling/persistence/restart_test.py --database-container agiledancers-s16-test
|
||||||
|
```
|
||||||
|
|
||||||
|
S16 targets one active backend. The existing domain monitor serializes short
|
||||||
|
snapshot/mutation/commit units of work; solver work runs outside that monitor and
|
||||||
|
outside database transactions. Results use the school revision captured at start.
|
||||||
|
Changes elsewhere in the same school may invalidate a run. Latest progress and
|
||||||
|
outcome are durable; calculations left Running at startup become Interrupted and
|
||||||
|
retain earlier saved assignments. There is no automatic resumption or run history.
|
||||||
|
|
||||||
|
|
||||||
|
## JPA mapping and schema upgrades
|
||||||
|
|
||||||
|
Hibernate manages relationships, element collections, ordered assignments and
|
||||||
|
orphan removal through mapped entities. Its `@Version` on the school entity
|
||||||
|
implements optimistic concurrency. A child-only change marks the aggregate dirty,
|
||||||
|
so the school version advances exactly once without manual version-update SQL.
|
||||||
|
JTA owns the transaction boundary; run acceptance and completion are published only
|
||||||
|
after commit. Detached domain snapshots do not expose Hibernate proxies through the
|
||||||
|
API or to solver threads.
|
||||||
|
|
||||||
|
Flyway remains the only schema writer. Hibernate runs in `validate` mode and never
|
||||||
|
creates or alters application tables. V1 is retained unchanged. V2 adds the aggregate
|
||||||
|
change stamp, a JPA-managed identifier counter seeded above the existing sequence,
|
||||||
|
and a deferred date/type uniqueness check for reordering dayparts. Existing school
|
||||||
|
keys, entity IDs, assignments and scheduling status are preserved. The counter is
|
||||||
|
locked while allocating IDs within a transaction. Rollback requires a release that
|
||||||
|
understands V2's identifier allocation; the earlier JDBC implementation is not a
|
||||||
|
compatible rollback target.
|
||||||
|
|
||||||
|
|
||||||
|
## API projections
|
||||||
|
|
||||||
|
MapStruct 1.6.3 generates the REST-layer `DomainViewMapper` for domain objects to
|
||||||
|
view records. REST resources inject the generated CDI bean. Relationship collections
|
||||||
|
map to ID lists through one shared helper; validation remains a use-case result
|
||||||
|
passed explicitly to the mapper. Preferred-daypart defaults call the existing
|
||||||
|
application rule. View constructors preserve immutable snapshots.
|
||||||
|
|
||||||
|
The mapper sets `unmappedTargetPolicy = ERROR`: every target component must have a
|
||||||
|
mapping, whether inferred from a matching property or declared explicitly. A newly
|
||||||
|
added unmatched component therefore fails compilation. The backend compiler runs
|
||||||
|
MapStruct together with Lombok and its pinned MapStruct binding. Generated classes
|
||||||
|
stay under `target/generated-sources/annotations`; they are not maintained source.
|
||||||
|
|
||||||
|
Persistence entities and shared domain graph identity continue to use PeopleMapping,
|
||||||
|
GroupMapping and ShowMapping. MapStruct does not construct or update that graph.
|
||||||
@@ -4,10 +4,11 @@ De buildworkflow bouwt, test en publiceert automatisch. Productie verandert
|
|||||||
alleen via **Actions → Deploy production → Run workflow** of **Rollback
|
alleen via **Actions → Deploy production → Run workflow** of **Rollback
|
||||||
production**. Start beide vanaf `main`; de workflows verschijnen na de merge.
|
production**. Start beide vanaf `main`; de workflows verschijnen na de merge.
|
||||||
|
|
||||||
**Tot S16 is geïmplementeerd kan iedere deployment, retry, rollback of automatisch
|
**Voor de eerste S16-deployment: exporteer alle scholen voordat de oude backend
|
||||||
herstel alle applicatiegegevens wissen.** Informeer de launching partner vooraf.
|
stopt.** De oude release bewaart gegevens alleen in geheugen. Configureer ook
|
||||||
Een onderbroken berekening moet opnieuw worden gestart. Een herstelde
|
`DATABASE_PASSWORD` in `production.env`. Zie [database-inrichting en overgang](persistent-storage.md).
|
||||||
applicatieversie herstelt geen verloren gegevens.
|
Nieuwe releases gebruiken een apart PostgreSQL-volume; verwijder dit volume niet.
|
||||||
|
Onderbroken berekeningen worden als Interrupted hersteld en starten niet vanzelf.
|
||||||
|
|
||||||
## Eenmalige DSM-inrichting
|
## Eenmalige DSM-inrichting
|
||||||
|
|
||||||
@@ -285,11 +286,12 @@ nieuwe release door te drukken.
|
|||||||
|
|
||||||
## Na S16 en lokale verificatie
|
## Na S16 en lokale verificatie
|
||||||
|
|
||||||
S16 blokkeert S6 niet. Zodra S16 is ingevoerd, moeten database/volumes buiten de
|
De databaseservice en het volume `agiledancers-production-database` blijven buiten
|
||||||
vervangbare containers blijven en saved data zowel deployment als rollback
|
de vervanging van backend en frontend. `production.env` levert het databasewachtwoord
|
||||||
overleven. De gewone procedure vereist dan databasecompatibiliteit met de vorige
|
ook bij retry en rollback; het staat niet in releasebestanden. De procedure vereist
|
||||||
applicatieversie. Plan incompatibele migraties apart: imageherstel maakt geen
|
databasecompatibiliteit met de vorige applicatieversie. Plan incompatibele migraties
|
||||||
migratie ongedaan. Backup/restore blijven S17-werk.
|
apart: imageherstel maakt geen migratie ongedaan. Rollback naar een oude release
|
||||||
|
van vóór S16 kan de database niet lezen. Backup/restore blijven S17-werk.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
java ci/Ci.java test
|
java ci/Ci.java test
|
||||||
|
|||||||
2
pom.xml
2
pom.xml
@@ -30,6 +30,8 @@
|
|||||||
<rewrite-core.version>8.63.4</rewrite-core.version>
|
<rewrite-core.version>8.63.4</rewrite-core.version>
|
||||||
|
|
||||||
<ortools-java.version>9.15.6755</ortools-java.version>
|
<ortools-java.version>9.15.6755</ortools-java.version>
|
||||||
|
<mapstruct.version>1.6.3</mapstruct.version>
|
||||||
|
<lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
|
||||||
<lombok.version>1.18.48</lombok.version>
|
<lombok.version>1.18.48</lombok.version>
|
||||||
|
|
||||||
<!-- test dependencies -->
|
<!-- test dependencies -->
|
||||||
|
|||||||
180
specs/S16-validation.md
Normal file
180
specs/S16-validation.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# S16 implementation validation
|
||||||
|
|
||||||
|
## Delivered behavior
|
||||||
|
|
||||||
|
PostgreSQL owns normalized school, dancer, coach, group, membership, style, show,
|
||||||
|
daypart, appearance and ordered-assignment tables, plus the latest scheduling run.
|
||||||
|
Flyway V1 creates the schema, persistent identifier sequence, cross-school-safe
|
||||||
|
foreign keys and row constraints. School keys, runtime identifiers, relationship
|
||||||
|
identity, coach links, draft assignments and finalized state survive reload.
|
||||||
|
|
||||||
|
Application persistence uses JPA/Hibernate ORM with mapped entities in
|
||||||
|
`repository.entity` and `EntityManager` repositories. Relationships, element
|
||||||
|
collections, daypart order and orphan removal are managed by Hibernate; domain
|
||||||
|
objects and API responses remain framework independent. The hand-written JDBC
|
||||||
|
query/update layer and row readers have been removed.
|
||||||
|
|
||||||
|
The HTTP interceptor runs each school operation in a short repeatable-read JTA
|
||||||
|
transaction under the existing domain monitor. Reads convert managed entities into
|
||||||
|
detached domain snapshots. Hibernate's school `@Version` checks optimistic changes;
|
||||||
|
a change stamp marks child-only edits so the school revision advances exactly once.
|
||||||
|
Missing or stale caller revisions conflict before applying edits, and stale detached
|
||||||
|
JPA entities also fail. Successful graph merges flush before commit, and status
|
||||||
|
publication remains after durable commit. A single school-data endpoint gives the
|
||||||
|
editor a consistent data/revision snapshot. Open editors retain their original
|
||||||
|
revision across background refreshes; conflict responses preserve their input and
|
||||||
|
require reload before retry. Replacement imports retain the target school ID and
|
||||||
|
check the revision returned by preview. Existing JSON import/export semantics remain.
|
||||||
|
|
||||||
|
Domain-to-view projection now uses MapStruct 1.6.3 in the REST layer. The generated
|
||||||
|
CDI `DomainViewMapper` covers schools, dancers, groups, coaches, dayparts and shows.
|
||||||
|
A shared ID conversion projects relationships to ordered ID lists; validation results
|
||||||
|
remain an explicit argument and preferred-daypart defaults delegate to the existing
|
||||||
|
application rule. View record constructors retain their defensive copies. Entity to
|
||||||
|
domain graph construction remains explicit in the persistence adapter.
|
||||||
|
`unmappedTargetPolicy = ERROR` rejects missing target fields at compilation. A compiler
|
||||||
|
probe with the actual mapper and an added unmapped SchoolView component confirms this
|
||||||
|
failure, without modifying maintained sources. MapStruct's generated implementation
|
||||||
|
is marked `lombok.Generated`, preserving the existing generated-code scope of coverage
|
||||||
|
and architecture checks; handwritten mapper helpers remain covered and checked.
|
||||||
|
|
||||||
|
Flyway remains the schema authority; Hibernate validates rather than generates DDL.
|
||||||
|
V1 is unchanged. V2 preserves existing rows, adds the aggregate change stamp and a
|
||||||
|
JPA-managed identifier counter seeded above previously allocated IDs, and defers
|
||||||
|
date/type uniqueness until commit so Hibernate can reorder retained dayparts safely.
|
||||||
|
Identifier allocation takes a pessimistic lock on the counter row. Coach/dancer and
|
||||||
|
group/coach associations use Hibernate composite join mappings to share the owning
|
||||||
|
school key while letting Hibernate update the linked member column directly.
|
||||||
|
|
||||||
|
Run acceptance checks the school revision and persists the run before launch or
|
||||||
|
acknowledgement. Acceptance itself does not change scheduling inputs. Background
|
||||||
|
computation holds neither a database transaction nor the domain monitor. Progress
|
||||||
|
commits before publication. Successful assignment replacement, school revision and
|
||||||
|
terminal run status commit together; publication callbacks run only after commit.
|
||||||
|
Deferred constraint failures roll back both assignments and status without
|
||||||
|
publishing success. Database failure never falls back to acknowledged memory-only
|
||||||
|
writes. Disconnected status subscribers cannot turn a committed result into a
|
||||||
|
solver failure.
|
||||||
|
|
||||||
|
Startup recovery marks persisted Running runs Interrupted before serving status,
|
||||||
|
retains their IDs, progress and previous saved assignments, and creates no old run
|
||||||
|
guards. The frontend recognizes Interrupted and retains S12's reconnect/reload
|
||||||
|
notification behavior. Timestamps use PostgreSQL microsecond precision consistently
|
||||||
|
in live and recovered snapshots.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Maven clean verify: all 72 backend tests and 19 standards-checker regression tests
|
||||||
|
pass. The JPA integration run also verified Flyway V1 and V2 and Hibernate schema
|
||||||
|
validation against an empty database, as well as an upgrade of the populated V1
|
||||||
|
test database.
|
||||||
|
- Backend line coverage: 1763/1827 (96.50%), above the 80% gate.
|
||||||
|
- Frontend: 32 unit tests, production build and ESLint pass (unchanged by the
|
||||||
|
MapStruct-only backend change).
|
||||||
|
- Generated-mapper CDI tests cover ordered relationship IDs, an unlinked coach,
|
||||||
|
nested assignment order, defensive copies, explicit validation messages and the
|
||||||
|
configured/default preferred-daypart rule. The compile-time unmapped-field probe
|
||||||
|
passes with the expected compiler error.
|
||||||
|
- Playwright: the three existing real REST/browser scenarios pass with PostgreSQL;
|
||||||
|
a fourth scenario verifies two screens editing the same revision, conflict
|
||||||
|
handling with retained form input, and successful reload/retry.
|
||||||
|
- PostgreSQL tests cover simultaneous edits (one succeeds, one conflicts), missing
|
||||||
|
revisions, relational graph recovery, IDs and ordered assignments, finalized
|
||||||
|
state, deferred commit failure without success publication, failed replacement
|
||||||
|
import rollback, stale scheduling inputs and interrupted-run recovery. JPA-specific
|
||||||
|
tests verify stale detached entities, exactly one aggregate version increment for
|
||||||
|
child-only edits, and reordered dayparts retaining their assignments.
|
||||||
|
- `tooling/persistence/restart_test.py` runs the packaged production backend against
|
||||||
|
an isolated persistent PostgreSQL container. It passes graceful restart and
|
||||||
|
SIGKILL interruption checks, verifies a completed saved result after every
|
||||||
|
restart, checks stable recovered IDs through HTTP and SSE, starts a new run after
|
||||||
|
interruption, rejects mutations during a database outage, and verifies startup
|
||||||
|
failure for an unreachable database and a deliberately failing Flyway migration.
|
||||||
|
- CI helper tests: 17 pass. Release and actual deployment-shell behavior tests:
|
||||||
|
32 pass. Shell syntax and Java/frontend formatting checks pass.
|
||||||
|
|
||||||
|
The packaged-process checks deliberately stop/restart only the named disposable
|
||||||
|
test database. Production deployment was not performed. Database provisioning and
|
||||||
|
transition instructions are in `docs/persistent-storage.md`; export every school
|
||||||
|
before stopping the old in-memory release. Production Compose retains its database
|
||||||
|
volume outside application replacement. CI supplies a separate disposable browser
|
||||||
|
database and removes its anonymous volume during cleanup.
|
||||||
|
|
||||||
|
## Manual standards assessment
|
||||||
|
|
||||||
|
No waivers or weaker gates were added. Architecture tests pass. Persistence entities
|
||||||
|
and their embeddable keys belong to the repository layer, while the scheduling
|
||||||
|
records remain in `model.scheduling`. Mutable classes, no-argument construction,
|
||||||
|
setters and replaceable collections are deliberate JPA lifecycle requirements;
|
||||||
|
Hibernate installs managed collection wrappers and hydrates fields. Composite key
|
||||||
|
values are assigned before persistence and are not mutated afterward. None of these
|
||||||
|
entities or lazy collections escapes into the solver or the API. Existing immutable
|
||||||
|
snapshot records still defensively copy mutable collections.
|
||||||
|
|
||||||
|
Retained review signals in changed code:
|
||||||
|
|
||||||
|
- Database fan-out 27 and transaction cyclomatic complexity 8: JPA/JTA lifecycle,
|
||||||
|
rollback and heuristic failures, optimistic-conflict classification, and committed
|
||||||
|
publication are handled in one transaction owner. Its two injected dependencies
|
||||||
|
remain below the review signal.
|
||||||
|
- SchoolWriter fan-out 17: one adapter builds and merges the school graph, removes
|
||||||
|
deleted-show statuses, checks JPA revisions and flushes changes. PeopleMapping,
|
||||||
|
GroupMapping and ShowMapping separate domain conversion from ORM lifecycle.
|
||||||
|
- Entity fan-out reflects explicit relationship and collection annotations:
|
||||||
|
DayPartEntity 22, GroupEntity 24, RunEntity 17, SchoolEntity 18 and ShowEntity 23.
|
||||||
|
Each maps one existing table/aggregate boundary; splitting fields merely to lower
|
||||||
|
annotation-driven fan-out would obscure the relational mappings. Entity collections
|
||||||
|
are mutable only within the persistence adapter and Hibernate session.
|
||||||
|
- SchoolTransactions fan-out 19 and request cyclomatic complexity 9: four collaborators
|
||||||
|
cover the detached domain monitor, transaction owner, loader and writer. The branch
|
||||||
|
structure distinguishes reads, run acceptance and domain mutations and maps storage
|
||||||
|
failures separately from optimistic conflicts. SchoolChange's four components
|
||||||
|
keep the revision and operation classification together.
|
||||||
|
- SchedulingRuns fan-out 31: it coordinates immutable input/progress/run/result
|
||||||
|
records, the solver port, durable lifecycle operations and subscriber callbacks;
|
||||||
|
its three injected collaborators remain below the dependency review signal.
|
||||||
|
- SchoolManagementService fan-out 38, 30 public methods, beginScheduling
|
||||||
|
cyclomatic complexity 8 and the existing five-parameter updateShow are retained:
|
||||||
|
aggregate use cases and validations remain together. Load installs detached state
|
||||||
|
and resets transient input counters under the existing monitor.
|
||||||
|
- REST resource fan-out is 18–28 (DanceSchoolResource 25): resources combine HTTP
|
||||||
|
annotations, use cases, request/view contracts and the injected projection mapper.
|
||||||
|
The consistent data endpoint assembles views inside the transaction. DomainViewMapper
|
||||||
|
fan-out 25 represents six explicit source/target projections plus the MapStruct
|
||||||
|
annotations and two small shared helpers. DomainViewMapperTest fan-out 22 exercises
|
||||||
|
the generated CDI mapper's API edge cases. SchoolView's six components include the revision;
|
||||||
|
SchoolDataView's five components represent one editor snapshot. ImportPreview's
|
||||||
|
seventh component identifies the target revision for replacement.
|
||||||
|
- PersistenceTest fan-out 47, seven injected fixtures, 335 code lines and methods of
|
||||||
|
41/33/42 lines: these cross real ORM, graph and transaction boundaries, including
|
||||||
|
detached-entity conflicts, commit-time failure, failed import, child versioning and
|
||||||
|
reordered assignments. The fixtures remain below all blocking limits. Existing
|
||||||
|
changed REST/SSE/background fixture metrics retain their S12/S7 rationale.
|
||||||
|
|
||||||
|
One active backend remains the supported deployment model. Global execution
|
||||||
|
capacity, distributed workers, database backup/restore and recovery after database
|
||||||
|
volume loss remain outside S16. S17 owns backup/restore.
|
||||||
|
|
||||||
|
## Review follow-up: database recovery during calculation
|
||||||
|
|
||||||
|
Terminal scheduling outcomes whose transaction fails remain pending in their worker.
|
||||||
|
The worker retries storage once per second without holding the school monitor between
|
||||||
|
attempts, retaining its run guard until the terminal status commits. Publication still
|
||||||
|
happens only after commit. Shutdown interrupts the wait; startup recovery remains
|
||||||
|
responsible for persisted Running statuses after a process restart. If the show was
|
||||||
|
removed during the outage, the pending outcome is discarded rather than recreating
|
||||||
|
its status. The finish method's cognitive complexity 12 and cyclomatic complexity 8 distinguish
|
||||||
|
retryable storage failures, removed shows and shutdown; these lifecycle decisions
|
||||||
|
stay in the run owner. SchedulingRuns fan-out is now 34 and its test fixture fan-out
|
||||||
|
35: the added storage exception, interruption handling and outage fixture belong to
|
||||||
|
this existing persistence lifecycle boundary.
|
||||||
|
|
||||||
|
The regression test simulates unavailable storage during result persistence, verifies
|
||||||
|
that no terminal status is published prematurely and duplicate starts remain rejected,
|
||||||
|
then restores storage without restarting the backend and checks the stable run ID,
|
||||||
|
persisted failure status and released guard. Public transaction/callback documentation
|
||||||
|
now specifies transaction ownership, callback timing and exception behavior.
|
||||||
|
|
||||||
|
Follow-up verification: clean Maven verify against PostgreSQL passes all 73 backend
|
||||||
|
tests and 19 standards-checker tests. Standards and the coverage gate pass;
|
||||||
|
backend line coverage is 1769/1841 (96.09%).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# S12 — Asynchronous feedback for scheduling
|
# S12 — Asynchronous feedback for scheduling
|
||||||
|
|
||||||
Status: IN_REVIEW
|
Status: IMPLEMENTED
|
||||||
|
|
||||||
Agreed after the grill-me interview; shared understanding confirmed.
|
Agreed after the grill-me interview; shared understanding confirmed.
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ and what the outcome is, including when I leave and return to the show page.
|
|||||||
## Persistence and backend restarts
|
## Persistence and backend restarts
|
||||||
|
|
||||||
The maintainer explicitly transferred durable scheduling status and recovery
|
The maintainer explicitly transferred durable scheduling status and recovery
|
||||||
following backend restarts to [S16](../refined/S16_store-state-in-persistent-storage)
|
following backend restarts to [S16](../in_review/S16_store-state-in-persistent-storage)
|
||||||
when completing S12. The latest status and outcome in S12 survive navigation,
|
when completing S12. The latest status and outcome in S12 survive navigation,
|
||||||
page reloads and SSE reconnection while the same backend process remains active.
|
page reloads and SSE reconnection while the same backend process remains active.
|
||||||
Schools, assignments and scheduling status currently remain in memory.
|
Schools, assignments and scheduling status currently remain in memory.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# S16 — Store state in persistent storage
|
# S16 — Store state in persistent storage
|
||||||
|
|
||||||
Status: REFINED
|
Status: IMPLEMENTED
|
||||||
|
|
||||||
Agreed after the grill-me interview; shared understanding confirmed.
|
Agreed after the grill-me interview; shared understanding confirmed.
|
||||||
|
|
||||||
@@ -14,7 +14,8 @@ and application crashes.
|
|||||||
|
|
||||||
Use PostgreSQL with relational tables for schools, dancers, coaches, groups,
|
Use PostgreSQL with relational tables for schools, dancers, coaches, groups,
|
||||||
shows and their relationships. Use Flyway to version and apply database schema
|
shows and their relationships. Use Flyway to version and apply database schema
|
||||||
changes.
|
changes. Use JPA with Hibernate ORM for application persistence; keep the mappings
|
||||||
|
inside the repository layer so the existing domain model remains framework independent.
|
||||||
|
|
||||||
The existing domain has shared relationships and operations on individual school
|
The existing domain has shared relationships and operations on individual school
|
||||||
components. Related changes must be atomic, and multiple maintainers must be able
|
components. Related changes must be atomic, and multiple maintainers must be able
|
||||||
@@ -121,32 +122,34 @@ for now. S16 does not promise recovery after loss of the database or its volume.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] PostgreSQL stores the complete maintained domain state and saved scheduling
|
- [x] PostgreSQL stores the complete maintained domain state and saved scheduling
|
||||||
results, with Flyway managing the relational schema.
|
results, with Flyway managing the relational schema.
|
||||||
- [ ] Confirmed changes remain available after both a normal restart and an
|
- [x] Confirmed changes remain available after both a normal restart and an
|
||||||
application crash, using the same persistent database.
|
application crash, using the same persistent database.
|
||||||
- [ ] Failed related mutations and failed school replacement imports leave no
|
- [x] Failed related mutations and failed school replacement imports leave no
|
||||||
partial changes or dangling references.
|
partial changes or dangling references.
|
||||||
- [ ] Two edits based on the same school revision cannot silently overwrite one
|
- [x] Two edits based on the same school revision cannot silently overwrite one
|
||||||
another: the stale edit receives a clear conflict response, and the user
|
another: the stale edit receives a clear conflict response, and the user
|
||||||
can reload and retry.
|
can reload and retry.
|
||||||
- [ ] Scheduling results based on outdated input revisions are rejected.
|
- [x] Scheduling results based on outdated input revisions are rejected.
|
||||||
- [ ] The latest accepted scheduling run, its stable identifier, timestamps,
|
- [x] The latest accepted scheduling run, its stable identifier, timestamps,
|
||||||
progress, outcome and objective summary survive normal restart and crash
|
progress, outcome and objective summary survive normal restart and crash
|
||||||
and are returned through the status API and SSE snapshots.
|
and are returned through the status API and SSE snapshots.
|
||||||
- [ ] Successful assignment replacement and completed run status commit
|
- [x] Successful assignment replacement and completed run status commit
|
||||||
atomically; success is published only after durable commit.
|
atomically; success is published only after durable commit.
|
||||||
- [ ] Calculations left Running become Interrupted before recovered status is
|
- [x] Calculations left Running become Interrupted before recovered status is
|
||||||
served; previous assignments remain, no automatic resumption occurs and a
|
served; previous assignments remain, no automatic resumption occurs and a
|
||||||
new run can start. Reopening does not replay old completion snackbars.
|
new run can start. Reopening does not replay old completion snackbars.
|
||||||
- [ ] Database unavailability prevents successful mutation acknowledgements;
|
- [x] Database unavailability prevents successful mutation acknowledgements;
|
||||||
startup connection and migration failures are clearly reported.
|
startup connection and migration failures are clearly reported.
|
||||||
- [ ] An empty database can be initialized through Flyway and populated through
|
- [x] An empty database can be initialized through Flyway and populated through
|
||||||
existing JSON import, preserving import/export behavior and identities.
|
existing JSON import, preserving import/export behavior and identities.
|
||||||
- [ ] Setup and transition instructions cover the database service, persistent
|
- [x] Setup and transition instructions cover the database service, persistent
|
||||||
storage, connection configuration, migrations and export-before-transition.
|
storage, connection configuration, migrations and export-before-transition.
|
||||||
|
|
||||||
## Implementation handoff
|
## Implementation handoff
|
||||||
|
|
||||||
This specification records the agreed design and behavior. Database provisioning,
|
PostgreSQL/JPA/Hibernate storage, Flyway migrations, school revision checks, durable scheduling status and
|
||||||
application changes and validation are subsequent implementation work.
|
restart recovery are implemented and verified. See [S16 validation](../S16-validation.md)
|
||||||
|
and [setup and transition instructions](../../docs/persistent-storage.md).
|
||||||
|
Implementation is ready for review; final acceptance is pending.
|
||||||
209
tooling/persistence/restart_test.py
Normal file
209
tooling/persistence/restart_test.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Exercise packaged-backend durability against an isolated, already running PostgreSQL container.
|
||||||
|
|
||||||
|
Build with mvn verify first. Never point this test at production: --database-container
|
||||||
|
is deliberately stopped and restarted to exercise failed mutation acknowledgements.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
JAVA = str(Path(os.environ['JAVA_HOME']) / 'bin/java') if os.environ.get('JAVA_HOME') else 'java'
|
||||||
|
|
||||||
|
|
||||||
|
class Backend:
|
||||||
|
def __init__(self, args):
|
||||||
|
self.args = args
|
||||||
|
self.base = f'http://127.0.0.1:{args.port}'
|
||||||
|
self.process = None
|
||||||
|
self.log = None
|
||||||
|
self.paths = []
|
||||||
|
|
||||||
|
def start(self, overrides=None, ready=True):
|
||||||
|
env = dict(os.environ, DATABASE_URL=self.args.url, DATABASE_USER=self.args.user,
|
||||||
|
DATABASE_PASSWORD=self.args.password, QUARKUS_HTTP_PORT=str(self.args.port))
|
||||||
|
env.update(overrides or {})
|
||||||
|
self.log = tempfile.NamedTemporaryFile(prefix='s16-backend-', suffix='.log', delete=False)
|
||||||
|
self.paths.append(self.log.name)
|
||||||
|
self.process = subprocess.Popen([JAVA, '-jar', str(ROOT / 'agiledancers-be/target/quarkus-app/quarkus-run.jar')],
|
||||||
|
cwd=ROOT, env=env, stdout=self.log, stderr=subprocess.STDOUT)
|
||||||
|
if ready:
|
||||||
|
deadline = time.monotonic() + 45
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if self.process.poll() is not None:
|
||||||
|
raise AssertionError(f'Backend exited: {Path(self.log.name).read_text()}')
|
||||||
|
try:
|
||||||
|
if self.request('/q/health/ready')[0] == 200:
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
time.sleep(.2)
|
||||||
|
raise AssertionError(f'Backend readiness timeout: {self.log.name}')
|
||||||
|
|
||||||
|
def stop(self, abrupt=False):
|
||||||
|
if self.process and self.process.poll() is None:
|
||||||
|
self.process.send_signal(signal.SIGKILL if abrupt else signal.SIGTERM)
|
||||||
|
self.process.wait(timeout=30)
|
||||||
|
if self.log:
|
||||||
|
self.log.close()
|
||||||
|
|
||||||
|
def request(self, path, method='GET', body=None, revision=None):
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if revision is not None:
|
||||||
|
headers['X-School-Revision'] = str(revision)
|
||||||
|
request = urllib.request.Request(self.base + path,
|
||||||
|
data=None if body is None else json.dumps(body).encode(), headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
response = urllib.request.urlopen(request, timeout=12)
|
||||||
|
except urllib.error.HTTPError as failure:
|
||||||
|
response = failure
|
||||||
|
with response:
|
||||||
|
data = response.read()
|
||||||
|
return response.status, json.loads(data) if data else None
|
||||||
|
|
||||||
|
def mutate(self, school, suffix, method='POST', body=None):
|
||||||
|
path = f'/api/dance-schools/{school}'
|
||||||
|
status, current = self.request(path)
|
||||||
|
assert status == 200, current
|
||||||
|
return self.request(path + suffix, method, body, current['revision'])
|
||||||
|
|
||||||
|
|
||||||
|
def imported(backend, size):
|
||||||
|
document = dict(formatVersion=3, key=str(uuid.uuid4()), name=f'S16 restart {size}',
|
||||||
|
dancers=[dict(id=str(index), name=f'Dancer {index}', birthDate='2000-01-01') for index in range(1, size + 1)], coaches=[],
|
||||||
|
groups=[dict(id=str(index), category=f'Group {index}', type='REGULAR', startAge=0, endAge=99,
|
||||||
|
styles=['JAZZ'], dancerIds=[str(index)], coachId=None) for index in range(1, size + 1)],
|
||||||
|
shows=[dict(id='1', name='Saved show', preferredDayParts=1, appearances={'REGULAR': 1}, finalized=False,
|
||||||
|
dayParts=[dict(date='2027-01-01', type='MORNING', groupIds=['1'])])])
|
||||||
|
status, school = backend.request('/api/dance-schools/import', 'POST', document)
|
||||||
|
assert status == 200, school
|
||||||
|
status, shows = backend.request(f'/api/dance-schools/{school["id"]}/shows')
|
||||||
|
assert status == 200
|
||||||
|
return school['id'], shows[0]['id']
|
||||||
|
|
||||||
|
|
||||||
|
def run_path(pair):
|
||||||
|
return f'/api/dance-schools/{pair[0]}/shows/{pair[1]}/schedule'
|
||||||
|
|
||||||
|
|
||||||
|
def assert_completed(backend, pair, original):
|
||||||
|
status, run = backend.request(run_path(pair))
|
||||||
|
assert status == 200 and run == original, run
|
||||||
|
status, shows = backend.request(f'/api/dance-schools/{pair[0]}/shows')
|
||||||
|
assert status == 200 and len(shows[0]['dayParts'][0]['danceGroupIds']) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def assert_interrupted(backend, pair, run, assignments):
|
||||||
|
status, current = backend.request(run_path(pair))
|
||||||
|
assert status == 200 and current['state'] == 'INTERRUPTED', current
|
||||||
|
assert current['runId'] == run['runId'] and current['finishedAt']
|
||||||
|
assert 'behouden' in current['message']
|
||||||
|
status, shows = backend.request(f'/api/dance-schools/{pair[0]}/shows')
|
||||||
|
assert status == 200 and shows[0]['dayParts'] == assignments
|
||||||
|
# Reconnected SSE snapshots carry stable recovered identities.
|
||||||
|
with urllib.request.urlopen(backend.base + '/api/scheduling/events', timeout=10) as stream:
|
||||||
|
while True:
|
||||||
|
line = stream.readline().decode().strip()
|
||||||
|
if line.startswith('data:'):
|
||||||
|
snapshot = json.loads(line[5:])
|
||||||
|
if snapshot['runs'] is not None:
|
||||||
|
assert any(item['runId'] == run['runId'] and item['state'] == 'INTERRUPTED'
|
||||||
|
for item in snapshot['runs'])
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--url', default='jdbc:postgresql://127.0.0.1:54329/agiledancers')
|
||||||
|
parser.add_argument('--user', default='agiledancers')
|
||||||
|
parser.add_argument('--password', default='s16-test-only')
|
||||||
|
parser.add_argument('--port', type=int, default=18086)
|
||||||
|
parser.add_argument('--database-container', required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
backend = Backend(args)
|
||||||
|
schools = []
|
||||||
|
try:
|
||||||
|
backend.start()
|
||||||
|
small = imported(backend, 1)
|
||||||
|
schools.append(small[0])
|
||||||
|
status, started = backend.mutate(small[0], f'/shows/{small[1]}/schedule')
|
||||||
|
assert status == 202, started
|
||||||
|
deadline = time.monotonic() + 20
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
_, completed = backend.request(run_path(small))
|
||||||
|
if completed['state'] != 'RUNNING':
|
||||||
|
break
|
||||||
|
time.sleep(.1)
|
||||||
|
assert completed['state'] == 'OPTIMAL' and completed['summary'] is not None, completed
|
||||||
|
backend.stop()
|
||||||
|
backend.start()
|
||||||
|
assert_completed(backend, small, completed)
|
||||||
|
large = imported(backend, 70)
|
||||||
|
schools.append(large[0])
|
||||||
|
_, shows = backend.request(f'/api/dance-schools/{large[0]}/shows')
|
||||||
|
assignments = shows[0]['dayParts']
|
||||||
|
for abrupt in (False, True):
|
||||||
|
status, run = backend.mutate(large[0], f'/shows/{large[1]}/schedule')
|
||||||
|
assert status == 202 and run['state'] == 'RUNNING', run
|
||||||
|
_, current = backend.request(run_path(large))
|
||||||
|
assert current['state'] == 'RUNNING', 'Fixture completed too quickly to test interruption'
|
||||||
|
backend.stop(abrupt=abrupt)
|
||||||
|
backend.start()
|
||||||
|
assert_interrupted(backend, large, run, assignments)
|
||||||
|
assert_completed(backend, small, completed)
|
||||||
|
_, before = backend.request(f'/api/dance-schools/{small[0]}')
|
||||||
|
subprocess.run(['docker', 'stop', args.database_container], check=True, stdout=subprocess.DEVNULL)
|
||||||
|
try:
|
||||||
|
status, failure = backend.request(f'/api/dance-schools/{small[0]}', 'PUT', {'name': 'Must not persist'}, before['revision'])
|
||||||
|
assert status == 503 and 'niet opgeslagen' in failure['message'], (status, failure)
|
||||||
|
assert backend.request('/q/health/ready')[0] == 503
|
||||||
|
finally:
|
||||||
|
subprocess.run(['docker', 'start', args.database_container], check=True, stdout=subprocess.DEVNULL)
|
||||||
|
backend.stop()
|
||||||
|
backend.start()
|
||||||
|
assert_completed(backend, small, completed)
|
||||||
|
assert backend.request(f'/api/dance-schools/{small[0]}')[1] == before
|
||||||
|
for school in schools:
|
||||||
|
assert backend.mutate(school, '', 'DELETE')[0] == 204
|
||||||
|
schools.clear()
|
||||||
|
backend.stop()
|
||||||
|
backend.start({'DATABASE_URL': 'jdbc:postgresql://127.0.0.1:1/unavailable'}, ready=False)
|
||||||
|
backend.process.wait(timeout=35)
|
||||||
|
assert backend.process.returncode != 0
|
||||||
|
assert 'Connection' in Path(backend.log.name).read_text()
|
||||||
|
backend.stop()
|
||||||
|
schema = 's16_invalid_' + uuid.uuid4().hex
|
||||||
|
def sql(statement):
|
||||||
|
subprocess.run(['docker', 'exec', args.database_container, 'psql', '-v', 'ON_ERROR_STOP=1',
|
||||||
|
'-U', args.user, '-d', args.url.rsplit('/', 1)[-1].split('?')[0], '-c', statement],
|
||||||
|
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
sql(f'CREATE SCHEMA {schema}; CREATE TABLE {schema}.school (blocker integer);')
|
||||||
|
try:
|
||||||
|
# Baseline version zero lets V1 run, then fail against the deliberately conflicting table.
|
||||||
|
backend.start({'QUARKUS_FLYWAY_SCHEMAS': schema, 'QUARKUS_FLYWAY_BASELINE_ON_MIGRATE': 'true',
|
||||||
|
'QUARKUS_FLYWAY_BASELINE_VERSION': '0'}, ready=False)
|
||||||
|
backend.process.wait(timeout=35)
|
||||||
|
assert backend.process.returncode != 0
|
||||||
|
assert 'Migration' in Path(backend.log.name).read_text()
|
||||||
|
finally:
|
||||||
|
backend.stop()
|
||||||
|
sql(f'DROP SCHEMA {schema} CASCADE;')
|
||||||
|
print('PASS: completed results, graceful interruption, SIGKILL recovery, stable SSE identity, database outage and startup/migration failure')
|
||||||
|
finally:
|
||||||
|
backend.stop()
|
||||||
|
print('Backend logs: ' + ', '.join(backend.paths))
|
||||||
|
if schools:
|
||||||
|
print('Test school IDs retained for diagnostics: ' + ', '.join(map(str, schools)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user