16 Commits

Author SHA1 Message Date
98d53e9cb5 Merge pull request 'adr' (#17) from adr into main
Some checks failed
Build, test and publish / verify (push) Failing after 8m44s
Reviewed-on: #17
2026-11-06 14:01:59 +01:00
36f4b15516 improved the adr skill on latest session and after ai feedback. removed dependency on other skill
All checks were successful
Build, test and publish / verify (push) Successful in 7m33s
2026-11-06 13:59:56 +01:00
0f1c5dcd92 skill development and ADR sessions
All checks were successful
Build, test and publish / verify (push) Successful in 7m44s
2026-11-06 13:26:25 +01:00
6acd005f98 Merge pull request 'Fix pipeline deployment with postgres' (#16) from S16_deploy-fix into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m10s
Reviewed-on: #16
2026-11-05 16:10:17 +01:00
01506ef837 Fix pipeline deployment with postgres
All checks were successful
Build, test and publish / verify (push) Successful in 6m15s
2026-11-05 11:20:49 +01:00
7a358ae623 Merge pull request 'S16_database' (#15) from S16_database into main
All checks were successful
Build, test and publish / verify (push) Successful in 7m21s
Reviewed-on: #15
2026-11-04 22:15:25 +01:00
2d8822c1a4 S16 - some transactional hardening
All checks were successful
Build, test and publish / verify (push) Successful in 7m32s
2026-11-04 21:54:20 +01:00
694bf3fa86 S16 postgresql opslag added
All checks were successful
Build, test and publish / verify (push) Successful in 7m31s
2026-11-04 17:10:25 +01:00
f59cad115a Merge pull request 'S12_async-feedback' (#14) from S12_async-feedback into main
All checks were successful
Build, test and publish / verify (push) Successful in 4m37s
Reviewed-on: #14
2026-11-04 08:35:22 +01:00
a3cff48a92 S12 - turned schedule status into enum and some other review comments
All checks were successful
Build, test and publish / verify (push) Successful in 7m6s
2026-11-04 08:05:39 +01:00
c241392132 S12 - Async feedback for scheduling
All checks were successful
Build, test and publish / verify (push) Successful in 7m25s
2026-11-03 23:18:44 +01:00
f1785ed174 S12 refinement result
All checks were successful
Build, test and publish / verify (push) Successful in 4m57s
2026-11-03 16:22:23 +01:00
b92fac1d1a Merge pull request 'S6: record production deployment and recovery evidence' (#13) from docs/S6-production-validation into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m9s
Reviewed-on: #13
2026-11-03 16:13:52 +01:00
95430691e4 updated spec status
All checks were successful
Build, test and publish / verify (push) Successful in 6m5s
2026-11-03 16:13:38 +01:00
95aa8c6302 testfile
Some checks failed
Build, test and publish / verify (push) Has been cancelled
2026-11-03 16:12:30 +01:00
3369b9a81c Record S6 production and NAS recovery acceptance [skip ci] 2026-11-03 15:34:31 +01:00
122 changed files with 7635 additions and 280 deletions

View File

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

View File

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

View File

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

View File

@@ -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 |
@@ -83,7 +94,8 @@ the solver live in `model.scheduling`. Each group requirement combines its group
ID and appearance count in one record. ID and appearance count in one record.
`service.port.SchedulingEngine` is the application-owned interface for solving a `service.port.SchedulingEngine` is the application-owned interface for solving a
scheduling problem. `SchedulingService` calls this interface; dependency injection scheduling problem. `SchedulingRuns` calls this interface for background runs;
`SchedulingService` retains the synchronous service entry point. Dependency injection
supplies `solver.OrToolsSchedulingEngine`. The port accepts and returns domain supplies `solver.OrToolsSchedulingEngine`. The port accepts and returns domain
types, so neither the caller nor the contract depends on OR-Tools or the concrete types, so neither the caller nor the contract depends on OR-Tools or the concrete
school-management service. Architecture tests enforce the port's location and school-management service. Architecture tests enforce the port's location and
@@ -160,11 +172,23 @@ dayparts; preschool groups once; showcases every daypart. See
[show-scheduling-rules.md](show-scheduling-rules.md) for the full hard constraints, [show-scheduling-rules.md](show-scheduling-rules.md) for the full hard constraints,
coach exceptions, ordering and optimization rules. coach exceptions, ordering and optimization rules.
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` generates an ordered `POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` returns `202` with a
draft using OR-Tools with a total thirty-minute time limit. A feasible result is run identifier and starts an ordered draft calculation in the background using
accepted without claiming optimality. Concurrent runs and stale results return OR-Tools with a total thirty-minute time limit. Duplicate starts for the same
`409`; failures retain existing assignments. Development and production proxies show return `409`; different shows may run concurrently. A feasible result is
allow 31 minutes. accepted without claiming optimality. Stale results and failures retain existing
assignments and appear as distinct terminal run states.
`GET .../shows/{showId}/schedule` returns the latest run (`204` if none exists).
`GET /api/scheduling/events` streams SSE JSON objects containing a `runs` snapshot
on connection and changes, or `runs: null` as a fifteen-second heartbeat. Clients
recover current state on reconnection rather than requiring event replay. The
production proxy disables buffering. The UI shows phase, elapsed time, solution
discovery and final objective scores, and reports completion in an application-wide
snackbar. Reloading recovers the latest state without replaying old notifications.
The latest run and saved assignments survive restart. Startup marks formerly
running calculations `INTERRUPTED`, preserves the previous assignments, and allows
a new run. Results and successful status commit in one database transaction.
`PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an `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;
@@ -269,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)
@@ -334,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.

33
adr/README.md Normal file
View File

@@ -0,0 +1,33 @@
The adr-folder contains Architecture Decision Records.
An Architecture Decision Record is a file that contains a decision that should be applied to all new and altered code and systems.
ADR's can only be added or altered on specific request by the user. Agents should never add or modify ADR's without explicit request to do so.
ADR's are immutable in essence. This means that the only modifications allowed are in its state, or when it applies to typo's, adding clarifications, or removing ambiguity.
An ADR MUST have the following properties:
- ID
- Title
- Supersedes
- Status (NEW, REFINED, APPROVED, REJECTED, EXPIRED)
- Expiration date
- Context
- Decisions
- Options considered with pro's and cons
- Consequences.
An _ID_ MUST be a unique number, prefixed with ADR (e.g. ADR1, ADR2, ADR3, etc.). This makes it easy to reference an ADR.
A _title_ is a short, unique description of the ADR. It should make it easy to know in a glance what the ADR is about.
The name of the file SHOULD contain the ID and the title, or a short variant of the title, of the ADR.
_Supersedes_ is an optional property that MAY reference another, expired ADR that it supersedes.
When _supersedes_ references another ADR, it MUST do so by referencing its ID.
When _supersedes_ references another ADR, the status of that other ADR MUST equal to EXPIRED.
_Status_ contains the state of the ADR. In the readme, when we mention state, we mean the value of this status-property. ADR's are always in a subfolder of the adr-folder, with the same name as the state, but in lowercase ('adr/new' for ADR's with state NEW, 'adr/refined' for ADR's with state REFINED, etc.).
Each created ADR will start with the state NEW. Apart from the ID and the state, each property can still be modified. Nothing that is in an ADR with state NEW, should be considered when writing or evaluating anything within the software landscape.
An RFD will get the state REFINED, when it is fully worked out. Only RFD's with the state NEW can be changed to REFINED. This means an effort has been made to get to a final version of the RFD. Getting an RFD from NEW to REFINED can be done with or without the help of an agent. However, an agent should always verify mutual understanding of the content with the human owner, before changing the state from NEW to REFINED.
An ADR with the state REFINED can be moved to either APPROVED or REJECTED. This step must be done manually, not by any agent. APPROVED means the decision in the ADR applies to all code and/or software added or altered as far as the content of the ADR applies to the situation. Exceptions are possible with explicit accord. Such an accord should be given by a person. REJECTED means that the ADR should not be considered anywhere when writing or evaluating anything within the software landscape.
An ADR with the state EXPIRED is no longer valid. It should no longer be considered anywhere when writing or evaluating anything within the software landscape, even though it had been the case previously. It MAY have been superseded by a new ADR, but that is not necessary.
An _expiration date_ indicated from when an ADR should be notified for reevaluation. Reaching the _expiration date_ does not mean the ADR is expired. It only means the ADR should be reevaluated by a person, with or without the help of an agent. As a result of the reevaluation, this ADR may get EXPIRED - only after explicit agreement of a person - and it MAY be superseded by a new one. Another option is that only the expiration date itself gets updated to a new expiration date. This also only after explicit agreement of a person.
_Context_ should explain the context of the problem where a decision had to be made for.
The _decision_ describes the decision. This is what all code MUST apply to, unless an explicit exception is approved by a person.
The _options_ describes the various alternatives that were considered, all with its pro's and con's, and why the chosen decision whas chosen.
_Consequences_ describes the consequences to the current state of the application landscape at the time of writing. This could be about a migration timeline, a new process, different tooling, or a change in the stack, or something else.

View File

@@ -0,0 +1,215 @@
**ID:** ADR1 \
**Title:** Application size \
**Supersedes:** - \
**Status:** REFINED \
**Expiration Date:** Six months after approval; record the calendar date upon manual approval.
## Context
Agile Dancers currently keeps its frontend, backend, CI pipeline, skills,
specifications, ADRs, and other documentation in one repository. As the application
grows, humans and agents need more context to understand individual tasks.
Functional dependencies can become entangled, making changes and reuse harder.
The primary goal is to reduce the context required for a task. Supporting goals
are preventing uncontrolled coupling and enabling reuse and replacement of
capabilities. Repository organization, module boundaries, and deployment
boundaries are separate architectural choices.
Business capabilities stand on their own, while an application system may support
multiple capabilities. Loosely coupled capability boundaries allow implementations
to change between custom software, libraries, and externally supplied components.
Scheduling and school administration are candidate areas to examine; their exact
system boundaries have not yet been decided.
Code-level modularity alone is insufficient when shared tables, materialized
views, SQL scripts, or other database access bypass module boundaries. Separate
services would have the same weakness without enforced data ownership.
Systems can initially run on the same physical server. Independent workload
placement is nevertheless useful: computational work, such as runners and future
scheduling calculations, may run on a mini-PC rather than the Synology. Independent
scaling and release cycles are future possibilities rather than universal current
requirements. Colocation reduces network exposure but process restarts, timeouts,
and resource contention still require defined failure behavior.
Reusable engineering resources should also support other projects. Specifications
can affect several projects, and ADRs apply across the application landscape.
## Decisions
### Capabilities and systems
Organize the landscape into independently deployable systems. Group capabilities
into systems according to cohesion, data ownership, replacement needs, and
operational requirements, including workload placement. Capability size alone does
not justify a separate system.
Each system has its own repository and independent deployment lifecycle. Within a
system, use modules to keep its capabilities loosely coupled. A capability does
not automatically require its own service or deployment. Compute workers may be
deployed separately from the business system that delegates work to them.
Capability contracts should allow implementations to be replaced by custom code,
libraries, or external products, using adapters where necessary.
### Data ownership
Each system exclusively owns its data. Other systems access that data through
explicit contracts, never through direct access to its storage. This restriction
also applies to reporting queries, views, migrations, and maintenance scripts.
Cross-system reporting requires explicit contracts or a separately owned
projection.
PostgreSQL-backed systems initially share one PostgreSQL server/container, with
separate databases or schemas and separate credentials whose permissions enforce
ownership. Sharing physical infrastructure does not permit sharing storage access.
Systems may choose other storage technologies when justified. This permits future
CQRS designs without adopting CQRS through this ADR.
### Contracts and communication
Systems communicate through explicit, versioned contracts. Internal domain models
and persistence entities remain private to their owning system and are not shared
through common libraries. Contract definitions and generated clients may be shared.
Choose synchronous APIs or asynchronous messaging for each interaction according
to its needs. Define failure behavior for each interaction. This ADR mandates
neither a landscape-wide communication mechanism nor a message broker.
### Landscape and engineering resources
Maintain a landscape repository containing ADRs, specifications, and the capability
and system map. Specifications identify the projects they affect and may span
multiple systems. ADRs govern the application landscape according to their scope.
Each system retains a README describing its implementation and operation.
Maintain reusable engineering resources, including pipeline components, skills,
and general engineering documentation, separately from the landscape repository
and individual systems. Group these resources according to maintenance and release
needs; a separate repository per resource type is not required. Version shared
resources so consuming projects can adopt updates deliberately.
### Adoption and review
Use this ADR as guidance and the starting point for follow-up specifications.
Detailed system boundaries, contracts, and migration designs are follow-up work.
Migrate incrementally, keeping the application working after each extraction.
Perform an initial review six months after approval. Review earlier if contract
coordination, operational overhead, or persistent cross-system coupling becomes a
significant burden. The expiration date triggers reevaluation and does not
automatically invalidate the decision.
## Options considered
### 1. Improve navigation within the current structure
Keep the existing projects and deployment structure, improving focused
documentation, task guidance, and file organization.
Pros:
- Requires little disruption or migration work.
- Makes relevant context easier to find.
- Retains simple local development and coordinated changes.
Cons:
- Offers limited protection against functional entanglement.
- Does not enforce data ownership or support independent workload placement.
- Leaves reuse and replacement dependent on existing application boundaries.
Not chosen because navigation improvements alone do not establish the ownership
and replacement boundaries needed as the landscape grows.
### 2. Modular monolith in one repository
Organize capabilities into modules with explicit interfaces and dependency rules,
while retaining coordinated deployment and one repository.
Pros:
- Reduces the code area needed to understand a capability.
- Enables dependency checks and straightforward changes across modules.
- Keeps deployment and in-process communication simple.
Cons:
- Shared database access can bypass code-level boundaries through tables, views,
and scripts unless storage ownership is separately enforced.
- Workload placement and deployment remain coupled.
- Independent replacement and reuse may require later extraction.
Not chosen as the landscape-wide approach because independently enforced ownership
and deployment boundaries are desired. Modular design remains part of the chosen
approach inside each system.
### 3. Separate projects and repositories with coordinated deployment
Extract capabilities into separately built libraries or applications with
versioned contracts, while retaining a coordinated deployment lifecycle.
Pros:
- Separates source and build context.
- Gives reusable components their own versioned releases.
- Retains a coordinated operational release process.
Cons:
- Changes spanning repositories require dependency release coordination.
- Deployment remains coupled despite repository separation.
- Repository separation alone does not enforce storage ownership or runtime
independence.
Not chosen because it incurs repository coordination costs while retaining a
deployment constraint that would limit workload placement and replacement.
### 4. Independently deployable systems with internal capability modules — chosen
Give systems their own repositories, deployment lifecycles, contracts, and enforced
data ownership. Keep capabilities loosely coupled through modules within a system.
Place compute workers independently where useful.
Pros:
- Supports focused development context and independent system evolution.
- Enforces data ownership beyond code dependency rules through storage permissions.
- Allows workloads to move between the Synology and mini-PC.
- Supports capability replacement and reuse through explicit contracts.
- Allows storage technology choices per system.
Cons:
- Requires contract compatibility and coordination for changes spanning systems.
- Adds deployment, configuration, and operational maintenance for multiple processes.
- Requires explicit failure handling and cross-system consistency decisions.
- Sharing one server or PostgreSQL instance retains common infrastructure failure
points despite logical isolation.
Chosen because ownership, replaceability, focused context, and independent workload
placement outweigh the additional operational and contract coordination work.
## Consequences
- Write follow-up specifications to map capabilities and dependencies, choose
initial system boundaries, define contracts and data ownership, and plan
extraction. This ADR does not itself settle those boundaries or implement them.
- Establish the landscape repository and the repositories and versioning needed
for reusable engineering resources. Preserve links between specifications and
affected projects, and between system READMEs and landscape guidance.
- Extract systems one at a time, establishing contracts and enforcing storage
ownership as part of each extraction. Moving tables into separate schemas is
insufficient while cross-system SQL access remains possible.
- Adapt build and deployment pipelines to release systems independently and
consume versioned shared engineering resources.
- Add appropriate checks for module dependencies, contract compatibility, and
storage isolation. Include views and scripts in boundary enforcement.
- Specify how systems exchange information, handle failed interactions, and keep
derived information consistent. These choices belong to the relevant follow-up
specifications rather than a universal messaging requirement.
- Keep one PostgreSQL container initially. Detailed backup and failover design is
future work; logical data separation does not provide infrastructure failover.
- Record the calendar review date when a maintainer manually approves this ADR.
REFINED records the agreed proposal; approval remains a separate manual step.

View File

@@ -0,0 +1,176 @@
**ID:** ADR2 \
**Title:** HTTP API contracts and tooling \
**Supersedes:** - \
**Status:** REFINED \
**Expiration Date:** Six months after approval; record the calendar date upon manual approval.
## Context
Agile Dancers currently uses REST without an explicit architectural decision.
The landscape needs agreed defaults for communication across system boundaries,
contract documentation, client generation, compatibility, and error handling.
Interactions include frontend access, requests between business systems,
delegation of long-running calculations to workers, change notifications, and
external consumers. Most clients and servers are developed by the same maintainer.
Consumers must nevertheless be able to upgrade independently. The independently
deployable systems proposed in ADR1 are relevant context; ADR1 remains REFINED
and is not approved through this decision.
The current frontend uses Angular, but future clients may use other frameworks
or languages. Contracts and tooling should support that flexibility.
For now, interactions use a defined alternative path when another system is
offline. Durable queued delivery is not a current requirement. More specialized
communication mechanisms may become appropriate for particular interactions.
## Decisions
### Scope and communication style
Use HTTP APIs with JSON payloads as the default across system boundaries.
Use resource-oriented endpoints where natural and action-oriented endpoints
where they express business intent more clearly. Do not force business actions
into CRUD operations. For example:
```text
GET /api/v1/schedules/{id}
POST /api/v1/schedules/{id}/calculate
```
Action endpoints still follow HTTP method and status semantics. For long-running
work, use an explicit job or operation resource with a status endpoint when the
caller needs to track progress. A notification can initially use a direct HTTP
request with documented failure behavior; this does not promise durable delivery.
These are landscape defaults. Document justified exceptions alongside the
relevant interface, including the reason and the alternative contract and failure
behavior. This ADR does not mandate GraphQL, RPC, or messaging infrastructure.
### Contracts and documentation
Describe HTTP APIs using OpenAPI. Normally generate the description from server
code, adding explicit descriptions and annotations where needed. Review the
resulting contract as a public interface; generation alone does not ensure
completeness or backward compatibility.
Use self-hosted Scalar API Reference as the default interactive documentation UI.
Keep the OpenAPI document independently available so consumers and tooling do not
depend on the UI. Publish a contract artifact for each supported major version.
Choose an OpenAPI specification version supported by the server tooling, Scalar,
and the selected client generators. Do not automatically adopt the latest version
without checking that compatibility.
### Client generation
Generate client transport code and contract types from OpenAPI by default, using
OpenAPI Generator. Select the generator appropriate to the consuming technology;
use `typescript-angular` for Angular clients. This choice does not require future
frontends to use Angular.
Pin generator versions and configuration for reproducible generation. Generate
against a selected contract artifact rather than requiring a running server during
the client build. Keep generated code separate from handwritten application logic
and do not manually edit generated files. Generated static types do not by
themselves provide runtime response validation.
### Versioning and compatibility
Put the major contract version in the URL, such as `/api/v1/schedules`.
Keep backward-compatible changes within that major version. Introduce a new
major version for breaking changes. Software release and generated client package
versions may advance independently of the major contract version.
Allow consumers to upgrade independently. Normally support one contract version;
during transitions, aim to support two. This is a guideline rather than a hard
cap: additional versions may coexist when justified. Document supported versions,
deprecation, and the transition plan, coordinating retirement with affected
consumers. Check compatibility before releasing contract changes.
### Errors and offline behavior
Standardize API error bodies on RFC 9457 Problem Details using
`application/problem+json` and appropriate HTTP status codes. Use stable problem
`type` identifiers for programmatic handling and readable explanations for humans.
Clients must not depend on parsing human-readable messages. Document expected
errors in OpenAPI, including validation details where applicable. Do not expose
internal exception details or sensitive information in error responses.
Define timeouts and the offline alternative path for each interaction. A network
failure may produce no HTTP response, so consumers must handle that separately
from a Problem Details response. Avoid automatic retries of actions unless
repeating them is safe. Do not assume durable delivery or eventual replay.
### Adoption and review
Adopt incrementally for new interfaces and interfaces being substantially changed.
Migrate existing interfaces through planned work while preserving compatibility
for existing consumers. No immediate landscape-wide rewrite is required.
Review six months after approval, or earlier if integration needs expose
limitations. The review date triggers reevaluation rather than automatic expiry.
## Options considered
### Communication approach
| Option | Pros | Cons | Outcome |
| --- | --- | --- | --- |
| Resource-oriented HTTP/JSON with action endpoints | Readable resources and business actions; supports different client technologies; retains HTTP semantics. | Requires consistent endpoint conventions and explicit handling of long-running work and failures. | Chosen as the default. |
| GraphQL | Flexible data selection; typed schema; supports different consumer views of related data. | Adds query authorization and performance concerns; requires additional conventions for work delegation and notifications. | Not chosen: current needs and common client/server ownership do not justify the added complexity. |
| RPC, including JSON-RPC and gRPC | Explicit operation contracts; JSON-RPC is lightweight; gRPC supports strongly typed generated clients. | Operation-oriented contracts require careful evolution; gRPC adds browser integration requirements. | Not chosen as the default: resource-oriented HTTP with optional actions better matches the desired interface flexibility. RPC remains a possible justified exception. |
### Contract description and authoring
| Option | Pros | Cons | Outcome |
| --- | --- | --- | --- |
| OpenAPI, normally generated from server code | Language-independent HTTP contracts; supports documentation and client generation; avoids separately maintaining a design language. | Needs annotations, review, and compatibility checks; generated output can be incomplete. | Chosen. |
| TypeSpec generating OpenAPI | Dedicated contract authoring language; can generate descriptions and other artifacts. | Adds a language and compiler to maintain. | Not chosen: no current benefit sufficient to justify the additional tooling. |
| RAML | API description language with reusable modeling constructs. | Its specification repository is archived, creating a maintenance concern for a new standard. | Not chosen. |
### Interactive documentation
| Option | Pros | Cons | Outcome |
| --- | --- | --- | --- |
| Scalar API Reference | Readable interactive documentation; integrated request testing; self-hosting and configuration options. | Requires integration and maintenance alongside the server framework. | Chosen: readability and testability are the primary purposes of the UI. |
| Swagger UI | Interactive requests and authentication support; direct integration with Quarkus OpenAPI tooling. | Its expandable operation layout is less aligned with the preferred reading experience. | Not chosen as the landscape default. |
### Client generation
| Option | Pros | Cons | Outcome |
| --- | --- | --- | --- |
| OpenAPI Generator | Supports Angular and other client technologies in one tool family; configurable generation. | Generator-specific options and upgrades need management. | Chosen to support future technology changes. |
| ng-openapi-gen | Angular-focused generation; npm-based tooling. | Other client technologies require another generator. | Not chosen as the landscape default. |
| Handwritten clients | Direct control over client code and application integration. | Duplicates contract types and request details; increases drift risk. | Not chosen as the default. |
## Consequences
- Apply the defaults to new and substantially changed interfaces. Plan migration
of existing interfaces incrementally, preserving consumer compatibility.
- Generate and publish versioned OpenAPI artifacts. Add Scalar to provide
interactive documentation and request testing for supported contracts.
- Establish pinned OpenAPI Generator configurations for client technologies,
initially Angular. Keep generated code separate from application logic.
- Add contract validation and compatibility checks to release workflows. Verify
generated clients against the actual contract before adopting generator upgrades.
- Introduce URL major versions and coordinate deprecation with consumers. Normally
maintain one version and temporarily two, documenting justified deviations.
- Align API error responses with Problem Details and document expected failures.
Define timeout and offline behavior per interaction, including safe retry rules.
- Record justified exceptions alongside their interfaces rather than imposing an
unsuitable communication mechanism on every interaction.
- Record the calendar review date upon manual approval. REFINED captures the
agreed proposal; approval remains a separate manual step.
### References
- [OpenAPI specification](https://spec.openapis.org/oas/)
- [Scalar API Reference configuration](https://scalar.com/products/api-references/configuration)
- [OpenAPI Generator: TypeScript Angular](https://openapi-generator.tech/docs/generators/typescript-angular/)
- [RFC 9457: Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457.html)
- [Swagger UI configuration](https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/)
- [TypeSpec overview](https://learn.microsoft.com/en-us/azure/developer/typespec/overview/)
- [RAML specification repository](https://github.com/raml-org/raml-spec)
- [JSON-RPC specification](https://www.jsonrpc.org/specification)
- [ng-openapi-gen](https://github.com/cyclosproject/ng-openapi-gen)

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package nl.agilecc.agiledancers.model.scheduling;
public enum SchedulingFailureKind {
INFEASIBLE,
NO_SOLUTION,
SOLVER_FAILED
}

View File

@@ -0,0 +1,8 @@
package nl.agilecc.agiledancers.model.scheduling;
public enum SchedulingPhase {
PREPARING,
ATTENDANCE,
SPREAD,
VARIETY
}

View File

@@ -0,0 +1,4 @@
package nl.agilecc.agiledancers.model.scheduling;
/** Progress is observational; intermediate assignments are never published or applied. */
public record SchedulingProgress(SchedulingPhase phase, boolean feasible) {}

View File

@@ -0,0 +1,60 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.time.Instant;
import java.util.UUID;
/** Latest immutable state for one show's calculation; runId identifies notifications across reconnects. */
public record SchedulingRun(
UUID runId,
long schoolId,
long showId,
String showName,
Instant startedAt,
Instant finishedAt,
SchedulingState state,
SchedulingProgress progress,
String message,
SchedulingSummary summary) {
public static SchedulingRun started(final long schoolId, final long showId, final String name) {
return new SchedulingRun(
UUID.randomUUID(),
schoolId,
showId,
name,
Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS),
null,
SchedulingState.RUNNING,
new SchedulingProgress(SchedulingPhase.PREPARING, false),
"Berekening gestart.",
null);
}
public SchedulingRun progressed(final SchedulingProgress update) {
return new SchedulingRun(
runId,
schoolId,
showId,
showName,
startedAt,
null,
state,
new SchedulingProgress(update.phase(), progress.feasible() || update.feasible()),
message,
null);
}
public SchedulingRun completed(
final SchedulingState outcome, final String explanation, final SchedulingSummary scores) {
return new SchedulingRun(
runId,
schoolId,
showId,
showName,
startedAt,
Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS),
outcome,
new SchedulingProgress(progress.phase(), progress.feasible() || scores != null),
explanation,
scores);
}
}

View File

@@ -0,0 +1,13 @@
package nl.agilecc.agiledancers.model.scheduling;
public enum SchedulingState {
RUNNING,
INTERRUPTED,
OPTIMAL,
VALID,
STALE,
INVALID_INPUT,
INFEASIBLE,
NO_SOLUTION,
SOLVER_FAILED
}

View File

@@ -0,0 +1,59 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Scores match the solver objectives; lower values are better. */
public record SchedulingSummary(long attendanceExcess, int performanceSpread, long varietyPenalty) {
public static SchedulingSummary of(final SchedulingInput input, final SchedulingResult result) {
final List<List<Long>> parts = result.assignments();
final Map<Long, GroupRequirement> groups = new HashMap<>();
input.groups().forEach((final GroupRequirement group) -> groups.put(group.groupId(), group));
final int min = parts.stream().mapToInt(List::size).min().orElse(0);
final int max = parts.stream().mapToInt(List::size).max().orElse(0);
return new SchedulingSummary(attendance(input, parts), max - min, variety(parts, groups));
}
private static long attendance(final SchedulingInput input, final List<List<Long>> parts) {
final Set<Long> showcases = new HashSet<>();
final Map<Long, Set<Integer>> attendance = new HashMap<>();
for (final GroupRequirement group : input.groups()) {
if (group.showcase()) {
showcases.addAll(group.dancerIds());
} else {
recordAttendance(group, parts, attendance);
}
}
showcases.removeAll(input.coachDancerIds());
return showcases.stream()
.mapToLong((final Long dancer) ->
Math.max(0, attendance.getOrDefault(dancer, Set.of()).size() - input.preferredDayParts()))
.sum();
}
private static void recordAttendance(
final GroupRequirement group, final List<List<Long>> parts, final Map<Long, Set<Integer>> attendance) {
for (int part = 0; part < parts.size(); part++) {
if (parts.get(part).contains(group.groupId())) {
for (final long dancer : group.dancerIds()) {
attendance
.computeIfAbsent(dancer, (final Long id) -> new HashSet<>())
.add(part);
}
}
}
}
private static long variety(final List<List<Long>> parts, final Map<Long, GroupRequirement> groups) {
long penalty = 0;
for (final List<Long> part : parts) {
for (int index = 1; index < part.size(); index++) {
penalty += VarietyScore.penalty(groups.get(part.get(index - 1)), groups.get(part.get(index)));
}
}
return penalty;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
package nl.agilecc.agiledancers.rest;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.subscription.BackPressureStrategy;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.Duration;
import java.util.List;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.rest.apimodel.SchedulingEventView;
import nl.agilecc.agiledancers.service.SchedulingRuns;
import org.jboss.resteasy.reactive.RestStreamElementType;
@Path("/api/scheduling/events")
public class SchedulingEventsResource {
private @Inject SchedulingRuns scheduling;
@GET
@Produces(MediaType.SERVER_SENT_EVENTS)
@RestStreamElementType(MediaType.APPLICATION_JSON)
public Multi<SchedulingEventView> events() {
final Multi<SchedulingEventView> updates = Multi.createFrom()
.emitter(
(final io.smallrye.mutiny.subscription.MultiEmitter<? super SchedulingEventView> emitter) -> {
final Runnable unsubscribe = scheduling.subscribe(
(final List<SchedulingRun> runs) -> emitter.emit(new SchedulingEventView(runs)));
emitter.onTermination(unsubscribe);
},
BackPressureStrategy.LATEST);
// Heartbeats keep idle proxy connections alive without changing the current snapshot.
final Multi<SchedulingEventView> heartbeat = Multi.createFrom()
.ticks()
.every(Duration.ofSeconds(15))
.onItem()
.transform((final Long tick) -> new SchedulingEventView(null));
return Multi.createBy()
.merging()
.streams(updates, heartbeat)
.runSubscriptionOn(io.smallrye.mutiny.infrastructure.Infrastructure.getDefaultWorkerPool());
}
}

View File

@@ -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.");
}
}
}

View File

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

View File

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

View File

@@ -18,23 +18,26 @@ 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.SchedulingResult; 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.SchedulingView;
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.service.SchedulingService; import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
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;
@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 SchedulingService scheduling; private @Inject SchedulingRuns scheduling;
private @Inject ShowSchedules schedules; private @Inject ShowSchedules schedules;
@@ -68,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) {
@@ -105,10 +108,14 @@ public class ShowResource {
@POST @POST
@Path("/{id}/schedule") @Path("/{id}/schedule")
@Consumes(MediaType.WILDCARD) @Consumes(MediaType.WILDCARD)
public SchedulingView schedule(final @PathParam("id") long id) { public Response schedule(final @PathParam("id") long id) {
final SchedulingResult result = scheduling.schedule(schoolId, id); return Response.accepted(scheduling.start(schoolId, id)).build();
return new SchedulingView( }
result.optimal(), result.optimal() ? "Optimal schedule" : "Valid schedule; optimality not proven");
@GET
@Path("/{id}/schedule")
public SchedulingRun schedulingStatus(final @PathParam("id") long id) {
return scheduling.status(schoolId, id);
} }
@DELETE @DELETE

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.util.List;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
/** A null runs field is a heartbeat, not a replacement of the current snapshot. */
public record SchedulingEventView(List<SchedulingRun> runs) {
public SchedulingEventView {
runs = runs == null ? null : List.copyOf(runs);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
/** Carries a stable calculation outcome independently of its human-readable explanation. */
public class SchedulingFailure extends ManagementException {
private final SchedulingFailureKind outcome;
public SchedulingFailure(final SchedulingFailureKind outcome, final String message) {
super(ManagementExceptionKind.INVALID_INPUT, message);
this.outcome = outcome;
}
public SchedulingFailureKind getOutcome() {
return outcome;
}
}

View File

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

View File

@@ -0,0 +1,240 @@
package nl.agilecc.agiledancers.service;
import io.quarkus.runtime.Startup;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
import nl.agilecc.agiledancers.repository.StorageException;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
/** Owns background tasks; the school monitor orders short commits and publication, never solver computation. */
@Startup
@Singleton
public class SchedulingRuns {
private final SchoolManagementService schools;
private final SchedulingEngine engine;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
private final Map<Long, SchedulingRun> runs = new HashMap<>();
private final Map<Object, Consumer<List<SchedulingRun>>> listeners = new HashMap<>();
private @Inject SchedulingPersistence persistence;
@Inject
public SchedulingRuns(final SchoolManagementService schools, final SchedulingEngine engine) {
this.schools = schools;
this.engine = engine;
}
@PostConstruct
public void recover() {
persistence.recover().forEach((final SchedulingRun run) -> runs.put(run.showId(), run));
}
public SchedulingRun start(final long schoolId, final long showId) {
synchronized (schools) {
final SchedulingInput input = schools.beginScheduling(schoolId, showId);
final SchedulingRun run = SchedulingRun.started(
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);
try {
executor.submit(() -> calculate(run, input));
} catch (final RuntimeException failure) {
finish(run.completed(
SchedulingState.SOLVER_FAILED, "Berekening kon niet starten. Bestaande indeling behouden.", null));
if (persistence == null) {
throw failure;
}
}
}
public SchedulingRun status(final long schoolId, final long showId) {
synchronized (schools) {
schools.show(schoolId, showId);
return runs.get(showId);
}
}
/** Registration and initial snapshot share the publication lock, preventing a missed update. */
public Runnable subscribe(final Consumer<List<SchedulingRun>> listener) {
synchronized (schools) {
if (persistence != null) {
runs.clear();
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 void unsubscribe(final Object key) {
synchronized (schools) {
listeners.remove(key);
}
}
private void publish(final SchedulingRun run) {
synchronized (schools) {
runs.put(run.showId(), run);
final List<SchedulingRun> snapshot = List.copyOf(runs.values());
for (final Consumer<List<SchedulingRun>> listener : List.copyOf(listeners.values())) {
notifyListener(listener, snapshot);
}
}
}
private void notifyListener(final Consumer<List<SchedulingRun>> listener, final List<SchedulingRun> snapshot) {
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) {
try {
final SchedulingResult result =
engine.solve(input, (final SchedulingProgress update) -> progress(run, update));
final SchedulingSummary summary = SchedulingSummary.of(input, result);
work(() -> {
schools.finishScheduling(run.schoolId(), input, result);
if (persistence != null) {
persistence.result(run.schoolId());
}
save(latest(run)
.completed(
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) {
finish(failed(run, failure));
} catch (final RuntimeException | LinkageError failure) {
finish(latest(run)
.completed(
SchedulingState.SOLVER_FAILED, "Berekening mislukt. Bestaande indeling behouden.", null));
}
}
private SchedulingRun failed(final SchedulingRun run, final ManagementException failure) {
final SchedulingState outcome = failure instanceof final SchedulingFailure scheduling
? switch (scheduling.getOutcome()) {
case INFEASIBLE -> SchedulingState.INFEASIBLE;
case NO_SOLUTION -> SchedulingState.NO_SOLUTION;
case SOLVER_FAILED -> SchedulingState.SOLVER_FAILED;
}
: (failure.getKind() == ManagementExceptionKind.CONFLICT
? SchedulingState.STALE
: SchedulingState.INVALID_INPUT);
return latest(run).completed(outcome, failure.getMessage() + " Bestaande indeling behouden.", null);
}
private SchedulingRun latest(final SchedulingRun run) {
synchronized (schools) {
return runs.get(run.showId());
}
}
private void finish(final SchedulingRun completed) {
try {
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
public void close() {
executor.shutdownNow();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,15 @@
package nl.agilecc.agiledancers.service.port; package nl.agilecc.agiledancers.service.port;
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.SchedulingResult; import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
public interface SchedulingEngine { public interface SchedulingEngine {
/** Computes assignments without mutating the captured inputs; the caller validates and commits the result. */ /** Computes assignments without mutating the captured inputs; the caller validates and commits the result. */
public SchedulingResult solve(final SchedulingInput input); public SchedulingResult solve(final SchedulingInput input);
/** Reports phase changes and solution discovery while calculating. */
public default SchedulingResult solve(final SchedulingInput input, final Consumer<SchedulingProgress> observer) {
return solve(input);
}
} }

View File

@@ -7,10 +7,13 @@ import com.google.ortools.sat.CpSolverStatus;
import com.google.ortools.sat.LinearExpr; import com.google.ortools.sat.LinearExpr;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import java.util.List; import java.util.List;
import java.util.function.Consumer;
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.SchedulingPhase;
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.service.ManagementException; import nl.agilecc.agiledancers.service.SchedulingFailure;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.port.SchedulingEngine; import nl.agilecc.agiledancers.service.port.SchedulingEngine;
@Singleton @Singleton
@@ -28,6 +31,11 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
@Override @Override
public SchedulingResult solve(final SchedulingInput input) { public SchedulingResult solve(final SchedulingInput input) {
return solve(input, (final SchedulingProgress progress) -> {});
}
@Override
public SchedulingResult solve(final SchedulingInput input, final Consumer<SchedulingProgress> observer) {
final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L); final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L);
Loader.loadNativeLibraries(); Loader.loadNativeLibraries();
final CpModel model = new CpModel(); final CpModel model = new CpModel();
@@ -38,7 +46,13 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
solver.getParameters().setCatchSigintSignal(false); solver.getParameters().setCatchSigintSignal(false);
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0); solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
SchedulingResult best = null; SchedulingResult best = null;
for (final LinearExpr objective : List.of(attendance, LinearExpr.term(vars.spread(), 1), variety)) { final List<LinearExpr> objectives = List.of(attendance, LinearExpr.term(vars.spread(), 1), variety);
final List<SchedulingPhase> phases =
List.of(SchedulingPhase.ATTENDANCE, SchedulingPhase.SPREAD, SchedulingPhase.VARIETY);
for (int stage = 0; stage < objectives.size(); stage++) {
final LinearExpr objective = objectives.get(stage);
final SchedulingPhase phase = phases.get(stage);
observer.accept(new SchedulingProgress(phase, best != null));
final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0; final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0;
if (remaining <= 0) { if (remaining <= 0) {
if (best != null) { if (best != null) {
@@ -48,7 +62,7 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
} }
solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining)); solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining));
model.minimize(objective); model.minimize(objective);
final CpSolverStatus status = solver.solve(model); final CpSolverStatus status = solver.solve(model, new SchedulingSolutionObserver(observer, phase));
if (status == CpSolverStatus.UNKNOWN && best != null) { if (status == CpSolverStatus.UNKNOWN && best != null) {
return new SchedulingResult(false, best.assignments()); return new SchedulingResult(false, best.assignments());
} }
@@ -64,8 +78,12 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
private static void requireSolution(final CpSolverStatus status) { private static void requireSolution(final CpSolverStatus status) {
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) { if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) {
throw new ManagementException( throw new SchedulingFailure(
ManagementExceptionKind.INVALID_INPUT, switch (status) {
case INFEASIBLE -> SchedulingFailureKind.INFEASIBLE;
case UNKNOWN -> SchedulingFailureKind.NO_SOLUTION;
default -> SchedulingFailureKind.SOLVER_FAILED;
},
switch (status) { switch (status) {
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved."; case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
case UNKNOWN -> case UNKNOWN ->

View File

@@ -0,0 +1,26 @@
package nl.agilecc.agiledancers.solver;
import com.google.ortools.sat.CpSolverSolutionCallback;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
/** Publish only the first feasible solution in a phase, avoiding an event per solver improvement. */
public class SchedulingSolutionObserver extends CpSolverSolutionCallback {
private final Consumer<SchedulingProgress> observer;
private final SchedulingPhase phase;
private boolean reported;
public SchedulingSolutionObserver(final Consumer<SchedulingProgress> observer, final SchedulingPhase phase) {
this.observer = observer;
this.phase = phase;
}
@Override
public void onSolutionCallback() {
if (!reported) {
reported = true;
observer.accept(new SchedulingProgress(phase, true));
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
@@ -92,7 +92,8 @@ class S18ResourceTest {
final long dancer = dancer(school, "Coach"); final long dancer = dancer(school, "Coach");
group(school, dancer); group(school, dancer);
final String show = show(school); final String show = show(school);
given().post(show + "/schedule").then().statusCode(200); given().post(show + "/schedule").then().statusCode(202);
awaitScheduling(show);
given().post(show + "/finalize").then().statusCode(200); given().post(show + "/finalize").then().statusCode(200);
final long coach = given().contentType("application/json") final long coach = given().contentType("application/json")
.body(Map.of("dancerId", dancer)) .body(Map.of("dancerId", dancer))
@@ -133,4 +134,13 @@ class S18ResourceTest {
given().delete(school).then().statusCode(204); given().delete(school).then().statusCode(204);
} }
} }
private static void awaitScheduling(final String show) {
final long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15);
while ("RUNNING".equals(given().get(show + "/schedule").jsonPath().getString("state"))
&& System.nanoTime() < deadline) {
java.util.concurrent.locks.LockSupport.parkNanos(java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(20));
}
given().get(show + "/schedule").then().statusCode(200).body("state", equalTo("OPTIMAL"));
}
} }

View File

@@ -0,0 +1,143 @@
package nl.agilecc.agiledancers;
import static nl.agilecc.agiledancers.RevisionRequests.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.test.common.http.TestHTTPResource;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
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.DayPartType;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import org.junit.jupiter.api.Test;
@QuarkusTest
class SchedulingEventsTest {
private @Inject SchoolManagementService schools;
private @Inject ObjectMapper json;
private @Inject nl.agilecc.agiledancers.service.SchoolTransactions transactions;
private @TestHTTPResource URI base;
public @Test void streamStartsWithSnapshotAndPushesCompletionAndReconnectSnapshot() throws Exception {
final long school = (Long) transactions.request(
null, null, true, () -> schools.createSchool("SSE school").getId());
try {
final Show show = (Show) transactions.request(school, 0L, true, () -> show(school));
final String path = "/api/dance-schools/" + school + "/shows/" + show.getId() + "/schedule";
given().get(path).then().statusCode(204);
try (final HttpClient client = HttpClient.newHttpClient();
final ExecutorService executor = Executors.newSingleThreadExecutor();
final InputStream stream = stream(client);
final BufferedReader reader =
new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
assertNotNull(next(reader).get("runs"));
final String runId = given().post(path)
.then()
.statusCode(202)
.extract()
.jsonPath()
.getString("runId");
final JsonNode completion =
executor.submit(() -> completion(reader, runId)).get(15, TimeUnit.SECONDS);
assertEquals("OPTIMAL", completion.get("state").asText());
assertTrue(completion.get("progress").get("feasible").asBoolean());
given().get(path).then().statusCode(200);
}
assertReconnectContains(show.getId());
} finally {
given().delete("/api/dance-schools/" + school);
}
}
private void assertReconnectContains(final long showId) throws Exception {
try (final HttpClient client = HttpClient.newHttpClient();
final InputStream stream = stream(client);
final BufferedReader reader =
new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
final JsonNode runs = next(reader).get("runs");
boolean found = false;
for (final JsonNode run : runs) {
if (run.get("showId").asLong() == showId) {
assertEquals("OPTIMAL", run.get("state").asText());
found = true;
}
}
assertTrue(found);
}
}
private InputStream stream(final HttpClient client) throws Exception {
final HttpResponse<InputStream> response = client.send(
HttpRequest.newBuilder(base.resolve("/api/scheduling/events"))
.header("Accept", "text/event-stream")
.build(),
HttpResponse.BodyHandlers.ofInputStream());
assertEquals(200, response.statusCode());
assertTrue(response.headers().firstValue("content-type").orElse("").startsWith("text/event-stream"));
return response.body();
}
private JsonNode completion(final BufferedReader reader, final String runId) throws Exception {
while (true) {
final JsonNode event = next(reader);
if (event.get("runs").isNull()) {
continue;
}
for (final JsonNode run : event.get("runs")) {
if (run.get("runId").asText().equals(runId)
&& !run.get("state").asText().equals("RUNNING")) {
return run;
}
}
}
}
private JsonNode next(final BufferedReader reader) throws Exception {
String line = reader.readLine();
while (line != null && !line.startsWith("data:")) {
line = reader.readLine();
}
assertNotNull(line);
return json.readTree(line.substring(5).trim());
}
private Show show(final long school) {
final long group = schools.createGroup(
school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()))
.getId();
schools.addGroupDancer(
school,
group,
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId());
return schools.createShow(
school,
"Show",
List.of(DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of())
.build()),
null);
}
}

View File

@@ -0,0 +1,61 @@
package nl.agilecc.agiledancers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
import nl.agilecc.agiledancers.model.scheduling.VarietyScore;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.service.SchedulingFailure;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class SchedulingProgressTest {
public @Test void realSolverReportsDiscoveryDuringEachPhase() {
final SchedulingInput input = new SchedulingInput(1, 0, 2, List.of(new GroupRequirement(2, 1)));
final List<SchedulingProgress> updates = new ArrayList<>();
final SchedulingResult result = new OrToolsSchedulingEngine().solve(input, updates::add);
assertTrue(result.optimal());
assertEquals(
List.of(SchedulingPhase.ATTENDANCE, SchedulingPhase.SPREAD, SchedulingPhase.VARIETY),
updates.stream().map(SchedulingProgress::phase).distinct().toList());
assertEquals(new SchedulingProgress(SchedulingPhase.ATTENDANCE, false), updates.getFirst());
assertTrue(updates.contains(new SchedulingProgress(SchedulingPhase.ATTENDANCE, true)));
assertTrue(updates.contains(new SchedulingProgress(SchedulingPhase.VARIETY, true)));
}
public @Test void noSolutionAndInfeasibleHaveStableDistinctOutcomes() {
final SchedulingInput input = new SchedulingInput(1, 0, 1, List.of(new GroupRequirement(2, 1)));
assertEquals(
SchedulingFailureKind.NO_SOLUTION,
assertThrows(SchedulingFailure.class, () -> new OrToolsSchedulingEngine(0).solve(input))
.getOutcome());
final SchedulingInput impossible = new SchedulingInput(1, 0, 1, List.of(new GroupRequirement(2, 2)));
assertEquals(
SchedulingFailureKind.INFEASIBLE,
assertThrows(SchedulingFailure.class, () -> new OrToolsSchedulingEngine().solve(impossible))
.getOutcome());
}
public @Test void summaryMatchesTheThreeObjectiveDefinitionsAndCoachExemptions() {
final GroupRequirement showcase = new GroupRequirement(1, 1, true, Set.of(10L, 11L), Set.of(), 20);
final GroupRequirement regular =
new GroupRequirement(2, 2, false, Set.of(10L, 11L), Set.of(DanceGroupStyle.MODERN), 22);
final SchedulingInput input = new SchedulingInput(1, 0, 3, List.of(showcase, regular), 1, Set.of(11L));
final SchedulingResult result = new SchedulingResult(false, List.of(List.of(1L, 2L), List.of(2L), List.of()));
final SchedulingSummary summary = SchedulingSummary.of(input, result);
assertEquals(1, summary.attendanceExcess());
assertEquals(2, summary.performanceSpread());
assertEquals(VarietyScore.penalty(showcase, regular), summary.varietyPenalty());
}
}

View File

@@ -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;
@@ -175,7 +175,8 @@ class SchoolManagementResourceTest {
given().put(school + "/dance-groups/" + group + "/dancers/" + dancer) given().put(school + "/dance-groups/" + group + "/dancers/" + dancer)
.then() .then()
.statusCode(204); .statusCode(204);
given().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true)); given().post(show + "/schedule").then().statusCode(202).body("state", equalTo("RUNNING"));
awaitScheduling(show);
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group)); given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
final String before = given().get(show).asString(); final String before = given().get(show).asString();
for (final String count : new String[] {"0", "2", "1.5", "7"}) { for (final String count : new String[] {"0", "2", "1.5", "7"}) {
@@ -291,4 +292,13 @@ class SchoolManagementResourceTest {
private static ValidatableResponse put(final String path, final Object body) { private static ValidatableResponse put(final String path, final Object body) {
return given().contentType("application/json").body(body).put(path).then(); return given().contentType("application/json").body(body).put(path).then();
} }
private static void awaitScheduling(final String show) {
final long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15);
while ("RUNNING".equals(given().get(show + "/schedule").jsonPath().getString("state"))
&& System.nanoTime() < deadline) {
java.util.concurrent.locks.LockSupport.parkNanos(java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(20));
}
given().get(show + "/schedule").then().statusCode(200).body("state", equalTo("OPTIMAL"));
}
} }

View File

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

View File

@@ -0,0 +1,247 @@
package nl.agilecc.agiledancers.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
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.DayPartType;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class SchedulingRunsTest {
private final SchoolManagementService schools = new SchoolManagementService();
private final long school = schools.createSchool("School").getId();
public @Test void startsInBackgroundRejectsDuplicatesAndRecoversCurrentStatus() throws Exception {
final Show show = show();
final var entered = new CountDownLatch(1);
final var release = new CountDownLatch(1);
final var updates = new AtomicReference<List<SchedulingRun>>();
final var runs = new SchedulingRuns(schools, blocking(entered, release));
try {
final Runnable unsubscribe = runs.subscribe(updates::set);
assertTrue(updates.get().isEmpty());
final SchedulingRun started = runs.start(school, show.getId());
assertEquals(SchedulingState.RUNNING, started.state());
assertTrue(entered.await(10, TimeUnit.SECONDS));
assertEquals(started, runs.status(school, show.getId()));
assertThrows(ManagementException.class, () -> runs.start(school, show.getId()));
unsubscribe.run();
final var reconnect = new AtomicReference<List<SchedulingRun>>();
final Runnable disconnect = runs.subscribe(reconnect::set);
assertEquals(started, reconnect.get().getFirst());
release.countDown();
final SchedulingRun finished = await(runs, show);
assertEquals(SchedulingState.OPTIMAL, finished.state());
assertEquals(0, finished.summary().performanceSpread());
assertTrue(finished.progress().feasible());
assertTrue(finished.finishedAt().compareTo(finished.startedAt()) >= 0);
assertFalse(show.getDayParts().getFirst().getDanceGroups().isEmpty());
assertEquals(SchedulingState.RUNNING, updates.get().getFirst().state());
disconnect.run();
assertNotEquals(started.runId(), runs.start(school, show.getId()).runId());
await(runs, show);
} finally {
release.countDown();
runs.close();
}
}
public @Test void differentShowsRunConcurrentlyAndEditsRejectStaleResults() throws Exception {
final Show first = show();
final Show second = show();
final var entered = new CountDownLatch(2);
final var release = new CountDownLatch(1);
final var runs = new SchedulingRuns(schools, blocking(entered, release));
try {
runs.start(school, first.getId());
runs.start(school, second.getId());
assertTrue(entered.await(10, TimeUnit.SECONDS));
schools.touch(first);
release.countDown();
assertEquals(SchedulingState.STALE, await(runs, first).state());
assertTrue(first.getDayParts().getFirst().getDanceGroups().isEmpty());
assertEquals(SchedulingState.OPTIMAL, await(runs, second).state());
} finally {
release.countDown();
runs.close();
}
}
public @Test void failuresRemainVisibleAndReleaseTheShowForRetry() {
final Show show = show();
final List<SchedulingEngine> engines = new ArrayList<>();
engines.add((final SchedulingInput input) -> {
throw new IllegalStateException("failure");
});
engines.add((final SchedulingInput input) -> {
throw new UnsatisfiedLinkError("native");
});
engines.add((final SchedulingInput input) -> null);
for (final SchedulingFailureKind kind : SchedulingFailureKind.values()) {
engines.add((final SchedulingInput input) -> {
throw new SchedulingFailure(kind, "Explained outcome");
});
}
for (final SchedulingEngine engine : engines) {
assertFailure(show, engine);
}
}
private void assertFailure(final Show show, final SchedulingEngine engine) {
final var runs = new SchedulingRuns(schools, engine);
try {
runs.start(school, show.getId());
final SchedulingRun failed = await(runs, show);
assertNotEquals(SchedulingState.RUNNING, failed.state());
assertTrue(failed.message().contains("behouden"));
assertTrue(show.getDayParts().getFirst().getDanceGroups().isEmpty());
schools.endScheduling(schools.beginScheduling(school, show.getId()).showId());
} finally {
runs.close();
}
}
public @Test void validUnprovenResultIsAppliedAndShutdownRejectsNewWorkWithoutLeakingGuard() {
final Show show = show();
final SchedulingEngine engine = (final SchedulingInput input) -> new SchedulingResult(
false, new OrToolsSchedulingEngine().solve(input).assignments());
final var runs = new SchedulingRuns(schools, engine);
runs.start(school, show.getId());
assertEquals(SchedulingState.VALID, await(runs, show).state());
runs.close();
assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> runs.start(school, show.getId()));
assertEquals(
SchedulingState.SOLVER_FAILED, runs.status(school, show.getId()).state());
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() {
final long group = schools.createGroup(
school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()))
.getId();
schools.addGroupDancer(
school,
group,
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId());
return schools.createShow(
school,
"Show",
List.of(DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of())
.build()),
null);
}
private SchedulingEngine blocking(final CountDownLatch entered, final CountDownLatch release) {
return (final SchedulingInput input) -> {
entered.countDown();
try {
assertTrue(release.await(10, TimeUnit.SECONDS));
} catch (final InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IllegalStateException(failure);
}
// The real solver's callbacks are covered separately; exercise the default port overload here.
return new OrToolsSchedulingEngine().solve(input);
};
}
private SchedulingRun await(final SchedulingRuns runs, final Show show) {
final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
SchedulingRun status = runs.status(school, show.getId());
while (status.state().equals(SchedulingState.RUNNING) && System.nanoTime() < deadline) {
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10));
status = runs.status(school, show.getId());
}
assertNotEquals(SchedulingState.RUNNING, status.state());
return status;
}
}

View File

@@ -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');
@@ -143,8 +159,13 @@ test('manages a school, shared memberships and shows through the real REST API',
await page.getByRole('button', { name: /Schedule groups/ }).click(); await page.getByRole('button', { name: /Schedule groups/ }).click();
await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible(); await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
await expect( await expect(
page.getByRole('status').filter({ hasText: 'Optimaal conceptrooster.' }), page.locator('.scheduling-progress').filter({ hasText: 'Optimaal conceptrooster.' }),
).toBeVisible(); ).toBeVisible();
await expect(page.locator('.scheduling-snackbar')).toContainText('Zomershow');
await expect(page.locator('.scheduling-progress')).toContainText('Spreiding:');
await page.reload();
await expect(page.locator('.scheduling-progress')).toContainText('Optimaal conceptrooster.');
await expect(page.locator('.scheduling-snackbar')).toHaveCount(0);
await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click(); await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click();
const scheduleDialog = page.getByRole('dialog'); const scheduleDialog = page.getByRole('dialog');
await scheduleDialog.getByRole('button', { name: 'Definitief maken' }).click(); await scheduleDialog.getByRole('button', { name: 'Definitief maken' }).click();
@@ -209,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',
@@ -225,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,
@@ -239,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`);
@@ -266,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();
}
});

View File

@@ -5,6 +5,10 @@ server {
index index.html; index index.html;
location /api/ { location /api/ {
proxy_buffering off;
proxy_cache off;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 1860s; proxy_read_timeout 1860s;
proxy_send_timeout 1860s; proxy_send_timeout 1860s;
proxy_pass http://${BACKEND_UPSTREAM}; proxy_pass http://${BACKEND_UPSTREAM};

View File

@@ -11,7 +11,10 @@ export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideHttpClient(), provideHttpClient(),
provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'enabled' })), provideRouter(
routes,
withInMemoryScrolling({ scrollPositionRestoration: 'enabled', anchorScrolling: 'enabled' }),
),
{ provide: LOCALE_ID, useValue: 'nl-NL' }, { provide: LOCALE_ID, useValue: 'nl-NL' },
], ],
}; };

