71 Commits

Author SHA1 Message Date
7750819d73 Merge remote-tracking branch 'origin/main' into feature/S19_multi-project
All checks were successful
Build, test and publish / verify (push) Successful in 10m1s
2026-11-10 08:53:20 +01:00
77b7159f0a S19: adopt shared mini-pc runner and landscape workflows 2026-11-10 08:53:20 +01:00
c6a2b70919 Merge pull request 'feature/S19_multi-project' (#20) from feature/S19_multi-project into main
All checks were successful
Build, test and publish / verify (push) Successful in 10m25s
Reviewed-on: #20
2026-11-08 21:31:35 +01:00
c6a495a93c S19: consume versioned engineering resources and move landscape documentation
All checks were successful
Build, test and publish / verify (push) Successful in 8m54s
2026-11-08 15:23:10 +01:00
bd898e92d2 S19 refinement
All checks were successful
Build, test and publish / verify (push) Successful in 8m15s
2026-11-08 13:39:16 +01:00
d2d4713c51 Merge pull request 'git helper tool added and improved coding-standards' (#19) from git-tools into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m46s
Reviewed-on: #19
2026-11-08 11:43:46 +01:00
8546f222fe git helper tool added and improved coding-standards
All checks were successful
Build, test and publish / verify (push) Successful in 6m57s
2026-11-08 10:25:37 +01:00
3c3ca1949c Merge pull request 'fixed the ADR licensing and gave due credit to the work of Matt Pocock' (#18) from adr-update into main
All checks were successful
Build, test and publish / verify (push) Successful in 5m34s
Reviewed-on: #18
2026-11-07 14:35:09 +01:00
7603b3339b fixed the ADR licensing and gave due credit to the work of Matt Pocock
All checks were successful
Build, test and publish / verify (push) Successful in 7m3s
2026-11-07 14:34:46 +01:00
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
b0d6592761 Merge pull request 'S6: manual versioned Synology deployment' (#12) from feature/S6_deployment into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m6s
Reviewed-on: #12
2026-11-03 14:51:13 +01:00
60496de8bb Record successful S6 feature build validation [skip ci] 2026-11-03 14:48:59 +01:00
525e9f9335 Implement manual versioned Synology deployments for S6
All checks were successful
Build, test and publish / verify (push) Successful in 6m34s
2026-11-03 14:16:52 +01:00
c9db528756 refinement of spec S6
All checks were successful
Build, test and publish / verify (push) Successful in 5m49s
2026-11-03 13:30:47 +01:00
b1544357bf Merge pull request 'ortools-documentation' (#11) from ortools-documentation into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m9s
Reviewed-on: #11
2026-11-03 13:09:09 +01:00
3a5203eb47 some renaming of variables
All checks were successful
Build, test and publish / verify (push) Successful in 5m55s
2026-11-03 12:58:15 +01:00
a7f7730ea0 S18 Added OR Tools rules for scheduling show
All checks were successful
Build, test and publish / verify (push) Successful in 6m12s
2026-11-03 11:03:47 +01:00
c965f9b41d Merge pull request 'refinement2' (#10) from refinement2 into main
All checks were successful
Build, test and publish / verify (push) Successful in 4m45s
Reviewed-on: #10
2026-11-01 21:29:04 +01:00
8fc430537b refinement S16 - database storage
All checks were successful
Build, test and publish / verify (push) Successful in 5m26s
2026-11-01 21:28:42 +01:00
fc307de64e backlog management
All checks were successful
Build, test and publish / verify (push) Successful in 5m38s
2026-11-01 20:51:27 +01:00
f281200f47 Merge pull request 'feature/S7-backend-coding-standards' (#9) from feature/S7-backend-coding-standards into main
Some checks failed
Build, test and publish / verify (push) Failing after 5m15s
Reviewed-on: #9
2026-11-01 20:40:45 +01:00
ff6a0ae0a9 review finished
All checks were successful
Build, test and publish / verify (push) Successful in 5m40s
2026-11-01 17:08:46 +01:00
aae5a97200 S7 review findings
Some checks failed
Build, test and publish / verify (push) Failing after 36s
2026-10-30 23:39:14 +01:00
f910f1e535 updated coding standards
All checks were successful
Build, test and publish / verify (push) Successful in 4m43s
2026-10-30 19:58:03 +01:00
9a69981025 Merge pull request 'refinement of S7 - coding standards for the backend' (#8) from coding-standards-be into main
Some checks failed
Build, test and publish / verify (push) Failing after 2m30s
Reviewed-on: #8
2026-10-30 17:18:29 +01:00
35123e51f6 refinement of S7 - coding standards for the backend
All checks were successful
Build, test and publish / verify (push) Successful in 1m45s
2026-10-30 17:18:13 +01:00
de42bbbbf0 Merge pull request 'S8-palentir' (#7) from S8-palentir into main
All checks were successful
Build, test and publish / verify (push) Successful in 1m57s
Reviewed-on: #7
2026-10-30 16:58:49 +01:00
5ec2dd83f3 review complete
All checks were successful
Build, test and publish / verify (push) Successful in 1m31s
2026-10-30 16:58:17 +01:00
7e4fd2dab7 spec-file updates
All checks were successful
Build, test and publish / verify (push) Successful in 1m41s
2026-10-30 16:54:05 +01:00
7e8b3a2cc7 S8 apply Palantir formatting to maintained Java sources
All checks were successful
Build, test and publish / verify (push) Successful in 1m39s
2026-10-30 15:50:51 +01:00
327a365550 S8 integrate pinned Palantir formatting through OpenRewrite 2026-10-30 15:50:51 +01:00
4cfd1d71de Merge pull request 'feature/S9_ortools-phase1' (#6) from feature/S9_ortools-phase1 into main
All checks were successful
Build, test and publish / verify (push) Successful in 2m44s
Reviewed-on: #6
2026-10-30 15:20:57 +01:00
38247d0705 ortools review on some design issues and fixed flaky test
All checks were successful
Build, test and publish / verify (push) Successful in 1m34s
2026-10-30 15:14:27 +01:00
1b4c7f7f2f S9 OR Tools basic scheduling
All checks were successful
Build, test and publish / verify (push) Successful in 1m48s
2026-10-30 15:00:45 +01:00
f64aa9123d Merge pull request 'refinement of spec S9 and updated S2 as implemented' (#5) from refinement into main
All checks were successful
Build, test and publish / verify (push) Successful in 1m44s
Reviewed-on: #5
2026-10-30 11:50:36 +01:00
9171a2d932 refinement of spec S9 and updated S2 as implemented
All checks were successful
Build, test and publish / verify (push) Successful in 1m38s
2026-10-30 11:46:06 +01:00
aa29283a94 Merge pull request 'S2 EsLint implementation' (#4) from feature/S2_eslint into main
All checks were successful
Build, test and publish / verify (push) Successful in 1m50s
Reviewed-on: #4
2026-10-29 22:07:38 +01:00
8f3026e6cd S2 EsLint implementation
All checks were successful
Build, test and publish / verify (push) Successful in 1m40s
2026-10-29 22:05:11 +01:00
09cb26107e Merge pull request 'config to start docker and runner at startup' (#3) from feature/s4-gitea-ci into main
All checks were successful
Build, test and publish / verify (push) Successful in 1m36s
Reviewed-on: #3
2026-10-29 21:17:41 +01:00
8ae2c16fee config to start docker and runner at startup
All checks were successful
Build, test and publish / verify (push) Successful in 1m15s
2026-10-29 21:16:31 +01:00
66d9844836 Merge pull request 'specs' (#2) from feature/s4-gitea-ci into main
All checks were successful
Build, test and publish / verify (push) Successful in 2m6s
Reviewed-on: #2
2026-10-29 21:10:39 +01:00
b0c2f28e13 specs
All checks were successful
Build, test and publish / verify (push) Successful in 1m21s
2026-10-29 19:35:10 +01:00
2356a31aa6 Merge pull request 'Bring the application and Gitea build pipeline to main' (#1) from feature/s4-gitea-ci into main
All checks were successful
Build, test and publish / verify (push) Successful in 2m9s
Reviewed-on: #1
2026-10-29 15:45:33 +01:00
aea792307c Probe the frontend on its IPv4 listener
All checks were successful
Build, test and publish / verify (push) Successful in 1m16s
2026-10-29 09:51:47 +01:00
19fffe2cd3 Report missing Docker clearly and preserve CI attempt setup
Some checks failed
Build, test and publish / verify (push) Failing after 3m9s
2026-10-29 09:23:24 +01:00
069b62ae92 Run CI orchestration and setup as Java 25 source programs
Some checks failed
Build, test and publish / verify (push) Failing after 11s
2026-10-29 09:07:41 +01:00
834584dae0 Give Gitea workflow reruns unique diagnostic names
All checks were successful
Build, test and publish / verify (push) Successful in 1m27s
2026-10-28 17:46:00 +01:00
74a5aa1237 Add Gitea build, container tests and registry publication
All checks were successful
Build, test and publish / verify (push) Successful in 1m41s
2026-10-28 17:07:23 +01:00
e3e362c2ac import export functionality added 2026-10-28 08:07:42 +01:00
bd497aefd5 Spec driven development setup 2026-10-28 07:26:47 +01:00
02337fafaa frontend-toegevoegd 2026-10-27 15:34:56 +01:00
777d9f199d Add school management REST API and architecture checks 2026-10-27 15:02:58 +01:00
2bb3beef60 turned into a quarkus application 2026-10-26 22:53:35 +01:00
c037abbde9 upgraded to Java 25 2026-10-26 22:45:14 +01:00
Wanja Krah
e617bc09c1 WIP: verder uitbreiden model 2025-05-22 18:42:39 +02:00
Wanja Krah
aa6f50e3c8 set basis and model 2025-05-15 18:38:36 +02:00
268 changed files with 32975 additions and 1 deletions

23
.dockerignore Normal file
View File

@@ -0,0 +1,23 @@
**/target
.git
.idea
*.iml
**/node_modules
**/.angular
**/dist
**/test-results
**/playwright-report
ci/artifacts
# Allow only the verified runtime output for the CI prebuilt target.
!agiledancers-be/target
agiledancers-be/target/*
!agiledancers-be/target/quarkus-app
**/unit-results
# CI sources are build inputs; runner credentials and local state are not.
ci/runner/.env
ci/runner/.runner
ci/runner/data
ci/runner/local

View File

@@ -0,0 +1,46 @@
name: Build, test and publish
on:
push:
branches: ['**']
jobs:
verify:
runs-on: mini-pc
env:
CI: 'true'
TARGET_PLATFORM: ${{ vars.TARGET_PLATFORM }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
CI_REPOSITORY: ${{ gitea.repository }}
CI_COMMIT: ${{ gitea.sha }}
CI_REF: ${{ gitea.ref }}
CI_RUN_ID: ${{ gitea.run_id }}
steps:
- name: Check out pushed commit
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Verify pinned engineering resources
run: python3 scripts/engineering.py --check
- name: Identify workflow attempt
# Gitea 1.25 does not populate gitea.run_attempt. Use a unique value for
# container names and diagnostic artifacts, including reruns of one SHA.
run: java generated/ci/Ci.java attempt
- name: Build and test both images
run: java generated/ci/Ci.java verify
- name: Publish verified main images
if: success() && gitea.ref == 'refs/heads/main'
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: java generated/ci/Ci.java publish
- name: Collect diagnostics and clean up test containers
if: always()
run: java generated/ci/Ci.java cleanup
- name: Retain diagnostics for fourteen days
if: always()
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
with:
name: diagnostics-${{ gitea.run_id }}-${{ env.CI_RUN_ATTEMPT }}
path: ci/artifacts/
if-no-files-found: error
retention-days: 14

View File

@@ -0,0 +1,70 @@
name: Deploy production
on:
workflow_dispatch:
inputs:
mode:
description: 'Release mode — export legacy data before the first S16 deployment'
required: true
type: choice
default: new
options:
- new
- retry
increment:
description: 'New release only: semantic version increment (first release is v1.0.0)'
required: true
type: choice
default: patch
options:
- patch
- minor
- major
version:
description: 'Retry only: existing version tag, e.g. v1.2.3; leave empty for new release'
required: false
type: string
# Gitea 1.25 ignores concurrency. The helper acquires a NAS-wide mkdir lock.
jobs:
deploy:
if: gitea.ref == 'refs/heads/main'
runs-on: mini-pc
env:
CI: 'true'
GITEA_SERVER_URL: ${{ gitea.server_url }}
CI_REPOSITORY: ${{ gitea.repository }}
CI_COMMIT: ${{ gitea.sha }}
CI_REF: ${{ gitea.ref }}
CI_EVENT: ${{ gitea.event_name }}
CI_RUN_ID: ${{ gitea.run_id }}
steps:
- name: Check out trusted main workflow revision
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Verify pinned engineering resources
run: python3 scripts/engineering.py --check
- name: Identify workflow attempt
run: java generated/ci/Ci.java attempt
- name: Release verified images — persistent PostgreSQL storage
env:
RELEASE_MODE: ${{ inputs.mode }}
RELEASE_INCREMENT: ${{ inputs.increment }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_SSH_PORT: ${{ vars.DEPLOY_SSH_PORT }}
DEPLOY_DIRECTORY: ${{ vars.DEPLOY_DIRECTORY }}
DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: java generated/ci/Ci.java deploy
- name: Retain deployment diagnostics for fourteen days
if: always()
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
with:
name: deployment-${{ gitea.run_id }}-${{ env.CI_RUN_ATTEMPT }}
path: ci/artifacts/deployment/
if-no-files-found: warn
retention-days: 14

View File

@@ -0,0 +1,44 @@
name: Rollback production
on:
workflow_dispatch:
jobs:
rollback:
if: gitea.ref == 'refs/heads/main'
runs-on: mini-pc
env:
CI: 'true'
GITEA_SERVER_URL: ${{ gitea.server_url }}
CI_REPOSITORY: ${{ gitea.repository }}
CI_COMMIT: ${{ gitea.sha }}
CI_REF: ${{ gitea.ref }}
CI_EVENT: ${{ gitea.event_name }}
CI_RUN_ID: ${{ gitea.run_id }}
steps:
- name: Check out trusted main workflow revision
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Verify pinned engineering resources
run: python3 scripts/engineering.py --check
- name: Identify workflow attempt
run: java generated/ci/Ci.java attempt
- name: Restore previous successful release — requires a compatible database schema
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_SSH_PORT: ${{ vars.DEPLOY_SSH_PORT }}
DEPLOY_DIRECTORY: ${{ vars.DEPLOY_DIRECTORY }}
DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: java generated/ci/Ci.java rollback
- name: Retain deployment diagnostics for fourteen days
if: always()
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
with:
name: rollback-${{ gitea.run_id }}-${{ env.CI_RUN_ATTEMPT }}
path: ci/artifacts/deployment/
if-no-files-found: warn
retention-days: 14

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
*.iml
.idea
target
# Local CI diagnostics and runner credentials/state
/ci/artifacts/
/ci/runner/.env
/ci/runner/.runner
/ci/runner/data/
/ci/runner/local/
**/unit-results/
**/__pycache__/

2
.husky/pre-commit Normal file
View File

@@ -0,0 +1,2 @@
cd agiledancers-fe || exit 1
node node_modules/lint-staged/bin/lint-staged.js --concurrent false

2
.mvn/maven.config Normal file
View File

@@ -0,0 +1,2 @@
-s.mvn/settings-local.xml

265
.mvn/settings-local.xml Normal file
View File

@@ -0,0 +1,265 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!--
| This is the configuration file for Maven. It can be specified at two levels:
|
| 1. User Level. This settings.xml file provides configuration for a single user,
| and is normally provided in ${user.home}/.m2/settings.xml.
|
| NOTE: This location can be overridden with the CLI option:
|
| -s /path/to/user/settings.xml
|
| 2. Global Level. This settings.xml file provides configuration for all Maven
| users on a machine (assuming they're all using the same Maven
| installation). It's normally provided in
| ${maven.conf}/settings.xml.
|
| NOTE: This location can be overridden with the CLI option:
|
| -gs /path/to/global/settings.xml
|
| The sections in this sample file are intended to give you a running start at
| getting the most out of your Maven installation. Where appropriate, the default
| values (values used when the setting is not specified) are provided.
|
|-->
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
<!-- localRepository
| The path to the local repository maven will use to store artifacts.
|
| Default: ${user.home}/.m2/repository
<localRepository>/path/to/local/repo</localRepository>
-->
<!-- interactiveMode
| This will determine whether maven prompts you when it needs input. If set to false,
| maven will use a sensible default value, perhaps based on some other setting, for
| the parameter in question.
|
| Default: true
<interactiveMode>true</interactiveMode>
-->
<!-- offline
| Determines whether maven should attempt to connect to the network when executing a build.
| This will have an effect on artifact downloads, artifact deployment, and others.
|
| Default: false
<offline>false</offline>
-->
<!-- pluginGroups
| This is a list of additional group identifiers that will be searched when resolving plugins by their prefix, i.e.
| when invoking a command line like "mvn prefix:goal". Maven will automatically add the group identifiers
| "org.apache.maven.plugins" and "org.codehaus.mojo" if these are not already contained in the list.
|-->
<pluginGroups>
<!-- pluginGroup
| Specifies a further group identifier to use for plugin lookup.
<pluginGroup>com.your.plugins</pluginGroup>
-->
</pluginGroups>
<!-- TODO Since when can proxies be selected as depicted? -->
<!-- proxies
| This is a list of proxies which can be used on this machine to connect to the network.
| Unless otherwise specified (by system property or command-line switch), the first proxy
| specification in this list marked as active will be used.
|-->
<proxies>
<!-- proxy
| Specification for one proxy, to be used in connecting to the network.
|
<proxy>
<id>optional</id>
<active>true</active>
<protocol>http</protocol>
<username>proxyuser</username>
<password>proxypass</password>
<host>proxy.host.net</host>
<port>80</port>
<nonProxyHosts>local.net|some.host.com</nonProxyHosts>
</proxy>
-->
</proxies>
<!-- servers
| This is a list of authentication profiles, keyed by the server-id used within the system.
| Authentication profiles can be used whenever maven must make a connection to a remote server.
|-->
<servers>
<!-- server
| Specifies the authentication information to use when connecting to a particular server, identified by
| a unique name within the system (referred to by the 'id' attribute below).
|
| NOTE: You should either specify username/password OR privateKey/passphrase, since these pairings are
| used together.
|
<server>
<id>deploymentRepo</id>
<username>repouser</username>
<password>repopwd</password>
</server>
-->
<!-- Another sample, using keys to authenticate.
<server>
<id>siteServer</id>
<privateKey>/path/to/private/key</privateKey>
<passphrase>optional; leave empty if not used.</passphrase>
</server>
-->
</servers>
<!-- mirrors
| This is a list of mirrors to be used in downloading artifacts from remote repositories.
|
| It works like this: a POM may declare a repository to use in resolving certain artifacts.
| However, this repository may have problems with heavy traffic at times, so people have mirrored
| it to several places.
|
| That repository definition will have a unique id, so we can create a mirror reference for that
| repository, to be used as an alternate download site. The mirror site will be the preferred
| server for that repository.
|-->
<mirrors>
<!-- mirror
| Specifies a repository mirror site to use instead of a given repository. The repository that
| this mirror serves has an ID that matches the mirrorOf element of this mirror. IDs are used
| for inheritance and direct lookup purposes, and must be unique across the set of mirrors.
|
<mirror>
<id>mirrorId</id>
<mirrorOf>repositoryId</mirrorOf>
<name>Human Readable Name for this Mirror.</name>
<url>http://my.repository.com/repo/path</url>
</mirror>
-->
<mirror>
<id>maven-default-http-blocker</id>
<mirrorOf>external:http:*</mirrorOf>
<name>Pseudo repository to mirror external repositories initially using HTTP.</name>
<url>http://0.0.0.0/</url>
<blocked>true</blocked>
</mirror>
</mirrors>
<!-- profiles
| This is a list of profiles which can be activated in a variety of ways, and which can modify
| the build process. Profiles provided in the settings.xml are intended to provide local machine-
| specific paths and repository locations which allow the build to work in the local environment.
|
| For example, if you have an integration testing plugin - like cactus - that needs to know where
| your Tomcat instance is installed, you can provide a variable here such that the variable is
| dereferenced during the build process to configure the cactus plugin.
|
| As noted above, profiles can be activated in a variety of ways. One way - the activeProfiles
| section of this document (settings.xml) - will be discussed later. Another way essentially
| relies on the detection of a property, either matching a particular value for the property,
| or merely testing its existence. Profiles can also be activated by JDK version prefix, where a
| value of '1.4' might activate a profile when the build is executed on a JDK version of '1.4.2_07'.
| Finally, the list of active profiles can be specified directly from the command line.
|
| NOTE: For profiles defined in the settings.xml, you are restricted to specifying only artifact
| repositories, plugin repositories, and free-form properties to be used as configuration
| variables for plugins in the POM.
|
|-->
<profiles>
<!-- profile
| Specifies a set of introductions to the build process, to be activated using one or more of the
| mechanisms described above. For inheritance purposes, and to activate profiles via <activatedProfiles/>
| or the command line, profiles have to have an ID that is unique.
|
| An encouraged best practice for profile identification is to use a consistent naming convention
| for profiles, such as 'env-dev', 'env-test', 'env-production', 'user-jdcasey', 'user-brett', etc.
| This will make it more intuitive to understand what the set of introduced profiles is attempting
| to accomplish, particularly when you only have a list of profile id's for debug.
|
| This profile example uses the JDK version to trigger activation, and provides a JDK-specific repo.
<profile>
<id>jdk-1.4</id>
<activation>
<jdk>1.4</jdk>
</activation>
<repositories>
<repository>
<id>jdk14</id>
<name>Repository for JDK 1.4 builds</name>
<url>http://www.myhost.com/maven/jdk14</url>
<layout>default</layout>
<snapshotPolicy>always</snapshotPolicy>
</repository>
</repositories>
</profile>
-->
<!--
| Here is another profile, activated by the property 'target-env' with a value of 'dev', which
| provides a specific path to the Tomcat instance. To use this, your plugin configuration might
| hypothetically look like:
|
| ...
| <plugin>
| <groupId>org.myco.myplugins</groupId>
| <artifactId>myplugin</artifactId>
|
| <configuration>
| <tomcatLocation>${tomcatPath}</tomcatLocation>
| </configuration>
| </plugin>
| ...
|
| NOTE: If you just wanted to inject this configuration whenever someone set 'target-env' to
| anything, you could just leave off the <value/> inside the activation-property.
|
<profile>
<id>env-dev</id>
<activation>
<property>
<name>target-env</name>
<value>dev</value>
</property>
</activation>
<properties>
<tomcatPath>/path/to/tomcat/instance</tomcatPath>
</properties>
</profile>
-->
</profiles>
<!-- activeProfiles
| List of profiles that are active for all builds.
|
<activeProfiles>
<activeProfile>alwaysActiveProfile</activeProfile>
<activeProfile>anotherAlwaysActiveProfile</activeProfile>
</activeProfiles>
-->
</settings>

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["angular.ng-template", "dbaeumer.vscode-eslint"]
}

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"eslint.workingDirectories": ["./agiledancers-fe"]
}

26
AGENTS.md Normal file
View File

@@ -0,0 +1,26 @@
# Shared skills and specifications
- Shared agent workflows live in [landscape skills](http://192.168.2.28:3000/wanjakrah/landscape/src/branch/main/skills/README.md).
Read the requested SKILL.md there before applying it. A sibling checkout is optional.
- For code reviews use landscape's skills/code-review/SKILL.md with docs/backend-review.md.
- Use grill-me only when explicitly requested, with its skills/grilling/SKILL.md dependency.
Installing or mentioning a skill starts no interview.
- Keep upstream skills unchanged; licenses and revision records live in landscape/skills/.
- Specifications and ADRs belong to landscape. Engineering owns pinned build resources.
# Backend changes and reviews
- Before changing backend Java, read `CODING_STANDARDS.md`.
- For reviews, use landscape's `skills/code-review/SKILL.md` together with
`docs/backend-review.md`. Keep upstream skills unchanged.
- Obtain maintainer agreement before adding any standards waiver; record its
narrow scope and reason alongside the affected code or configuration.
# Landscape and engineering
- Read current [landscape guidance](http://192.168.2.28:3000/wanjakrah/landscape),
including applicable ADRs and specifications. This repository owns its implementation.
- Engineering resources are pinned by `engineering.lock.json`. Do not edit
`generated/`; change engineering and deliberately adopt a released version.
- Regenerate with `mvn -N -Pregenerate-engineering validate`; check with
`python3 scripts/engineering.py --check`. No sibling checkout is needed.

33
CHANGELOG.md Normal file
View File

@@ -0,0 +1,33 @@
# Changelog
## [Unreleased]
### Added
- Angular 22 frontend in `agiledancers-fe` with Dutch-language school management screens.
- Forms for dancers, groups and shows, including multiple group memberships and deletion confirmation.
- Development proxy to the Quarkus REST API, frontend unit tests and end-to-end browser tests.
## [1.0] - 2026-10-27
### Added
- Quarkus backend running on Java 25, with health endpoints and Docker support.
- Domain model for dance schools, dancers, dance groups, coaches, shows and day parts.
- REST API to create, list, retrieve, update and delete dance schools, dancers, dance groups and shows.
- Membership management allowing a dancer to belong to multiple groups within a school.
- Shows with a name, date and three day parts: morning, afternoon and evening.
- Input validation, school-scoped resource access and cleanup of memberships when dancers are deleted.
- Shared in-memory storage for the REST API.
- Separate API model records in `rest.apimodel`.
- ArchUnit checks for package dependencies, domain independence, package cycles and API model conventions.
- HTTP integration tests for resource management, memberships, validation and school isolation.
### Fixed
- Adding entities to initially empty dance school and dance group collections now retains them.
### Limitations
- Data is lost when the application stops.
- Assigning dance groups to show day parts is not yet available.

11
CODING_STANDARDS.md Normal file
View File

@@ -0,0 +1,11 @@
# Agile Dancers coding standards
Read [the pinned engineering standards](generated/CODING_STANDARDS.md) before
changing Java. Their backend-module placeholder means `agiledancers-be` here.
Frontend conventions remain in README.md and agiledancers-fe.
The existing maintainer-approved exception for Group's eight-parameter canonical
constructor remains in `tooling/standards/waivers.tsv`. No new waiver is introduced
by S19. For reviews also read [the local companion](docs/backend-review.md).
Validation evidence: [S7](docs/validation/S7-validation.md).

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM maven:3.9-eclipse-temurin-25 AS build
WORKDIR /workspace
COPY pom.xml ./
COPY .mvn .mvn
COPY generated generated
COPY tooling tooling
COPY engineering.properties engineering.properties
COPY agiledancers-be/pom.xml agiledancers-be/pom.xml
COPY agiledancers-be/src agiledancers-be/src
# Source builds run the same PostgreSQL tests as local/CI verification.
# Supply connection configuration as BuildKit secrets, which are not retained in image layers.
RUN --mount=type=secret,id=database_url,env=QUARKUS_DATASOURCE_JDBC_URL,required=true \
--mount=type=secret,id=database_user,env=QUARKUS_DATASOURCE_USERNAME,required=true \
--mount=type=secret,id=database_password,env=QUARKUS_DATASOURCE_PASSWORD,required=true \
mvn -B -ntp clean verify
FROM eclipse-temurin:25-jre AS runtime
WORKDIR /app
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/quarkus-run.jar"]
# CI packages the exact output that already passed mvn clean verify.
FROM runtime AS prebuilt
COPY --chown=10001:10001 agiledancers-be/target/quarkus-app/ ./
# Default standalone build still compiles and tests from source.
FROM runtime AS standalone
COPY --from=build --chown=10001:10001 /workspace/agiledancers-be/target/quarkus-app/ ./

389
README.md
View File

@@ -1,3 +1,390 @@
# Landscape and shared engineering
Architecture decisions, specifications and project onboarding are maintained in
[landscape](http://192.168.2.28:3000/wanjakrah/landscape). Shared agent skills are maintained in landscape/skills/. Reusable tooling,
standards and the project generator are maintained in
[engineering](http://192.168.2.28:3000/wanjakrah/engineering).
This project commits the engineering version recorded in `engineering.lock.json`
under `generated/`. Ordinary builds need no other repository checkout. Regenerate
with `mvn -N -Pregenerate-engineering validate`; CI checks the selected tag and
commit with `python3 scripts/engineering.py --check`. To upgrade, update both tag
and commit, follow engineering's UPGRADING.md, regenerate and review the diff.
Project configuration in `engineering.properties`, workflows and `deploy/` remains
application-owned. Read CODING_STANDARDS.md and AGENTS.md for project guidance.
# agiledancers
To map dancegroups to performancedays
To map dance groups to performance days. The backend runs on Quarkus with Java 25.
Requires JDK 25 and Maven 3.9.0 or newer. Set `JAVA_HOME` to your JDK 25 installation.
Build and run all tests (including `*Sit` tests) from the repository root:
```sh
mvn clean verify
```
Start development mode with live reload from the repository root:
```sh
mvn -pl agiledancers-be quarkus:dev
```
Run the packaged application after building:
```sh
# Set DATABASE_URL, DATABASE_USER and DATABASE_PASSWORD first.
java -jar agiledancers-be/target/quarkus-app/quarkus-run.jar
```
Build a Docker container against an isolated test database (see [connection setup](docs/persistent-storage.md)):
```sh
docker build --network=host \
--secret id=database_url,env=TEST_DATABASE_URL \
--secret id=database_user,env=TEST_DATABASE_USER \
--secret id=database_password,env=TEST_DATABASE_PASSWORD -t agiledancers .
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,
then copies the Quarkus application into a Java 25 runtime image running as a non-root user.
Check readiness at <http://localhost:8080/q/health/ready> or overall health at
<http://localhost:8080/q/health>. For example:
```sh
curl --fail http://localhost:8080/q/health/ready
```
Quarkus configuration can be overridden with environment variables, for example
`docker run --rm -e QUARKUS_HTTP_PORT=9090 -p 9090:9090 agiledancers`.
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:
```sh
mvn -pl agiledancers-be -Dtest=ArchitectureTest test
```
`ArchitectureTest` guards the following packages below `nl.agilecc.agiledancers`:
| Package | Responsibility | Allowed dependencies within the application |
| --- | --- | --- |
| `model` | Domain entities and specifications | Model |
| `service` | Application use cases and future ports | Model, repositories |
| `repository` | JPA/Hibernate entities, repositories and transaction boundaries | Model |
| `rest` | Future REST services | Services, model |
| `solver` | Future OR-Tools calculations | Services (ports), model |
REST and solver packages are optional until implemented. OR-Tools dependencies
are allowed only in `solver`; JAX-RS and RESTEasy dependencies only in `rest`.
The model may depend only on itself, Java and Lombok. Package cycles and
production classes outside these packages fail the architecture tests.
API models live in `rest.apimodel`, each in its own Java file as a public top-level
class or record. Their names end in `Request`, `Response`, `View`, `Dto` or `DTO`.
Architecture tests enforce this naming and location convention and require all
records in the REST layer to reside in `rest.apimodel`.
The current services instantiate concrete repositories, so this is not yet strict
Onion architecture. That requires repository interfaces in the core and injected
implementations in an outer adapter. For future calculations, define a port in
`service.port` using domain types and implement it in `solver`; services must not
depend on solver implementations or expose OR-Tools types.
Handwritten production types each have their own file and must be top-level.
Service records live in `service.model`; the school JSON snapshot records and
import preview are service-owned data models. Scheduling records shared with
the solver live in `model.scheduling`. Each group requirement combines its group
ID and appearance count in one record.
`service.port.SchedulingEngine` is the application-owned interface for solving a
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
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
dependencies, the location of service records, and the top-level type rule
(excluding generated Lombok builders and compiler helpers).
## School management REST API
All endpoints consume and produce JSON. Resources are scoped to their dance school.
A dancer can belong to multiple dance groups within the same school. Memberships
are managed separately from group details and survive updates to dancers and groups.
| Resource collection | Item URL |
| --- | --- |
| `/api/dance-schools` | `/api/dance-schools/{schoolId}` |
| `/api/dance-schools/{schoolId}/dancers` | `.../dancers/{dancerId}` |
| `/api/dance-schools/{schoolId}/dance-groups` | `.../dance-groups/{groupId}` |
| `/api/dance-schools/{schoolId}/shows` | `.../shows/{showId}` |
Each collection supports `GET` (list) and `POST` (create). Each item supports `GET`,
`PUT` (replace editable details), and `DELETE`. Creation returns `201` with a
`Location` header; updates return `200`; deletion returns `204`. Invalid input
returns `400`, and missing resources or resources belonging to another school
return `404`. Client-supplied IDs are not used.
Request bodies (dates use ISO `yyyy-MM-dd`):
```json
{"name": "24 Dance Studio"}
```
```json
{"name": "Sam", "birthDate": "2010-05-12"}
```
```json
{
"category": "Teens",
"type": "REGULAR",
"startAge": 12,
"endAge": 18,
"styles": ["HIPHOP", "JAZZ"]
}
```
```json
{"name": "Annual show", "dayParts": [{"date": "2027-06-12", "type": "AFTERNOON"}]}
```
Names and categories must be nonblank. Birth dates cannot be in the future.
Both ages must be nonnegative and `startAge <= endAge`; `styles` is required
and may be empty. Group types are `SHOW_CASE`, `PRE_EDUCATION`, `SELECTION`,
`REGULAR`, `PRE_SCHOOLER`. Styles are `HIPHOP`, `JAZZ`, `MODERN`, `STREETDANCE`,
`CLASSIC`, `BREAKDANCE`.
School responses include `dancerIds`, `danceGroupIds`, and `showIds`; group
responses include `dancerIds`. To add or remove a dancer's group membership:
```sh
curl -X PUT http://localhost:8080/api/dance-schools/0/dance-groups/0/dancers/0
curl -X DELETE http://localhost:8080/api/dance-schools/0/dance-groups/0/dancers/0
```
Both operations return `204` and are idempotent for existing groups and dancers.
Deleting a dancer removes all their memberships; deleting a group retains the
dancers; deleting a school also deletes its dancers, groups and shows.
Shows have a name, zero to six dated dayparts (`MORNING`, `AFTERNOON`, `EVENING`),
with unique date/type combinations. The displayed date range comes from these
dayparts. Each show stores `preferredDayParts`, defaulting to two or one for
one daypart. Ordinary groups, including PRE_EDUCATION, perform in exactly preferredDayParts
dayparts; preschool groups once; showcases every daypart. See
[show-scheduling-rules.md](show-scheduling-rules.md) for the full hard constraints,
coach exceptions, ordering and optimization rules.
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` returns `202` with a
run identifier and starts an ordered draft calculation in the background using
OR-Tools with a total thirty-minute time limit. Duplicate starts for the same
show return `409`; different shows may run concurrently. A feasible result is
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
ordered list of group IDs per daypart. Incomplete or invalid drafts can be saved;
unknown IDs and malformed lists are rejected. `POST .../shows/{showId}/finalize`
checks every hard rule before marking a schedule final. Show responses include
`preferredDayParts`, `finalized` and `violations`. Relevant edits invalidate final
status. The UI supports ordering and moving performances, and displays violations.
`GET .../coaches` lists coach registrations. `POST .../coaches` with
`{"dancerId":1}` registers a dancer as a coach. `PUT .../coaches/{coachId}` uses
the same body to link an existing coach. `DELETE .../coaches/{coachId}/dancer`
removes the identity link. The dancers screen supports maintaining these links.
School JSON exports use format version **3**, including preferredDayParts, schedule status and
coach-to-dancer links. Version 2 remains importable; its schedules migrate to
drafts with default preferredDayParts. Version 1 is unsupported. Legacy `appearances` fields are
retained for file compatibility; S18 scheduling uses preferredDayParts and fixed type exceptions.
The REST `appearances` input is deprecated; use `preferredDayParts`.
Run the HTTP integration tests with:
```sh
mvn -pl agiledancers-be -Dtest=SchoolManagementResourceTest test
```
## Angular frontend
The Angular 22 frontend is in [`agiledancers-fe`](agiledancers-fe/README.md),
next to `agiledancers-be`. It provides Dutch-language screens to manage schools,
dancers, groups, memberships and shows through the REST API.
Use Node.js 22.22.3+ (22.x), 24.15.0+ (24.x), or 26+.
Start the backend as described above, then run in a second terminal:
```sh
cd agiledancers-fe
npm ci
npm start
```
Open <http://localhost:4200>. The development proxy forwards API requests to the
backend on port 8080. Frontend builds and tests run independently of Maven:
`npm run build` and `npm test`. See the frontend README for browser tests and
production hosting requirements.
## Frontend lint and formatting
Run these commands from `agiledancers-fe/`:
```sh
npm run lint
npm run lint:fix
```
`lint` checks maintained application code, inline and external Angular templates,
unit tests, Playwright tests and JavaScript/TypeScript configuration. It uses the
recommended JavaScript, Angular and type-aware TypeScript rules. It changes no
files; both errors and warnings cause failure. Dependencies, generated output
and `package-lock.json` are excluded. `lint:fix` applies ESLint's available fixes;
review the diff and resolve any remaining findings manually. Prettier retains
responsibility for formatting with the existing configuration.
Every branch pipeline runs `npm run lint` immediately after frontend `npm ci`,
before frontend tests/builds and container E2E. Failure prevents image publication;
findings are in the job log and retained `ci/artifacts/verify.log`. CI only checks
and never fixes or stages source files.
Local `npm ci` automatically installs Husky at the repository root. Its commit
hook runs **Prettier only**, through lint-staged with backup, rollback and hiding
of unstaged changes in partially staged files enabled. It formats applicable
staged frontend TypeScript, JavaScript, HTML, CSS and JSON files, then stages the
formatting. Unrelated unstaged edits and files remain outside the commit. A
formatter error or a conflict restoring unstaged edits aborts the commit and
restores the original staged and unstaged state. Generated files are excluded;
commits without applicable frontend changes skip these checks.
The complete Prettier-plus-ESLint hook exceeds the agreed three-second budget
when all maintained files are staged. The selected Prettier-only hook meets the
budget for single-file, mixed and all-file commits. ESLint is therefore run
explicitly during development and always by CI. See [S2 validation](docs/s2-validation.md)
for the measured environment, timings and preservation checks.
Hook installation is skipped when `CI` is set (including Docker builds) or
`HUSKY=0`. Use `HUSKY=0 npm ci` to skip local installation, `HUSKY=0 git commit`
to skip one hook execution, or `git config --local core.hooksPath /dev/null` to
disable hooks for this clone. A subsequent normal `npm ci` enables them again.
VS Code recommends the ESLint extension in both root and frontend workspaces.
With it installed, findings appear in the editor and Problems panel; the root
workspace selects `agiledancers-fe/` as ESLint's working directory. No automatic
fixing or formatting on save is required. `npm run lint` uses the same rules as CI.
## Gitea CI
Every branch push builds and tests both applications and their Docker images.
Only successful `main` runs publish images to the Synology registry. See
[the CI management guide](docs/gitea-ci.md) for runner setup, registry secrets,
image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md)
for the current operational status.
Production releases and rollback use separate manual Gitea workflows. See the
[deployment runbook](docs/production-deployment.md) for Synology provisioning,
semantic version tags, release/retry/rollback and the accepted data-loss limitation
during the one-time transition from an in-memory release, and [S6 evidence](docs/s6-validation.md) for deployment readiness.
### Java formatting (S8)
Maven is the authoritative formatting path. The root build runs the actual
Palantir Java Format **2.101.0**, Palantir style, through a custom OpenRewrite
**8.63.4** text recipe in `generated/tooling/format/Format.java`. The pinned
`exec-maven-plugin` **3.6.3** launches that helper with JDK 25. Native import
ordering and unused-import removal are enabled; extra Javadoc formatting is off.
No OpenRewrite AutoFormat or additional refactoring recipes run.
From the repository root:
```sh
mvn -N exec:exec@format # apply, without application compilation
mvn -N exec:exec@format -Dformat.mode=check # check, without application compilation
mvn compile # applies during validate, before compilation
mvn -Dformat.mode=check clean verify # CI: fails before compilation on differences
```
Root `compile`, `test`, `verify` and `install` may rewrite maintained Java files
in the validate phase. CI's Java orchestration passes `-Dformat.mode=check` and
never repairs source files. On failure, the output lists paths and the apply
command. Apply locally, inspect and commit the result, then repeat the check.
Run root Maven commands; selecting only the backend with `-pl agiledancers-be`
bypasses the root formatting execution.
The scope is every `.java` file recursively under `agiledancers-be/src/main/java`,
`agiledancers-be/src/test/java`, `ci` (including the runner helper), and the
formatter helper itself. Generated `target` files and frontend files are excluded.
The adapter keeps paths and UTF-8 content, computes changes before writing, and
uses atomic per-file replacements. Unsupported syntax fails with its path/cause.
The source launcher compiles the adapter directly in memory from a clean checkout;
no custom recipe jar needs compiling or installing beforehand. Maven downloads
only pinned external tooling dependencies on first use.
Successfully checked/formatted content hashes are cached in
`target/palantir-cache.properties`; new or changed files are always processed.
The root POM, adapter source, dependency classpath and JDK version invalidate the cache. `clean` removes
it. Check and apply share the engine, options, scope and successful-content cache;
a failing check does not write sources or update the cache. For controlled timing
comparisons only, `-Dformat.skip=true` disables the execution.
Editors are optional. Palantir supplies an IntelliJ plugin (see the
[upstream instructions](https://github.com/palantir/palantir-java-format#intellij-plugin)).
Use it only with the same engine version/style and Javadoc setting; if the editor
cannot match them, use Maven to format instead. Editor import optimization or
format-on-save must not override Maven's result. Other editors can invoke the
standalone Maven apply command as an external tool.
Verification and measurements are recorded in
[the S8 evidence](docs/validation/S8-formatting-evidence.md). Reproduce the isolated failure,
UTF-8, import, cache and idempotence checks with
the formatter integration test in engineering (`python3 tests/format_integration.py`), and the sequential end-to-end build measurements
with `python3 tooling/format/benchmark.py` (requires the ordinary backend test
prerequisites). Results are written under `target/`.
## Backend coding standards (S7)
Read [CODING_STANDARDS.md](CODING_STANDARDS.md) before changing backend Java.
It defines the agreed rules, exact metric limits, manual review duties, pinned
analysis tools, coverage policy and standalone commands. `mvn verify` runs standards
checks and requires at least 80% module-wide backend line coverage. CI also collects
and merges coverage from Playwright before publication. Reports are retained with
CI diagnostics. See [S7 validation](docs/validation/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.

153
agiledancers-be/pom.xml Normal file
View File

@@ -0,0 +1,153 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>nl.agilecc.agiledancers</groupId>
<artifactId>agiledancers</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>agiledancers-be</artifactId>
<packaging>jar</packaging>
<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>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId>
</dependency>
<dependency>
<groupId>com.google.ortools</groupId>
<artifactId>ortools-java</artifactId>
</dependency>
<!-- test dependencies -->
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-jacoco</artifactId><scope>test</scope></dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.datafaker</groupId>
<artifactId>datafaker</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<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>
<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>
<configuration><outputFile>${project.build.directory}/standards-classpath.txt</outputFile></configuration>
</execution>
<execution><id>coverage-tools</id><phase>prepare-package</phase><goals><goal>copy</goal></goals>
<configuration><artifactItems>
<artifactItem><groupId>org.jacoco</groupId><artifactId>org.jacoco.agent</artifactId><version>0.8.14</version><classifier>runtime</classifier><destFileName>agent.jar</destFileName></artifactItem>
<artifactItem><groupId>org.jacoco</groupId><artifactId>org.jacoco.cli</artifactId><version>0.8.14</version><classifier>nodeps</classifier><destFileName>cli.jar</destFileName></artifactItem>
</artifactItems><outputDirectory>${project.build.directory}/coverage-tools</outputDirectory></configuration>
</execution></executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.6.3</version>
<executions><execution><id>standards</id><phase>verify</phase><goals><goal>exec</goal></goals>
<configuration>
<executable>${maven.home}/bin/mvn</executable>
<workingDirectory>${maven.multiModuleProjectDirectory}</workingDirectory>
<arguments><argument>-B</argument><argument>-ntp</argument><argument>-f</argument>
<argument>${maven.multiModuleProjectDirectory}/generated/tooling/standards/pom.xml</argument>
<argument>test</argument><argument>exec:java</argument>
</arguments>
</configuration>
</execution>
<execution><id>coverage</id><phase>verify</phase><goals><goal>exec</goal></goals>
<configuration><executable>${java.home}/bin/java</executable><arguments>
<argument>${maven.multiModuleProjectDirectory}/generated/tooling/coverage/Coverage.java</argument>
<argument>${project.build.directory}/site/jacoco/jacoco.xml</argument>
</arguments></configuration>
</execution>
<execution><id>combined-coverage</id><phase>none</phase><goals><goal>exec</goal></goals>
<configuration><executable>${java.home}/bin/java</executable><arguments>
<argument>${maven.multiModuleProjectDirectory}/generated/tooling/coverage/Coverage.java</argument>
<argument>${project.build.directory}/site/jacoco-combined/jacoco.xml</argument>
</arguments></configuration>
</execution></executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId><artifactId>jacoco-maven-plugin</artifactId><version>0.8.14</version>
<executions>
<execution><id>agent</id><goals><goal>prepare-agent</goal></goals><configuration>
<exclClassLoaders>*QuarkusClassLoader</exclClassLoaders><destFile>${project.build.directory}/jacoco.exec</destFile><append>true</append>
</configuration></execution>
<execution><id>report</id><phase>post-integration-test</phase><goals><goal>report</goal></goals></execution>
<execution><id>combined</id><phase>none</phase><goals><goal>merge</goal></goals><configuration>
<fileSets><fileSet><directory>${project.build.directory}</directory><includes><include>jacoco.exec</include><include>playwright.exec</include></includes></fileSet></fileSets>
<destFile>${project.build.directory}/jacoco-combined.exec</destFile>
</configuration></execution>
<execution><id>combined-report</id><phase>none</phase><goals><goal>report</goal></goals><configuration>
<dataFile>${project.build.directory}/jacoco-combined.exec</dataFile>
<outputDirectory>${project.build.directory}/site/jacoco-combined</outputDirectory>
</configuration></execution>
</executions>
</plugin>
<plugin>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.platform.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
<goal>generate-code-tests</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,31 @@
package nl.agilecc.agiledancers.model;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@EqualsAndHashCode
@ToString
@NoArgsConstructor
public abstract class AbstractEntity {
private Long id;
protected <T> void addNullSafe(final Set<T> set, final T t) {
emptyIfNull(set).add(t);
}
private <T> Set<T> emptyIfNull(final Set<T> set) {
return set == null ? new HashSet<>() : set;
}
protected <T> Set<T> unmodifiableNullSafe(final Set<T> set) {
return set == null ? Collections.emptySet() : Collections.unmodifiableSet(set);
}
}

View File

@@ -0,0 +1,20 @@
package nl.agilecc.agiledancers.model;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString(onlyExplicitlyIncluded = true)
public class Coach extends AbstractEntity {
@ToString.Include
private final String name;
@Setter
private Dancer dancer;
}

View File

@@ -0,0 +1,70 @@
package nl.agilecc.agiledancers.model;
import java.util.LinkedHashSet;
import java.util.Set;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString(onlyExplicitlyIncluded = true)
public class DanceGroup extends AbstractEntity {
@ToString.Include
@Setter
private String category;
@ToString.Include
@Setter
private DanceGroupType type;
@ToString.Include
@Setter
private int startAge;
@ToString.Include
@Setter
private int endAge;
@ToString.Include
@Builder.Default
private final Set<Dancer> dancers = new LinkedHashSet<>();
@ToString.Include
@Builder.Default
private final Set<DanceGroupStyle> styles = new LinkedHashSet<>();
@ToString.Include
private Coach coach;
public Set<Dancer> getDancers() {
return super.unmodifiableNullSafe(dancers);
}
public void addDancer(final Dancer dancer) {
super.addNullSafe(dancers, dancer);
}
public Set<DanceGroupStyle> getStyles() {
return super.unmodifiableNullSafe(styles);
}
public void addStyle(final DanceGroupStyle style) {
super.addNullSafe(styles, style);
}
public void removeDancer(final Dancer dancer) {
dancers.remove(dancer);
}
public void replaceStyles(final Set<DanceGroupStyle> styles) {
this.styles.clear();
this.styles.addAll(styles);
}
}

View File

@@ -0,0 +1,81 @@
package nl.agilecc.agiledancers.model;
import java.util.LinkedHashSet;
import java.util.Set;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString(onlyExplicitlyIncluded = true)
public class DanceSchool extends AbstractEntity {
@Builder.Default
private final java.util.UUID key = java.util.UUID.randomUUID();
@Setter
private String name;
@Setter
private long revision;
@Builder.Default
private final Set<Dancer> dancers = new LinkedHashSet<>();
@Builder.Default
private final Set<DanceGroup> danceGroups = new LinkedHashSet<>();
@Builder.Default
private final Set<Show> shows = new LinkedHashSet<>();
@Builder.Default
private final Set<Coach> coaches = new LinkedHashSet<>();
public Set<Dancer> getDancers() {
return super.unmodifiableNullSafe(dancers);
}
public Set<DanceGroup> getDanceGroups() {
return super.unmodifiableNullSafe(danceGroups);
}
public Set<Show> getShows() {
return super.unmodifiableNullSafe(shows);
}
public Set<Coach> getCoaches() {
return super.unmodifiableNullSafe(coaches);
}
public void addDancer(final Dancer dancer) {
super.addNullSafe(dancers, dancer);
}
public void addDanceGroup(final DanceGroup danceGroup) {
super.addNullSafe(danceGroups, danceGroup);
}
public void addShow(final Show show) {
super.addNullSafe(shows, show);
}
public void addCoach(final Coach coach) {
super.addNullSafe(coaches, coach);
}
public void removeDancer(final Dancer entity) {
dancers.remove(entity);
}
public void removeDanceGroup(final DanceGroup entity) {
danceGroups.remove(entity);
}
public void removeShow(final Show entity) {
shows.remove(entity);
}
}

View File

@@ -0,0 +1,28 @@
package nl.agilecc.agiledancers.model;
import java.time.LocalDate;
import java.time.Period;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString(onlyExplicitlyIncluded = true)
public class Dancer extends AbstractEntity {
@ToString.Include
@Setter
private String name;
@ToString.Include
@Setter
private LocalDate birthDate;
public int age() {
return Period.between(birthDate, LocalDate.now()).getYears();
}
}

View File

@@ -0,0 +1,32 @@
package nl.agilecc.agiledancers.model;
import java.time.LocalDate;
import java.util.List;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString(onlyExplicitlyIncluded = true)
public class DayPart extends AbstractEntity {
@ToString.Include
private final DayPartType type;
private final LocalDate date;
@ToString.Include
@Setter
private List<DanceGroup> danceGroups;
public void removeDanceGroup(final DanceGroup group) {
danceGroups = danceGroups.stream()
.filter((final DanceGroup value) -> value != group)
.toList();
}
}

View File

@@ -0,0 +1,33 @@
package nl.agilecc.agiledancers.model;
import java.util.List;
import java.util.Map;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString(onlyExplicitlyIncluded = true)
public class Show extends AbstractEntity {
@Setter
private String name;
@Setter
private Integer preferredDayParts;
@Setter
private boolean finalized;
@Setter
private Map<DanceGroupType, Integer> appearances;
@ToString.Include
@Setter
private List<DayPart> dayParts;
}

View File

@@ -0,0 +1,21 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.Set;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
public record GroupRequirement(
long groupId,
int appearances,
boolean showcase,
Set<Long> dancerIds,
Set<DanceGroupStyle> styles,
double averageAge) {
public GroupRequirement {
dancerIds = Set.copyOf(dancerIds);
styles = Set.copyOf(styles);
}
public GroupRequirement(final long groupId, final int appearances) {
this(groupId, appearances, false, Set.of(), Set.of(), 0);
}
}

View File

@@ -0,0 +1,117 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Shared hard-rule validation for solver results and manually edited schedules. */
public final class ScheduleRules {
private ScheduleRules() {}
public static List<String> violations(final SchedulingInput input, final List<List<Long>> parts) {
final List<String> errors = new ArrayList<>();
if (input.dayParts() == 0 || input.preferredDayParts() < 1 || input.preferredDayParts() > input.dayParts()) {
errors.add("Configure dayparts and preferredDayParts between 1 and the number of dayparts.");
}
if (parts == null || parts.size() != input.dayParts() || parts.stream().anyMatch(java.util.Objects::isNull)) {
errors.add("Assignments must contain one list for every daypart.");
return List.copyOf(errors);
}
final Map<Long, GroupRequirement> groups = new HashMap<>();
input.groups().forEach((final GroupRequirement g) -> groups.put(g.groupId(), g));
for (int p = 0; p < parts.size(); p++) {
validatePart(parts.get(p), groups, p, errors);
}
appearances(input, parts, errors);
attendance(input, parts, errors);
final int min = parts.stream().mapToInt(List::size).min().orElse(0);
final int max = parts.stream().mapToInt(List::size).max().orElse(0);
if (max - min > 2) {
errors.add("Daypart performance counts differ by more than two.");
}
return List.copyOf(errors);
}
private static void validatePart(
final List<Long> ids, final Map<Long, GroupRequirement> groups, final int part, final List<String> errors) {
if (new HashSet<>(ids).size() != ids.size() || !groups.keySet().containsAll(ids)) {
errors.add("Daypart " + (part + 1) + " contains duplicate, unknown or empty groups.");
return;
}
final List<GroupRequirement> ordered = ids.stream().map(groups::get).toList();
showcases(ordered, part, errors);
gaps(ordered, part, errors);
}
private static void showcases(final List<GroupRequirement> groups, final int part, final List<String> errors) {
final long count = groups.stream().filter(GroupRequirement::showcase).count();
if (count == 0) {
return;
}
final boolean first = groups.getFirst().showcase();
final boolean last = groups.getLast().showcase();
if ((count == 1 && !first && !last) || (count >= 2 && (!first || !last))) {
errors.add("Daypart " + (part + 1) + " must place showcases at its opening/closing.");
}
}
private static void gaps(final List<GroupRequirement> groups, final int part, final List<String> errors) {
final Map<Long, Integer> last = new HashMap<>();
for (int i = 0; i < groups.size(); i++) {
for (final long dancer : groups.get(i).dancerIds()) {
final Integer previous = last.put(dancer, i);
if (previous != null && i - previous < 3) {
errors.add("Daypart " + (part + 1) + ": dancer " + dancer
+ " needs two intervening performances before group "
+ groups.get(i).groupId() + ".");
}
}
}
}
private static void appearances(
final SchedulingInput input, final List<List<Long>> parts, final List<String> errors) {
for (final GroupRequirement group : input.groups()) {
if (parts.stream()
.filter((final List<Long> ids) -> ids.contains(group.groupId()))
.count()
!= group.appearances()) {
errors.add("Group " + group.groupId() + " requires " + group.appearances() + " dayparts.");
}
}
}
private static void attendance(
final SchedulingInput input, final List<List<Long>> parts, final List<String> errors) {
final Set<Long> exempt = new HashSet<>(input.coachDancerIds());
input.groups().stream()
.filter(GroupRequirement::showcase)
.forEach((final GroupRequirement g) -> exempt.addAll(g.dancerIds()));
final Map<Long, Set<Integer>> attendance = new HashMap<>();
for (final GroupRequirement group : input.groups()) {
recordAttendance(group, parts, attendance);
}
attendance.forEach((final Long dancer, final Set<Integer> dayparts) -> {
if (!exempt.contains(dancer) && dayparts.size() > input.preferredDayParts()) {
errors.add(
"Dancer " + dancer + " exceeds preferredDayParts=" + input.preferredDayParts() + " dayparts.");
}
});
}
private static void recordAttendance(
final GroupRequirement group, final List<List<Long>> parts, final Map<Long, Set<Integer>> attendance) {
for (int p = 0; p < parts.size(); p++) {
if (parts.get(p).contains(group.groupId())) {
for (final long dancer : group.dancerIds()) {
attendance
.computeIfAbsent(dancer, (final Long id) -> new HashSet<>())
.add(p);
}
}
}
}
}

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,27 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.List;
import java.util.Set;
/**
* Immutable solver snapshot.
*
* @param coachDancerIds dancer registration IDs linked to coaches, used for participation exemptions
*/
public record SchedulingInput(
long showId,
long revision,
int dayParts,
List<GroupRequirement> groups,
int preferredDayParts,
Set<Long> coachDancerIds) {
public SchedulingInput {
groups = List.copyOf(groups);
coachDancerIds = Set.copyOf(coachDancerIds);
}
public SchedulingInput(
final long showId, final long revision, final int dayParts, final List<GroupRequirement> groups) {
this(showId, revision, dayParts, groups, Math.min(2, dayParts), Set.of());
}
}

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,16 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public record SchedulingResult(boolean optimal, List<List<Long>> assignments) {
public SchedulingResult {
assignments = assignments == null
? null
: assignments.stream()
.map((final List<Long> part) ->
part == null ? null : Collections.unmodifiableList(new ArrayList<>(part)))
.toList();
}
}

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

@@ -0,0 +1,20 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.HashSet;
import java.util.Set;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
public final class VarietyScore {
private VarietyScore() {}
/** Integer CP-SAT score: each equally weighted component ranges from zero to one million. */
public static long penalty(final GroupRequirement a, final GroupRequirement b) {
final Set<DanceGroupStyle> union = new HashSet<>(a.styles());
union.addAll(b.styles());
final Set<DanceGroupStyle> shared = new HashSet<>(a.styles());
shared.retainAll(b.styles());
final double style = union.isEmpty() ? 0 : (double) shared.size() / union.size();
final double age = Math.max(0, 1 - Math.abs(a.averageAge() - b.averageAge()) / 5);
return Math.round(1_000_000 * style) + Math.round(1_000_000 * age);
}
}

View File

@@ -0,0 +1,10 @@
package nl.agilecc.agiledancers.model.specifications;
public enum DanceGroupStyle {
HIPHOP,
JAZZ,
MODERN,
STREETDANCE,
CLASSIC,
BREAKDANCE
}

View File

@@ -0,0 +1,9 @@
package nl.agilecc.agiledancers.model.specifications;
public enum DanceGroupType {
SHOW_CASE,
PRE_EDUCATION,
SELECTION,
REGULAR,
PRE_SCHOOLER
}

View File

@@ -0,0 +1,7 @@
package nl.agilecc.agiledancers.model.specifications;
public enum DayPartType {
MORNING,
AFTERNOON,
EVENING;
}

View File

@@ -0,0 +1,44 @@
package nl.agilecc.agiledancers.repository;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.AbstractEntity;
public abstract class AbstractRepository<T extends AbstractEntity> {
private long sequence = 0L;
private java.util.function.LongSupplier identifiers;
protected final Map<Long, T> entities = new LinkedHashMap<>();
public long nextId() {
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) {
if (entity.getId() == null) {
entity.setId(nextId());
}
entities.put(entity.getId(), entity);
return entity;
}
public List<T> findAll() {
return List.copyOf(entities.values());
}
public T findById(final long id) {
return entities.get(id);
}
public void delete(final long id) {
entities.remove(id);
}
}

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.repository;
import nl.agilecc.agiledancers.model.Coach;
public class CoachRepository extends AbstractRepository<Coach> {}

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.repository;
import nl.agilecc.agiledancers.model.DanceGroup;
public class DanceGroupRepository extends AbstractRepository<DanceGroup> {}

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.repository;
import nl.agilecc.agiledancers.model.DanceSchool;
public class DanceSchoolRepository extends AbstractRepository<DanceSchool> {}

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.repository;
import nl.agilecc.agiledancers.model.Dancer;
public class DancerRepository extends AbstractRepository<Dancer> {}

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,5 @@
package nl.agilecc.agiledancers.repository;
import nl.agilecc.agiledancers.model.DayPart;
public class DayPartRepository extends AbstractRepository<DayPart> {}

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,5 @@
package nl.agilecc.agiledancers.repository;
import nl.agilecc.agiledancers.model.Show;
public class ShowRepository extends AbstractRepository<Show> {}

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

@@ -0,0 +1,52 @@
package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.CoachRequest;
import nl.agilecc.agiledancers.rest.apimodel.CoachView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.CoachLinks;
@SchoolTransactionBoundary
@Path("/api/dance-schools/{schoolId}/coaches")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CoachResource {
private @Inject DomainViewMapper views;
private @Inject CoachLinks links;
private @PathParam("schoolId") long schoolId;
@GET
public List<CoachView> list() {
return links.list(schoolId).stream().map(views::coach).toList();
}
@POST
public CoachView create(final @NotNull @Valid CoachRequest request) {
return views.coach(links.link(schoolId, null, request.dancerId()));
}
@PUT
@Path("/{id}")
public CoachView link(final @PathParam("id") long id, final @NotNull @Valid CoachRequest request) {
return views.coach(links.link(schoolId, id, request.dancerId()));
}
@DELETE
@Path("/{id}/dancer")
public void unlink(final @PathParam("id") long id) {
links.unlink(schoolId, id);
}
}

View File

@@ -0,0 +1,91 @@
package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import java.util.List;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.model.GroupDetails;
@SchoolTransactionBoundary
@Path("/api/dance-schools/{schoolId}/dance-groups")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DanceGroupResource {
private @Inject DomainViewMapper views;
private @Inject SchoolManagementService service;
private @PathParam("schoolId") long schoolId;
@GET
public List<GroupView> list() {
return service.groups(schoolId).stream().map(views::group).toList();
}
@GET
@Path("/{id}")
public GroupView get(final @PathParam("id") long id) {
return views.group(service.group(schoolId, id));
}
@POST
public Response create(final @NotNull @Valid GroupRequest request, final @Context UriInfo uriInfo) {
final DanceGroup entity = service.createGroup(
schoolId,
new GroupDetails(
request.category(), request.type(), request.startAge(), request.endAge(), request.styles()));
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
.entity(views.group(entity))
.build();
}
@PUT
@Path("/{id}")
public GroupView update(final @PathParam("id") long id, final @NotNull @Valid GroupRequest request) {
return views.group(service.updateGroup(
schoolId,
id,
new GroupDetails(
request.category(), request.type(), request.startAge(), request.endAge(), request.styles())));
}
@DELETE
@Path("/{id}")
public Response delete(final @PathParam("id") long id) {
service.deleteGroup(schoolId, id);
return Response.noContent().build();
}
@PUT
@Path("/{id}/dancers/{dancerId}")
public Response addDancer(final @PathParam("id") long id, final @PathParam("dancerId") long dancerId) {
service.addGroupDancer(schoolId, id, dancerId);
return Response.noContent().build();
}
@DELETE
@Path("/{id}/dancers/{dancerId}")
public Response removeDancer(final @PathParam("id") long id, final @PathParam("dancerId") long dancerId) {
service.removeGroupDancer(schoolId, id, dancerId);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,84 @@
package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import java.util.List;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.SchoolManagementService;
@SchoolTransactionBoundary
@Path("/api/dance-schools")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DanceSchoolResource {
private @Inject DomainViewMapper views;
private @Inject SchoolManagementService service;
private @Inject nl.agilecc.agiledancers.service.ShowSchedules schedules;
@GET
@Path("/{id}/data")
public nl.agilecc.agiledancers.rest.apimodel.SchoolDataView data(final @PathParam("id") long id) {
final DanceSchool school = service.school(id);
return new nl.agilecc.agiledancers.rest.apimodel.SchoolDataView(
views.school(school),
school.getDancers().stream().map(views::dancer).toList(),
school.getDanceGroups().stream().map(views::group).toList(),
school.getShows().stream()
.map((final nl.agilecc.agiledancers.model.Show show) ->
views.show(show, schedules.violations(id, show.getId())))
.toList(),
school.getCoaches().stream().map(views::coach).toList());
}
@GET
public List<SchoolView> list() {
return service.schools().stream().map(views::school).toList();
}
@GET
@Path("/{id}")
public SchoolView get(final @PathParam("id") long id) {
return views.school(service.school(id));
}
@POST
public Response create(final @NotNull @Valid SchoolRequest request, final @Context UriInfo uriInfo) {
final DanceSchool entity = service.createSchool(request.name());
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
.entity(views.school(entity))
.build();
}
@PUT
@Path("/{id}")
public SchoolView update(final @PathParam("id") long id, final @NotNull @Valid SchoolRequest request) {
return views.school(service.updateSchool(id, request.name()));
}
@DELETE
@Path("/{id}")
public Response delete(final @PathParam("id") long id) {
service.deleteSchool(id);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,69 @@
package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import java.util.List;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.SchoolManagementService;
@SchoolTransactionBoundary
@Path("/api/dance-schools/{schoolId}/dancers")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DancerResource {
private @Inject DomainViewMapper views;
private @Inject SchoolManagementService service;
private @PathParam("schoolId") long schoolId;
@GET
public List<DancerView> list() {
return service.dancers(schoolId).stream().map(views::dancer).toList();
}
@GET
@Path("/{id}")
public DancerView get(final @PathParam("id") long id) {
return views.dancer(service.dancer(schoolId, id));
}
@POST
public Response create(final @NotNull @Valid DancerRequest request, final @Context UriInfo uriInfo) {
final Dancer entity = service.createDancer(schoolId, request.name(), request.birthDate());
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
.entity(views.dancer(entity))
.build();
}
@PUT
@Path("/{id}")
public DancerView update(final @PathParam("id") long id, final @NotNull @Valid DancerRequest request) {
return views.dancer(service.updateDancer(schoolId, id, request.name(), request.birthDate()));
}
@DELETE
@Path("/{id}")
public Response delete(final @PathParam("id") long id) {
service.deleteDancer(schoolId, id);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,14 @@
package nl.agilecc.agiledancers.rest;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.jackson.ObjectMapperCustomizer;
import jakarta.inject.Singleton;
@Singleton
public class JacksonConfiguration implements ObjectMapperCustomizer {
@Override
public void customize(final ObjectMapper mapper) {
mapper.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT);
}
}

View File

@@ -0,0 +1,25 @@
package nl.agilecc.agiledancers.rest;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import nl.agilecc.agiledancers.rest.apimodel.ErrorView;
import nl.agilecc.agiledancers.service.ManagementException;
@Provider
public class ManagementExceptionMapper implements ExceptionMapper<ManagementException> {
@Override
public Response toResponse(final ManagementException exception) {
final int status = switch (exception.getKind()) {
case NOT_FOUND -> 404;
case STORAGE_UNAVAILABLE -> 503;
case INVALID_INPUT -> 400;
case CONFLICT -> 409;
};
return Response.status(status)
.type(MediaType.APPLICATION_JSON_TYPE)
.entity(new ErrorView(exception.getMessage()))
.build();
}
}

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

@@ -0,0 +1,76 @@
package nl.agilecc.agiledancers.rest;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.model.ImportPreview;
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
@SchoolTransactionBoundary
@Path("/api/dance-schools")
@Produces(MediaType.APPLICATION_JSON)
public class SchoolSnapshotResource {
private @Inject DomainViewMapper views;
private @Inject SchoolManagementService service;
private @Inject ObjectMapper mapper;
@GET
@Path("/{id}/export")
public Response export(final @PathParam("id") long id) throws Exception {
final String json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(service.exportSchool(id));
return Response.ok(json)
.header("Content-Disposition", "attachment; filename=school-" + id + ".json")
.build();
}
@POST
@Path("/import/preview")
@Consumes(MediaType.APPLICATION_JSON)
public ImportPreview preview(final String json) {
return service.preview(parse(json));
}
@POST
@Path("/import")
@Consumes(MediaType.APPLICATION_JSON)
public SchoolView apply(final String json, final @QueryParam("replaceId") Long replaceId) {
return views.school(service.importSchool(parse(json), replaceId));
}
private SchoolSnapshot parse(final String json) {
try {
final SchoolSnapshot snapshot = mapper.copy()
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
.readValue(json, SchoolSnapshot.class);
if (snapshot == null) {
throw new IllegalArgumentException("Het bestand bevat geen school");
}
return snapshot;
} catch (final Exception e) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT, "Ongeldig JSON-bestand: " + e.getMessage());
}
}
}

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

@@ -0,0 +1,127 @@
package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import java.util.List;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest;
import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import nl.agilecc.agiledancers.service.SchedulingRuns;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.ShowSchedules;
@SchoolTransactionBoundary
@Path("/api/dance-schools/{schoolId}/shows")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ShowResource {
private @Inject DomainViewMapper views;
private @Inject SchoolManagementService service;
private @Inject SchedulingRuns scheduling;
private @Inject ShowSchedules schedules;
private @PathParam("schoolId") long schoolId;
@GET
public List<ShowView> list() {
return service.shows(schoolId).stream().map(this::view).toList();
}
@GET
@Path("/{id}")
public ShowView get(final @PathParam("id") long id) {
return view(service.show(schoolId, id));
}
@POST
public Response create(final @NotNull @Valid ShowRequest request, final @Context UriInfo uriInfo) {
final Show entity = schedules.configure(schoolId, null, settings(request));
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
.entity(view(entity))
.build();
}
@PUT
@Path("/{id}")
public ShowView update(final @PathParam("id") long id, final @NotNull @Valid ShowRequest request) {
return view(schedules.configure(schoolId, id, settings(request)));
}
private ShowView view(final Show show) {
return views.show(show, schedules.violations(schoolId, show.getId()));
}
private static Show settings(final ShowRequest request) {
return Show.builder()
.name(request.name())
.preferredDayParts(request.preferredDayParts())
.dayParts(parts(request))
.build();
}
@PUT
@Path("/{id}/assignments")
public ShowView assignments(final @PathParam("id") long id, final @NotNull @Valid ScheduleRequest request) {
return view(schedules.saveDraft(schoolId, id, request.assignments()));
}
@POST
@Path("/{id}/finalize")
@Consumes(MediaType.WILDCARD)
public ShowView finalizeSchedule(final @PathParam("id") long id) {
return view(schedules.finalizeSchedule(schoolId, id));
}
private static List<DayPart> parts(final ShowRequest request) {
return request.dayParts().stream()
.map((final DayPartRequest p) -> DayPart.builder()
.date(p.date())
.type(p.type())
.danceGroups(List.of())
.build())
.toList();
}
@POST
@Path("/{id}/schedule")
@Consumes(MediaType.WILDCARD)
public Response schedule(final @PathParam("id") long id) {
return Response.accepted(scheduling.start(schoolId, id)).build();
}
@GET
@Path("/{id}/schedule")
public SchedulingRun schedulingStatus(final @PathParam("id") long id) {
return scheduling.status(schoolId, id);
}
@DELETE
@Path("/{id}")
public Response delete(final @PathParam("id") long id) {
service.deleteShow(schoolId, id);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotNull;
public record CoachRequest(@NotNull Long dancerId) {}

View File

@@ -0,0 +1,3 @@
package nl.agilecc.agiledancers.rest.apimodel;
public record CoachView(long id, String name, Long dancerId) {}

View File

@@ -0,0 +1,9 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PastOrPresent;
import java.time.LocalDate;
public record DancerRequest(
@NotBlank String name, @NotNull @PastOrPresent LocalDate birthDate) {}

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.time.LocalDate;
public record DancerView(long id, String name, LocalDate birthDate) {}

View File

@@ -0,0 +1,8 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
public record DayPartRequest(
@NotNull LocalDate date, @NotNull DayPartType type) {}

View File

@@ -0,0 +1,13 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
public record DayPartView(LocalDate date, DayPartType type, List<Long> danceGroupIds) {
public DayPartView {
danceGroupIds = danceGroupIds == null ? null : Collections.unmodifiableList(new ArrayList<>(danceGroupIds));
}
}

View File

@@ -0,0 +1,6 @@
package nl.agilecc.agiledancers.rest.apimodel;
import io.quarkus.runtime.annotations.RegisterForReflection;
@RegisterForReflection
public record ErrorView(String message) {}

View File

@@ -0,0 +1,21 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record GroupRequest(
@NotBlank String category,
@NotNull DanceGroupType type,
@NotNull @Min(0) Integer startAge,
@NotNull @Min(0) Integer endAge,
@NotNull Set<@NotNull DanceGroupStyle> styles) {
public GroupRequest {
styles = styles == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(styles));
}
}

View File

@@ -0,0 +1,23 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record GroupView(
long id,
String category,
DanceGroupType type,
int startAge,
int endAge,
Set<DanceGroupStyle> styles,
List<Long> dancerIds) {
public GroupView {
styles = styles == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(styles));
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
}
}

View File

@@ -0,0 +1,15 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotNull;
import java.util.List;
public record ScheduleRequest(@NotNull List<List<Long>> assignments) {
public ScheduleRequest {
assignments = assignments == null
? null
: assignments.stream()
.map((final List<Long> p) ->
p == null ? null : java.util.Collections.unmodifiableList(new java.util.ArrayList<>(p)))
.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,3 @@
package nl.agilecc.agiledancers.rest.apimodel;
public record SchedulingView(boolean optimal, String message) {}

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

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotBlank;
public record SchoolRequest(@NotBlank String name) {}

View File

@@ -0,0 +1,14 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public record SchoolView(
long id, long revision, String name, List<Long> dancerIds, List<Long> danceGroupIds, List<Long> showIds) {
public SchoolView {
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
danceGroupIds = danceGroupIds == null ? null : Collections.unmodifiableList(new ArrayList<>(danceGroupIds));
showIds = showIds == null ? null : Collections.unmodifiableList(new ArrayList<>(showIds));
}
}

View File

@@ -0,0 +1,22 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record ShowRequest(
@NotBlank String name,
@NotNull List<@NotNull @Valid DayPartRequest> dayParts,
Map<DanceGroupType, Integer> appearances,
Integer preferredDayParts) {
public ShowRequest {
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
}
}

View File

@@ -0,0 +1,23 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record ShowView(
long id,
String name,
List<DayPartView> dayParts,
Map<DanceGroupType, Integer> appearances,
int preferredDayParts,
boolean finalized,
List<String> violations) {
public ShowView {
violations = List.copyOf(violations);
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
}
}

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

@@ -0,0 +1,51 @@
package nl.agilecc.agiledancers.service;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.List;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
@Singleton
public class CoachLinks {
@Inject
private SchoolManagementService schools;
public List<Coach> list(final long schoolId) {
return List.copyOf(schools.school(schoolId).getCoaches());
}
public Coach link(final long schoolId, final Long coachId, final long dancerId) {
synchronized (schools) {
final DanceSchool school = schools.school(schoolId);
final Dancer dancer = schools.dancer(schoolId, dancerId);
final Coach coach = coachId == null ? create(school, dancer) : required(school, coachId);
coach.setDancer(dancer);
school.getShows().forEach(schools::touch);
return coach;
}
}
private static Coach create(final DanceSchool school, final Dancer dancer) {
final Coach coach = Coach.builder().name(dancer.getName()).build();
coach.setId(school.getCoaches().stream().mapToLong(Coach::getId).max().orElse(0) + 1);
school.addCoach(coach);
return coach;
}
private static Coach required(final DanceSchool school, final long id) {
return school.getCoaches().stream()
.filter((final Coach c) -> c.getId() == id)
.findFirst()
.orElseThrow(() -> new ManagementException(ManagementExceptionKind.NOT_FOUND, "Unknown coach"));
}
public void unlink(final long schoolId, final long id) {
synchronized (schools) {
final DanceSchool school = schools.school(schoolId);
required(school, id).setDancer(null);
school.getShows().forEach(schools::touch);
}
}
}

View File

@@ -0,0 +1,12 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.repository.CoachRepository;
public class CoachService {
private final CoachRepository coachRepository = new CoachRepository();
public Coach addCoach(final String name, final Coach coach) {
return coachRepository.add(Coach.builder().name(name).build());
}
}

View File

@@ -0,0 +1,27 @@
package nl.agilecc.agiledancers.service;
import java.util.LinkedHashSet;
import java.util.Set;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.repository.DanceGroupRepository;
import nl.agilecc.agiledancers.service.model.GroupDetails;
public class DanceGroupService {
private final DanceGroupRepository danceGroupRepository = new DanceGroupRepository();
public DanceGroup add(final GroupDetails details, final Set<Dancer> dancers, final Coach coach) {
final DanceGroup danceGroup = DanceGroup.builder()
.category(details.category())
.type(details.type())
.startAge(details.startAge())
.endAge(details.endAge())
.dancers(dancers)
.styles(details.styles() == null ? null : new LinkedHashSet<>(details.styles()))
.coach(coach)
.build();
return danceGroupRepository.add(danceGroup);
}
}

View File

@@ -0,0 +1,33 @@
package nl.agilecc.agiledancers.service;
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.DanceSchoolRepository;
public class DanceSchoolService {
private final DanceSchoolRepository danceSchoolRepository = new DanceSchoolRepository();
public DanceSchool add(final String name) {
final DanceSchool danceSchool = DanceSchool.builder().name(name).build();
return danceSchoolRepository.add(danceSchool);
}
public void addDancer(final Long danceSchoolId, final Dancer dancer) {
danceSchoolRepository.findById(danceSchoolId).addDancer(dancer);
}
public void addDanceGroup(final Long danceSchoolId, final DanceGroup danceGroup) {
danceSchoolRepository.findById(danceSchoolId).addDanceGroup(danceGroup);
}
public void addCoach(final Long danceSchoolId, final Coach coach) {
danceSchoolRepository.findById(danceSchoolId).addCoach(coach);
}
public void addShow(final Long danceSchoolId, final Show show) {
danceSchoolRepository.findById(danceSchoolId).addShow(show);
}
}

View File

@@ -0,0 +1,14 @@
package nl.agilecc.agiledancers.service;
import java.time.LocalDate;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.repository.DancerRepository;
public class DancerService {
private final DancerRepository dancerRepository = new DancerRepository();
public Dancer addDancer(final String name, final LocalDate birthDate) {
final Dancer dancer = Dancer.builder().name(name).birthDate(birthDate).build();
return dancerRepository.add(dancer);
}
}

View File

@@ -0,0 +1,15 @@
package nl.agilecc.agiledancers.service;
public class ManagementException extends RuntimeException {
private final ManagementExceptionKind kind;
public ManagementException(final ManagementExceptionKind kind, final String message) {
super(message);
this.kind = kind;
}
public ManagementExceptionKind getKind() {
return kind;
}
}

View File

@@ -0,0 +1,8 @@
package nl.agilecc.agiledancers.service;
public enum ManagementExceptionKind {
NOT_FOUND,
STORAGE_UNAVAILABLE,
INVALID_INPUT,
CONFLICT
}

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