View File

@@ -237,3 +237,30 @@ main {
margin: 0 20px; margin: 0 20px;
} }
} }
.scheduling-snackbar {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 1000;
display: flex;
align-items: center;
gap: 1rem;
max-width: min(40rem, calc(100vw - 3rem));
padding: 1rem 1.5rem;
background: #193b33;
color: white;
border-radius: 0.75rem;
box-shadow: 0 0.25rem 1rem #0003;
}
.scheduling-snackbar a {
color: white;
text-decoration: underline;
}
.scheduling-snackbar button {
background: transparent;
color: white;
border: 0;
cursor: pointer;
font-size: 1.5rem;
}

View File

@@ -44,3 +44,19 @@
</footer> </footer>
</div> </div>
</div> </div>
@if (scheduling.notifications()[0]; as run) {
<aside class="scheduling-snackbar" role="status" aria-live="polite">
<span
><strong>{{ run.showName }}</strong
>: {{ run.message }}</span
>
<a
[routerLink]="['/schools', run.schoolId, 'shows']"
[fragment]="'show-' + run.showId"
(click)="scheduling.dismiss()"
>Bekijk show</a
>
<button type="button" aria-label="Melding sluiten" (click)="scheduling.dismiss()">×</button>
</aside>
}

View File

@@ -1,12 +1,17 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { App } from './app'; import { App } from './app';
import { SchedulingStatus } from './core/scheduling-status';
import { signal } from '@angular/core';
describe('Application shell', () => { describe('Application shell', () => {
it('provides navigation and a keyboard skip link', async () => { it('provides navigation and a keyboard skip link', async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
providers: [provideRouter([])], providers: [
provideRouter([]),
{ provide: SchedulingStatus, useValue: { connect: vi.fn(), notifications: signal([]) } },
],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
await fixture.whenStable(); await fixture.whenStable();

View File

@@ -1,4 +1,5 @@
import { Component } from '@angular/core'; import { SchedulingStatus } from './core/scheduling-status';
import { Component, inject, OnInit } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router'; import { RouterLink, RouterOutlet } from '@angular/router';
@Component({ @Component({
@@ -7,4 +8,10 @@ import { RouterLink, RouterOutlet } from '@angular/router';
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.css', styleUrl: './app.css',
}) })
export class App {} export class App implements OnInit {
readonly scheduling = inject(SchedulingStatus);
ngOnInit(): void {
this.scheduling.connect();
}
}

View File

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

View File

@@ -0,0 +1,108 @@
import { TestBed } from '@angular/core/testing';
import { SchedulingRun, SchedulingState, SchedulingStatus } from './scheduling-status';
class FakeEventSource {
static current: FakeEventSource;
readonly close = vi.fn();
onmessage?: (event: { data: string }) => void;
onerror?: () => void;
constructor(readonly url: string) {
FakeEventSource.current = this;
}
send(runs: SchedulingRun[] | null): void {
this.onmessage?.({ data: JSON.stringify({ runs }) });
}
}
function run(state: SchedulingState, runId = 'run-1'): SchedulingRun {
return {
runId,
schoolId: 1,
showId: 2,
showName: 'Zomershow',
startedAt: '2026-11-03T12:00:00Z',
finishedAt: state === 'RUNNING' ? null : '2026-11-03T12:01:02Z',
state,
progress: { phase: 'VARIETY', feasible: true },
message: 'Afgerond',
summary: null,
};
}
describe('Scheduling status over SSE', () => {
let status: SchedulingStatus;
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('EventSource', FakeEventSource);
TestBed.configureTestingModule({});
status = TestBed.inject(SchedulingStatus);
status.connect();
});
afterEach(() => {
TestBed.resetTestingModule();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('recovers an existing outcome without replaying old notifications', () => {
expect(FakeEventSource.current.url).toBe('/api/scheduling/events');
FakeEventSource.current.send([run('OPTIMAL')]);
expect(status.run(1, 2)?.state).toBe('OPTIMAL');
expect(status.notifications()).toEqual([]);
expect(status.elapsed(run('OPTIMAL'))).toBe('1m 2s');
expect(status.phase(run('OPTIMAL'))).toBe('Variatie');
});
it('announces a completion missed during disconnection exactly once', () => {
FakeEventSource.current.send([run('RUNNING')]);
FakeEventSource.current.onerror?.();
expect(status.connected()).toBe(false);
const disconnected = FakeEventSource.current;
vi.advanceTimersByTime(3000);
expect(FakeEventSource.current).not.toBe(disconnected);
expect(disconnected.close).toHaveBeenCalledOnce();
expect(status.run(1, 2)?.state).toBe('RUNNING');
FakeEventSource.current.send([run('VALID')]);
expect(status.connected()).toBe(true);
expect(status.notifications()).toHaveLength(1);
status.dismiss();
FakeEventSource.current.send([run('VALID')]);
expect(status.notifications()).toEqual([]);
});
it('does not treat heartbeats or phase changes as completions', () => {
FakeEventSource.current.send(null);
FakeEventSource.current.send([run('OPTIMAL')]);
expect(status.notifications()).toEqual([]);
FakeEventSource.current.send([run('RUNNING', 'run-2')]);
FakeEventSource.current.send(null);
expect(status.run(1, 2)?.runId).toBe('run-2');
expect(status.notifications()).toEqual([]);
FakeEventSource.current.send([run('SOLVER_FAILED', 'run-2')]);
expect(status.notifications()).toHaveLength(1);
});
it('announces a locally started run completed before the first SSE snapshot', () => {
FakeEventSource.current.onerror?.();
status.started(run('RUNNING'));
vi.advanceTimersByTime(3000);
FakeEventSource.current.send([run('OPTIMAL'), run('VALID', 'historical')]);
expect(status.notifications().map((item) => item.runId)).toEqual(['run-1']);
status.dismiss();
FakeEventSource.current.onerror?.();
vi.advanceTimersByTime(3000);
FakeEventSource.current.send([run('OPTIMAL'), run('VALID', 'historical')]);
expect(status.notifications()).toEqual([]);
});
it('retains a fast completion when the start response arrives after SSE', () => {
FakeEventSource.current.send([]);
FakeEventSource.current.send([run('OPTIMAL')]);
status.started(run('RUNNING'));
expect(status.run(1, 2)?.state).toBe('OPTIMAL');
status.connect();
const source = FakeEventSource.current;
TestBed.resetTestingModule();
expect(source.close).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,125 @@
import { DestroyRef, inject, Injectable, signal } from '@angular/core';
export type SchedulingState =
| 'RUNNING'
| 'INTERRUPTED'
| 'OPTIMAL'
| 'VALID'
| 'STALE'
| 'INVALID_INPUT'
| 'INFEASIBLE'
| 'NO_SOLUTION'
| 'SOLVER_FAILED';
export type SchedulingPhase = 'PREPARING' | 'ATTENDANCE' | 'SPREAD' | 'VARIETY';
export interface SchedulingRun {
runId: string;
schoolId: number;
showId: number;
showName: string;
startedAt: string;
finishedAt: string | null;
state: SchedulingState;
progress: { phase: SchedulingPhase; feasible: boolean };
message: string;
summary: {
attendanceExcess: number;
performanceSpread: number;
varietyPenalty: number;
} | null;
}
@Injectable({ providedIn: 'root' })
export class SchedulingStatus {
private readonly destroyRef = inject(DestroyRef);
readonly runs = signal<SchedulingRun[]>([]);
readonly connected = signal(false);
readonly notifications = signal<SchedulingRun[]>([]);
readonly now = signal(Date.now());
private source?: EventSource;
private reconnectTimer?: ReturnType<typeof setTimeout>;
private initialized = false;
private readonly seen = new Set<string>();
connect(): void {
if (this.source) return;
this.open();
const timer = setInterval(() => this.now.set(Date.now()), 1000);
this.destroyRef.onDestroy(() => {
this.source?.close();
clearTimeout(this.reconnectTimer);
clearInterval(timer);
});
}
private open(): void {
const source = new EventSource('/api/scheduling/events');
this.source = source;
source.onmessage = (event: MessageEvent<string>) => {
const eventData = JSON.parse(event.data) as { runs: SchedulingRun[] | null };
if (eventData.runs !== null) this.accept(eventData.runs);
this.connected.set(true);
};
source.onerror = () => {
this.connected.set(false);
// Retry also when a proxy error makes native EventSource reconnection stop permanently.
source.close();
clearTimeout(this.reconnectTimer);
this.reconnectTimer = setTimeout(() => this.open(), 3000);
};
}
private accept(runs: SchedulingRun[]): void {
const activeRuns = new Set(
this.runs()
.filter((run) => run.state === 'RUNNING')
.map((run) => run.runId),
);
for (const run of runs) {
if (run.state === 'RUNNING' || this.seen.has(run.runId)) continue;
this.seen.add(run.runId);
if (this.initialized || activeRuns.has(run.runId))
this.notifications.update((items) => [...items, run]);
}
this.initialized = true;
this.runs.set(runs);
}
run(schoolId: number, showId: number): SchedulingRun | undefined {
return this.runs().find((run) => run.schoolId === schoolId && run.showId === showId);
}
started(run: SchedulingRun): void {
// The SSE completion can arrive before the POST response. Never overwrite it with RUNNING.
if (this.runs().some((item) => item.runId === run.runId)) return;
this.runs.update((items) => [...items.filter((item) => item.showId !== run.showId), run]);
}
elapsed(run: SchedulingRun): string {
const seconds = Math.max(
0,
Math.floor(
((run.finishedAt ? Date.parse(run.finishedAt) : this.now()) - Date.parse(run.startedAt)) /
1000,
),
);
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}
phase(run: SchedulingRun): string {
return (
(
{
PREPARING: 'Voorbereiden',
ATTENDANCE: 'Aanwezigheid',
SPREAD: 'Spreiding',
VARIETY: 'Variatie',
} satisfies Record<SchedulingPhase, string>
)[run.progress.phase] ?? run.progress.phase
);
}
dismiss(): void {
this.notifications.update((items) => items.slice(1));
}
}

View File

@@ -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', () => {
@@ -73,11 +97,11 @@ describe('SchoolApi contract', () => {
.flush({ message: 'Invalid data' }, { status: 400, statusText: 'Bad Request' }); .flush({ message: 'Invalid data' }, { status: 400, statusText: 'Bad Request' });
expect(failure).toHaveBeenCalledOnce(); expect(failure).toHaveBeenCalledOnce();
}); });
it('schedules synchronously with room for the 30-minute solver limit', () => { it('starts a background calculation without a solver-length HTTP timeout', () => {
api.schedule(7, 3).subscribe(); api.schedule(7, 3).subscribe();
const request = http.expectOne('/api/dance-schools/7/shows/3/schedule'); const request = http.expectOne('/api/dance-schools/7/shows/3/schedule');
expect(request.request.method).toBe('POST'); expect(request.request.method).toBe('POST');
expect(request.request.timeout).toBe(1860000); expect(request.request.timeout).toBeUndefined();
request.flush({ optimal: false, message: 'Valid result; optimal balance not proven' }); request.flush({ optimal: false, message: 'Valid result; optimal balance not proven' });
}); });
}); });

View File

@@ -1,6 +1,7 @@
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,
@@ -19,68 +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) => {
schoolData(schoolId: number): Observable<SchoolData> { for (const school of schools) this.revisions.set(school.id, school.revision);
const base = `${this.base}/${schoolId}`; }),
return forkJoin({
school: this.http.get<School>(base),
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> {
return id === undefined
? this.http.post<School>(this.base, body)
: this.http.put<School>(`${this.base}/${id}`, body);
}
saveDancer(schoolId: number, body: DancerRequest, id?: number): Observable<Dancer> {
return this.save(`${this.base}/${schoolId}/dancers`, body, id);
}
saveGroup(schoolId: number, body: GroupRequest, id?: number): Observable<DanceGroup> {
return this.save(`${this.base}/${schoolId}/dance-groups`, body, id);
}
saveShow(schoolId: number, body: ShowRequest, id?: number): Observable<Show> {
return this.save(`${this.base}/${schoolId}/shows`, body, id);
}
schedule(schoolId: number, showId: number): Observable<{ optimal: boolean; message: string }> {
return this.http.post<{ optimal: boolean; message: string }>(
`${this.base}/${schoolId}/shows/${showId}/schedule`,
null,
{ timeout: 1860000 },
); );
} }
saveAssignments(schoolId: number, showId: number, assignments: number[][]): Observable<Show> { schoolData(schoolId: number): Observable<SchoolData> {
return this.http.put<Show>(`${this.base}/${schoolId}/shows/${showId}/assignments`, { return this.http
assignments, .get<SchoolData>(`${this.base}/${schoolId}/data`)
}); .pipe(tap((data) => this.revisions.set(schoolId, data.school.revision)));
} }
finalize(schoolId: number, showId: number): Observable<Show> { private options(schoolId: number, expected?: number): { headers: Record<string, string> } {
return this.http.post<Show>(`${this.base}/${schoolId}/shows/${showId}/finalize`, null); 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
? this.http.post<School>(this.base, body)
: this.http.put<School>(`${this.base}/${id}`, body, this.options(id, revision));
}
saveDancer(
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,
revision?: number,
): Observable<DanceGroup> {
return this.save(`${this.base}/${schoolId}/dance-groups`, body, schoolId, id, revision);
}
saveShow(schoolId: number, body: ShowRequest, id?: number, revision?: number): Observable<Show> {
return this.save(`${this.base}/${schoolId}/shows`, body, schoolId, id, revision);
}
schedule(schoolId: number, showId: number): Observable<SchedulingRun> {
return this.http.post<SchedulingRun>(
`${this.base}/${schoolId}/shows/${showId}/schedule`,
null,
this.options(schoolId),
);
}
saveAssignments(
schoolId: number,
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, revision?: number): Observable<Show> {
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));
} }
} }

View File

@@ -344,7 +344,7 @@
</div> </div>
<div class="show-list"> <div class="show-list">
@for (show of shows(); track show.id) { @for (show of shows(); track show.id) {
<article class="show-card"> <article class="show-card" [id]="'show-' + show.id">
<div class="date-tile"> <div class="date-tile">
<strong>{{ firstDate(show) | date: 'dd' }}</strong <strong>{{ firstDate(show) | date: 'dd' }}</strong
><span>{{ firstDate(show) | date: 'MMM yyyy' }}</span> ><span>{{ firstDate(show) | date: 'MMM yyyy' }}</span>
@@ -372,6 +372,48 @@
: 'Geen geldige planning. Plan de groepen opnieuw in.' : 'Geen geldige planning. Plan de groepen opnieuw in.'
}} }}
</p> </p>
@if (schedulingStatus.run(context.school.id, show.id); as run) {
<div class="scheduling-progress" role="status">
@if (!schedulingStatus.connected()) {
<p>
Verbinding verbroken. Status mogelijk verouderd; de berekening loopt door.
</p>
}
@if (run.state === 'RUNNING') {
<p>
Bezig met inplannen · {{ schedulingStatus.phase(run) }} ·
{{ schedulingStatus.elapsed(run) }}
</p>
<p>
{{
run.progress.feasible
? 'Geldige oplossing gevonden; optimalisatie loopt door.'
: 'Nog geen geldige oplossing gevonden.'
}}
</p>
} @else {
<p>{{ run.message }}</p>
<p>
Afgerond: {{ run.finishedAt | date: 'dd-MM-yyyy HH:mm:ss' }} · Duur:
{{ schedulingStatus.elapsed(run) }}
</p>
@if (run.summary; as summary) {
<p>
Aanwezigheid: {{ summary.attendanceExcess }} extra dagdelen boven voorkeur
(showcasedansers, exclusief coaches).
</p>
<p>
Spreiding: {{ summary.performanceSpread }} optredens verschil tussen drukste
en rustigste dagdeel.
</p>
<p>
Variatie: {{ summary.varietyPenalty }} strafpunten voor opeenvolgende
groepen (lager is beter).
</p>
}
}
</div>
}
<button <button
type="button" type="button"
class="button primary" class="button primary"
@@ -435,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()"
@@ -445,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()"
/> />
@@ -453,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)"

View File

@@ -1,5 +1,5 @@
import { SchoolTransfer } from '../shared/school-transfer'; import { SchoolTransfer } from '../shared/school-transfer';
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core'; import { Component, computed, DestroyRef, effect, inject, OnInit, signal } from '@angular/core';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink, RouterLinkActive } from '@angular/router'; import { ActivatedRoute, Router, RouterLink, RouterLinkActive } from '@angular/router';
@@ -14,6 +14,7 @@ import {
Show, Show,
} from '../core/api-models'; } from '../core/api-models';
import { apiError } from '../core/api-error'; import { apiError } from '../core/api-error';
import { SchedulingStatus } from '../core/scheduling-status';
import { SchoolApi } from '../core/school-api'; import { SchoolApi } from '../core/school-api';
import { EntityEditor } from '../shared/entity-editor'; import { EntityEditor } from '../shared/entity-editor';
import { ConfirmDialog } from '../shared/confirm-dialog'; import { ConfirmDialog } from '../shared/confirm-dialog';
@@ -57,7 +58,30 @@ export class SchoolPage implements OnInit {
readonly scheduleEditing = signal<Show | null>(null); readonly scheduleEditing = signal<Show | null>(null);
readonly members = signal<DanceGroup | null>(null); readonly members = signal<DanceGroup | null>(null);
readonly deleting = signal<DeleteTarget | null>(null); readonly deleting = signal<DeleteTarget | null>(null);
readonly scheduling = signal<number[]>([]); readonly schedulingStatus = inject(SchedulingStatus);
private readonly starting = signal<number[]>([]);
readonly scheduling = computed(() => [
...this.starting(),
...this.schedulingStatus
.runs()
.filter((run) => run.schoolId === this.data()?.school.id && run.state === 'RUNNING')
.map((run) => run.showId),
]);
private readonly completed = new Set<string>();
constructor() {
effect(() => {
const schoolId = this.data()?.school.id;
let changed = false;
for (const run of this.schedulingStatus.runs()) {
if (run.schoolId !== schoolId || run.state === 'RUNNING' || this.completed.has(run.runId))
continue;
this.completed.add(run.runId);
changed = true;
}
if (changed) void this.reload();
});
}
readonly deleteBusy = signal(false); readonly deleteBusy = signal(false);
readonly deleteError = signal(''); readonly deleteError = signal('');
readonly search = signal(''); readonly search = signal('');
@@ -151,20 +175,15 @@ export class SchoolPage implements OnInit {
) )
) )
return; return;
this.scheduling.update((ids) => [...ids, show.id]); this.starting.update((ids) => [...ids, show.id]);
this.error.set(''); this.error.set('');
try { try {
const result = await firstValueFrom(this.api.schedule(schoolId, show.id)); const result = await firstValueFrom(this.api.schedule(schoolId, show.id));
this.notice.set( this.schedulingStatus.started(result);
result.optimal
? 'Optimaal conceptrooster.'
: 'Geldig conceptrooster; optimaliteit niet bewezen.',
);
await this.reload();
} catch (error: unknown) { } catch (error: unknown) {
this.error.set(apiError(error)); this.error.set(apiError(error));
} finally { } finally {
this.scheduling.update((ids) => ids.filter((id) => id !== show.id)); this.starting.update((ids) => ids.filter((id) => id !== show.id));
} }
} }
async reload(): Promise<void> { async reload(): Promise<void> {
@@ -200,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 =
@@ -236,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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,30 @@ class ReleaseShellTests {
CiTests.equal(false, calls.contains("prune")); CiTests.equal(false, calls.contains("prune"));
} }
}); });
ReleaseTests.test("actual NAS script rejects a missing database password before pulling images", () -> {
try (var f = new Fixture()) {
Path config = f.directory.resolve("production.env");
Files.writeString(
config, Files.readString(config).replace("DATABASE_PASSWORD=fixture-only-password\n", ""));
f.call("lock", null, "one").checked();
Commands.Result result = f.call("preflight", null, "one");
CiTests.equal(1, result.code());
CiTests.equal(true, result.output().contains("Missing DATABASE_PASSWORD"));
CiTests.equal(false, Files.exists(f.directory.resolve("live.env")));
}
});
ReleaseTests.test("actual NAS script passes database configuration through sudo", () -> {
try (var f = new Fixture()) {
f.executable("sudo", "#!/bin/sh\n[ \"$1\" != -n ] || shift\nunset DATABASE_PASSWORD\nexec \"$@\"\n");
Path config = f.directory.resolve("production.env");
Files.writeString(config, Files.readString(config).replace("DOCKER_SUDO=0", "DOCKER_SUDO=1"));
f.prepare();
CiTests.equal(
false,
Files.readString(f.directory.resolve("releases/one/release.env"))
.contains("PASSWORD"));
}
});
ReleaseTests.test("actual NAS script rejects wrong image revision before replacement", () -> { ReleaseTests.test("actual NAS script rejects wrong image revision before replacement", () -> {
try (var f = new Fixture()) { try (var f = new Fixture()) {
f.env.put("WRONG_REVISION", "1"); f.env.put("WRONG_REVISION", "1");
@@ -102,7 +126,11 @@ class ReleaseShellTests {
Files.writeString(directory.resolve("docker-auth/config.json"), "{}"); Files.writeString(directory.resolve("docker-auth/config.json"), "{}");
Files.writeString( Files.writeString(
directory.resolve("production.env"), directory.resolve("production.env"),
"BIND_IP=127.0.0.1\nFRONTEND_PORT=8088\nDOCKER_SUDO=0\nDOCKER_BIN=" "DATABASE_PASSWORD=fixture-only-password\n"
+ "BIND_IP=127.0.0.1\n"
+ "FRONTEND_PORT=8088\n"
+ "DOCKER_SUDO=0\n"
+ "DOCKER_BIN="
+ directory.resolve("bin/docker") + "\nDOCKER_CONFIG=" + directory.resolve("docker-auth") + directory.resolve("bin/docker") + "\nDOCKER_CONFIG=" + directory.resolve("docker-auth")
+ "\n"); + "\n");
} }
@@ -171,8 +199,15 @@ class ReleaseShellTests {
[ "$1" != version ] || exit 0 [ "$1" != version ] || exit 0
shift 4 shift 4
[ "$1" = --env-file ] || exit 1 [ "$1" = --env-file ] || exit 1
production=$2
shift 2
[ "$1" = --env-file ] || exit 1
file=$2 file=$2
shift 4 shift 4
# Compose must read the secret from disk, even after sudo clears the environment.
test -f "$production"
. "$production"
[ "$DATABASE_PASSWORD" = fixture-only-password ] || exit 15
case "$1" in case "$1" in
config) test -f "$file";; config) test -f "$file";;
up) cp "$file" "$FIXTURE/live.env";; up) cp "$file" "$FIXTURE/live.env";;

View File

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

Some files were not shown because too many files have changed in this diff Show More