Compare commits

...
26 Commits
Author SHA1 Message Date
fromme f48e53fdd0 fix: verify the oauth2 state before the error parameter 2026-09-23 17:48:24 +02:00
fromme 3f4586ce06 feat: harden the myep oauth2 client 2026-09-23 16:06:25 +02:00
frommeandClaude Opus 5 2a8649fc6d chore: run MariaDB 11.8 in ddev to match production
Development ran MySQL 8.4 while production runs MariaDB, so nothing engine-specific
was ever exercised on the engine it ships on.

DATABASE_URL carries serverVersion=mariadb-* now, which is what makes DBAL pick its
MariaDB platform. This matters most for the test environment: it reads DATABASE_URL
from the committed .env and not from .env.dev.local, so a MySQL version there had the
suite running on MySQL84Platform against a MariaDB server - and schema:validate
reporting every nullable and every json column as drift. With the platform right, a
db_test built from the mapping is in sync.

The post-start hooks create db_test and grant the db user, which ddev does not do on
its own, and sync the schema from the entity mapping. Without them the first test run
after a fresh start fails on the missing database.

The remaining changes in config.yaml are ddev's own regeneration of the commented
reference section.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 09:02:56 +02:00
fromme 161cfb9e62 chore: exclude secrets file from repository 2026-09-23 08:06:45 +02:00
fromme eac125517f fix: use a SortDirection instance for the availabilities ordering 2026-09-21 12:20:55 +02:00
fromme af46c57bd1 feat: normalize email addresses to lowercase on write 2026-09-21 12:20:55 +02:00
fromme d6327f033e fix: stagger cron triggered tasks between prod and staging 2026-09-20 13:32:48 +02:00
fromme 752db081b3 chore: update project dependencies 2026-09-20 13:32:23 +02:00
fromme 518f3a46d0 test: adopt the ROLE_TEAM_ADMIN label rename 2026-09-19 11:36:23 +02:00
fromme a05143bfbe fix: make BusProNet response failures diagnosable and non-fatal 2026-09-19 11:36:14 +02:00
fromme 29cedd5916 chore: disable unused ddev ssh-agent 2026-09-17 08:31:29 +02:00
fromme 5c57dc823f fix: add missing access checks 2026-09-16 18:03:04 +02:00
fromme d4908b6ab9 feat: rename ROLE_ADMIN to ROLE_TEAM_ADMIN since being too broad 2026-09-16 17:20:45 +02:00
fromme 39a343911a chore: update project dependencies 2026-09-16 17:15:31 +02:00
fromme 66d560f98d fix: pass query parameters individually to querybuilder 2026-09-16 17:14:24 +02:00
fromme 86817731f4 chore: add deployer config for staging environment 2026-09-16 15:35:46 +02:00
fromme 9d639c8db1 chore: update project dependencies 2026-09-15 09:20:07 +02:00
fromme 8e599a5dd7 fix: correctly implement abstract methods in filter handler 2026-09-15 09:17:35 +02:00
fromme 9072e764c0 chore: update deployer config after final migration to new host 2026-09-12 08:24:15 +02:00
fromme 24451d0f36 chore: update scheduler notification config 2026-09-11 12:57:52 +02:00
fromme d86eb1fd2e feat: maintenance mode indicator for whitelisted ips 2026-09-11 08:29:51 +02:00
fromme 3cdaee1df3 chore: code cleanup 2026-09-09 11:15:35 +02:00
fromme 4b20aad98e chore: fix deprecations 2026-09-09 11:14:00 +02:00
fromme 52926ec6cc feat: additional receipts as attachments to teamer invoices
addresses #869dv97u9
2026-09-09 11:10:57 +02:00
fromme 16d847137d feat: project and subdomain rename 2026-09-08 14:13:46 +02:00
fromme 66527515ef feat: record application deleted event for statistics 2026-09-08 14:11:30 +02:00
178 changed files with 2429 additions and 768 deletions
+50 -28
View File
@@ -1,3 +1,4 @@
name: myep-team
type: php
docroot: public
php_version: "8.4"
@@ -6,8 +7,20 @@ xdebug_enabled: false
additional_hostnames: []
additional_fqdns: []
database:
type: mysql
version: "8.4"
type: mariadb
version: "11.8"
hooks:
# The agent hooks in config.codex-claude.yaml are merged with these, not replaced by them.
post-start:
# ddev creates "db" alone, so the test database has to be created and granted here.
# Without it the first test run after a fresh start fails with "Access denied for user
# 'db'@'%' to database 'db_test'".
- exec: mysql -uroot -proot -hdb -e "CREATE DATABASE IF NOT EXISTS db_test CHARACTER SET utf8mb4; GRANT ALL ON db_test.* TO 'db'@'%';"
# Schema from the entity mapping, never from the migrations: the squashed history is not
# replayable from an empty database (Version20260824124850 alters a table that
# Version20260901090029 only creates later). No fixtures - a test persists its own rows.
- exec: php bin/console doctrine:schema:update --force --env=test --no-interaction
omit_containers: [ddev-ssh-agent]
use_dns_when_possible: true
timezone: Europe/Berlin
composer_version: "2"
@@ -15,7 +28,6 @@ web_environment: []
nodejs_version: "18"
corepack_enable: false
disable_upload_dirs_warning: true
xhgui_https_port: "8142"
xhgui_http_port: "8143"
@@ -26,16 +38,16 @@ xhgui_http_port: "8143"
# If the name is omitted, the project will take the name of the enclosing directory,
# which is useful if you want to have a copy of the project side by side with this one.
# type: <projecttype> # backdrop, cakephp, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, generic, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress
# type: <projecttype> # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, maho, modx, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock
# See https://docs.ddev.com/en/stable/users/quickstart/ for more
# information on the different project types
# docroot: <relative_path> # Relative path to the directory containing index.php.
# php_version: "8.3" # PHP version to use, "5.6" through "8.5"
# php_version: "8.4" # PHP version to use, "5.6" through "8.5"
# You can explicitly specify the webimage but this
# is not recommended, as the images are often closely tied to DDEV's' behavior,
# is not recommended, as the images are often closely tied to DDEV's behavior,
# so this can break upgrades.
# webimage: <docker_image>
@@ -45,10 +57,18 @@ xhgui_http_port: "8143"
# database:
# type: <dbtype> # mysql, mariadb, postgres
# version: <version> # database version, like "10.11" or "8.0"
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8
# MySQL versions can be 5.5-8.0, 8.4
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8, 12.3
# MySQL versions can be 5.5-8.0, 8.4, 9.7
# PostgreSQL versions can be 9-18
# You can explicitly specify the dbimage but this
# is not recommended, as the images are often closely tied to DDEV's behavior,
# so this can break upgrades.
# dbimage: <docker_image>
# Its unusual to change this option, and we dont recommend it without Docker experience and a good reason.
# Typically, this means additions to the existing db image using a .ddev/db-build/Dockerfile.*
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
@@ -68,8 +88,7 @@ xhgui_http_port: "8143"
# bind_all_ports is used (normally with router disabled)
# xhprof_mode: [prepend|xhgui|global]
# Set to "xhgui" to enable XHGui features
# "xhgui" will become default in a future major release
# Default is "xhgui"
# webserver_type: nginx-fpm, apache-fpm, generic
@@ -87,22 +106,24 @@ xhgui_http_port: "8143"
# commands are executed.
# composer_version: "2"
# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1
# You can set it to "" or "2" (default) for Composer v2
# to use the latest major version available at the time your container is built.
# It is also possible to use each other Composer version channel. This includes:
# It is also possible to use any other Composer version channel. This includes:
# - 2.2 (latest Composer LTS version)
# - stable
# - preview
# - snapshot
# Alternatively, an explicit Composer version may be specified, for example "2.2.18".
# To reinstall Composer after the image was built, run "ddev debug rebuild".
# To reinstall Composer after the image was built, run "ddev utility rebuild".
# nodejs_version: "22"
# nodejs_root: <relative_path>
# Relative path to the directory containing the Node.js version file from the
# project root. Only used with "nodejs_version: auto" or "nodejs_version: engine".
# nodejs_version: "24"
# change from the default system Node.js version to any other version.
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation,
# Note that using of 'ddev nvm' is discouraged because "nodejs_version" is much easier to use,
# can specify any version, and is more robust than using 'nvm'.
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.
# corepack_enable: false
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
@@ -162,9 +183,7 @@ xhgui_http_port: "8143"
# - "global": uses the value from the global config.
# - "none": disables performance optimization for this project.
# - "mutagen": enables Mutagen for this project.
# - "nfs": enables NFS for this project.
#
# See https://docs.ddev.com/en/stable/users/install/performance/#nfs
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
# fail_on_hook_fail: False
@@ -194,10 +213,10 @@ xhgui_http_port: "8143"
# The mailpit port is not normally bound on the host at all, instead being routed
# through ddev-router, but it can be bound directly to localhost if specified here.
# webimage_extra_packages: [php7.4-tidy, php-bcmath]
# webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
# Extra Debian packages that are needed in the webimage can be added here
# dbimage_extra_packages: [telnet,netcat]
# dbimage_extra_packages: [netcat, telnet, sudo]
# Extra Debian packages that are needed in the dbimage can be added here
# use_dns_when_possible: true
@@ -209,12 +228,15 @@ xhgui_http_port: "8143"
# project_tld: ddev.site
# The top-level domain used for project URLs
# The default "ddev.site" allows DNS lookup via a wildcard
# If you prefer you can change this to "ddev.local" to preserve
# pre-v1.9 behavior.
# ngrok_args: --basic-auth username:pass1234
# Provide extra flags to the "ngrok http" command, see
# https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h"
# share_default_provider: ngrok
# The default share provider to use for "ddev share"
# Defaults to global configuration, usually "ngrok"
# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/
# share_provider_args: --basic-auth username:pass1234
# Provide extra flags to the share provider script
# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args
# disable_settings_management: false
# If true, DDEV will not create CMS-specific settings files like
@@ -276,7 +298,7 @@ xhgui_http_port: "8143"
# override_config: false
# By default, config.*.yaml files are *merged* into the configuration
# But this means that some things can't be overridden
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge
# For example, if you have 'use_dns_when_possible: true' you can't override it with a merge
# and you can't erase existing hooks or all environment variables.
# However, with "override_config: true" in a particular config.*.yaml file,
# 'use_dns_when_possible: false' can override the existing values, and
@@ -286,7 +308,7 @@ xhgui_http_port: "8143"
# web_environment: []
# or
# additional_hostnames: []
# can have their intended affect. 'override_config' affects only behavior of the
# can have their intended effect. 'override_config' affects only behavior of the
# config.*.yaml file it exists in.
# Many DDEV commands can be extended to run tasks before or after the
+10 -2
View File
@@ -27,7 +27,10 @@ APP_SECRET=b3cb0285aea14bbffb57df0cc3aa84f4
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=15&charset=utf8"
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.4.5&charset=utf8mb4"
# ddev runs MariaDB to match production. The serverVersion is what makes DBAL pick its
# MariaDB platform - left at a MySQL version it speaks MySQL to a MariaDB server, and the
# test suite then runs against a different platform than production.
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=mariadb-11.8.9&charset=utf8mb4"
###< doctrine/doctrine-bundle ###
###> symfony/messenger ###
@@ -46,6 +49,11 @@ MAILING_MAILER_DSN=null://null
APP_BASE_URI=https://myep-team.ddev.site
# Regex matched against the Host header; requests for any other host are refused. The
# OAuth2 redirect_uri is generated from the request, so this is what pins it. Production
# sets its own value outside the repository, like APP_BASE_URI above.
APP_TRUSTED_HOSTS=^myep-team\.ddev\.site$
APP_BPN_USER=
APP_BPN_PASSWORD=
APP_BPN_IP=
@@ -55,7 +63,7 @@ APP_BPN_DEBUG=false
# This hotel code will be assigned to admin users together with ROLE_HOTEL_MANAGER
# in dev and staging environments for testing purposes
APP_BPN_DEFAULT_HOTEL_CODE=
APP_BPN_CRM_ID_ADMIN=1292
APP_BPN_CRM_ID_TEAM_ADMIN=1484
APP_BPN_CRM_ID_MANAGER=1293
APP_BPN_CRM_ID_TEAMER=1070
+2
View File
@@ -4,3 +4,5 @@ APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
# the test client requests http://localhost/
APP_TRUSTED_HOSTS='^localhost$'
+2
View File
@@ -35,3 +35,5 @@ yarn-error.log
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###
/http-client.private.env.json
Generated
+394 -354
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -25,7 +25,7 @@ texts:
Zusätzliche Absprachen: {specialAgreements}
Schau einmal in das My E&P-Team Portal, um die Einsatzdetails einzusehen und deinen Honorarvertrag zu unterschreiben.
Schau einmal in das E&P-Team Portal, um die Einsatzdetails einzusehen und deinen Honorarvertrag zu unterschreiben.
Mit dem Erhalt dieser E-Mail hast du {contractUploadDeadlineDays} Tage Zeit deinen Einsatz zu bestätigen, indem du den unterschriebenen Vertrag hochlädst. Ist diese Frist vergangen, wird der Einsatz für deine Teamkolleg:innen freigeschaltet.
@@ -45,7 +45,7 @@ texts:
Damit ist für dich alles erledigt - für diesen Einsatz brauchen wir weder einen Honorarvertrag noch eine Honorarnote von dir.
Schau einmal in das My E&P-Team Portal, um die Einsatzdetails einzusehen.
Schau einmal in das E&P-Team Portal, um die Einsatzdetails einzusehen.
Schön, dass du dabei bist und ganz viel Spaß in den Bergen! 😊
@@ -139,7 +139,7 @@ texts:
Wir hoffen, dass du ich schon freust und wünschen dir viel Spaß und Erfolg! Finale Infos erhältst du, falls noch nicht geschehen, ein paar Tage vor deinem Einsatz von deinen zuständigen Haus- oder Reisemanager:innen.
Bitte denk daran, deine **ausgefüllte Honorarnote bis zu {invoiceUploadDeadlineDays} Tage nach deinem Einsatz** in My E&P Team hochzuladen.
Bitte denk daran, deine **ausgefüllte Honorarnote bis zu {invoiceUploadDeadlineDays} Tage nach deinem Einsatz** in E&P Team hochzuladen.
Liebe Grüße,
dein Team Personalabteilung
+5
View File
@@ -5,6 +5,11 @@ framework:
http_method_override: false
handle_all_throwables: true
# The OAuth2 redirect_uri handed to MyE&P is generated from the incoming request, so an
# unvalidated Host header would let a crafted request point the authorization code
# somewhere else. Regex patterns, matched against the host without the scheme or port.
trusted_hosts: ['%env(APP_TRUSTED_HOSTS)%']
# Enables session support. Note that the session will ONLY be started if you read or write from it.
# Remove or comment this section to explicitly disable session support.
session:
+9
View File
@@ -36,6 +36,15 @@ oneup_uploader:
namer: app.upload_namer
storage:
directory: '%kernel.project_dir%/uploads/driver_license'
# Belege backing a Honorarnote. The directory has to be named exactly after
# Upload::TYPE_RECEIPT: UploadHandler::getUploadFilepath() interpolates the type into
# uploads/<type>/<shard>/<filename> rather than looking the mapping up.
receipt:
frontend: dropzone
use_orphanage: true
namer: app.upload_namer
storage:
directory: '%kernel.project_dir%/uploads/receipt'
chunks:
maxage: 86400
storage:
+1 -1
View File
@@ -9,7 +9,7 @@ security:
property: email
role_hierarchy:
ROLE_ADMIN: [ ROLE_ADMINISTRATIVE ]
ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ]
ROLE_MANAGER: [ ROLE_ADMINISTRATIVE ]
firewalls:
+47 -18
View File
@@ -4,30 +4,59 @@ zenstruck_schedule:
mailer:
service: mailer
default_to: [email protected]
default_from: team@ep-reisen.de
subject_prefix: "[MyE&P-Team]"
default_from: noreply@ep-reisen.de
subject_prefix: "[E&P-Team]"
schedule_extensions:
email_on_failure:
enabled: true
to: [email protected]
tasks:
- task: app:bpn-import
frequency: '0 3 * * *'
description: "Imports dates, products and hotels from BusProNet"
when@prod:
zenstruck_schedule:
tasks:
- task: app:bpn-import
frequency: '0 3 * * *'
description: "Imports dates, products and hotels from BusProNet"
- task: app:cron
frequency: '0 1 * * *'
description: "Executes several tasks triggered on a daily basis"
- task: app:cron
frequency: '0 1 * * *'
description: "Executes several tasks triggered on a daily basis"
- task: app:teamer-status
frequency: '30 1 * * *'
description: "Updates status of teamers depending on their disposition"
- task: app:teamer-status
frequency: '30 1 * * *'
description: "Updates status of teamers depending on their disposition"
- task: oneup:uploader:clear-orphans
frequency: '30 * * * *'
description: "Removes orphaned uploads"
- task: oneup:uploader:clear-orphans
frequency: '30 * * * *'
description: "Removes orphaned uploads"
- task: app:cleanup:xml-dumps
frequency: "0 2 * * *"
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
- task: app:cleanup:xml-dumps
frequency: '0 2 * * *'
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
when@staging:
zenstruck_schedule:
mailer:
subject_prefix: "[E&P-Team STAGING]"
tasks:
- task: app:bpn-import
frequency: '30 3 * * *'
description: "Imports dates, products and hotels from BusProNet"
- task: app:cron
frequency: '30 2 * * *'
description: "Executes several tasks triggered on a daily basis"
- task: app:teamer-status
frequency: '30 1 * * *'
description: "Updates status of teamers depending on their disposition"
- task: oneup:uploader:clear-orphans
frequency: '30 * * * *'
description: "Removes orphaned uploads"
- task: app:cleanup:xml-dumps
frequency: '0 2 * * *'
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
+2 -2
View File
@@ -1,6 +1,6 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
parameters:
bpn_crm_id_admin: '%env(int:APP_BPN_CRM_ID_ADMIN)%'
bpn_crm_id_team_admin: '%env(int:APP_BPN_CRM_ID_TEAM_ADMIN)%'
bpn_crm_id_manager: '%env(int:APP_BPN_CRM_ID_MANAGER)%'
bpn_crm_id_teamer: '%env(int:APP_BPN_CRM_ID_TEAMER)%'
bpn_default_hotel_code: '%env(default::APP_BPN_DEFAULT_HOTEL_CODE)%'
@@ -162,7 +162,7 @@ services:
App\BusProNet\ResponseParser:
arguments:
$options:
bpn_crm_id_admin: '%bpn_crm_id_admin%'
bpn_crm_id_team_admin: '%bpn_crm_id_team_admin%'
bpn_crm_id_manager: '%bpn_crm_id_manager%'
bpn_crm_id_teamer: '%bpn_crm_id_teamer%'
bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%'
+20 -36
View File
@@ -22,7 +22,7 @@ set('keep_releases', 3);
// Rsync options, mainly files/dirs to exclude
$rsyncOptions = [
'exclude' => [
'.cursor',
'.claude',
'.idea',
'.vscode',
'.DS_Store',
@@ -70,44 +70,33 @@ $rsyncOptions = [
];
host('prod')
->setHostname('185.243.135.29')
->setRemoteUser('p704161')
->setForwardAgent(true)
->setSshMultiplexing(true)
->setDeployPath('/home/www/p704161/html/myep-team')
->set('writable_mode', 'chmod')
->set('http_user', 'p704161')
->set('bin/php', '/usr/local/bin/php')
->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://myep-team.ep-reisen.de')
;
host('staging')
->setHostname('185.243.135.29')
->setRemoteUser('p704161')
->setForwardAgent(true)
->setSshMultiplexing(true)
->setDeployPath('/home/www/p704161/html/myep-team-staging')
->set('writable_mode', 'chmod')
->set('http_user', 'p704161')
->set('bin/php', '/usr/local/bin/php')
->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://myep-team.ep-reisen.net')
;
host('hetzner')
->setHostname('dedi10193.your-server.de')
->setRemoteUser('myepteam')
->setForwardAgent(true)
->setSshMultiplexing(true)
->setDeployPath('/usr/home/myepteam/public_html/myep-team')
->setDeployPath('/usr/www/users/myepteam/prod')
->set('bin/php', '/usr/bin/php')
->set('http_user', 'myepteam')
->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://myep-team.ep-reisen.net')
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://team.ep-reisen.de')
;
host('staging')
->setHostname('dedi10193.your-server.de')
->setRemoteUser('myepteam')
->setForwardAgent(true)
->setSshMultiplexing(true)
->setDeployPath('/usr/www/users/myepteam/staging')
->set('bin/php', '/usr/bin/php')
->set('http_user', 'myepteam')
->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://team.ep-reisen.net --web-basic-auth=team:staging')
->add('shared_files', [
'public/.htaccess',
'public/.htpasswd',
])
;
task('deploy', [
@@ -116,7 +105,6 @@ task('deploy', [
'deploy:lock',
'deploy:release',
'deploy:assets',
'deploy:check-platform',
'rsync',
'deploy:shared',
'deploy:writable',
@@ -127,10 +115,6 @@ task('deploy', [
'deploy:stop-workers',
]);
task('deploy:check-platform', function () {
runLocally('composer check-platform-reqs');
});
task('deploy:stop-workers', function () {
run('{{bin/console}} messenger:stop-workers');
});
+44 -12
View File
@@ -30,17 +30,35 @@ assign:
| Role | Label | Granted by | Revoked by | Hierarchy |
|------|-------|-----------|------------|-----------|
| `ROLE_ADMIN` | Admin | super admin, approving a CRM claim | the CRM, automatically | ⇒ `ROLE_ADMINISTRATIVE` |
| `ROLE_TEAM_ADMIN` | Admin | super admin, approving a CRM claim | the CRM, automatically | ⇒ `ROLE_ADMINISTRATIVE` |
| `ROLE_MANAGER` | Reisemanager | super admin, approving a CRM claim | the CRM, automatically | ⇒ `ROLE_ADMINISTRATIVE` |
| `ROLE_HOUSE_MANAGER` | Hausleitung | super admin, approving a CRM claim | the CRM, automatically | — |
| `ROLE_TEAMER` | Teamer | the CRM, automatically | the CRM, automatically | — |
### A note on the `TEAM_` prefix
`ROLE_TEAM_ADMIN`, `ROLE_TEAM_ADMIN_PENDING` and `ROLE_TEAM_SUPER_ADMIN` were renamed from
`ROLE_ADMIN`, `ROLE_ADMIN_PENDING` and `ROLE_SUPER_ADMIN`. The MyE&P identity provider is
shared with a sibling portal that uses `ROLE_ADMIN` for a different privilege, so the plain
name was ambiguous across the estate; the prefix makes it unambiguously *this* application's
admin. The German label is unchanged — it still reads "Admin" everywhere in the UI.
Two consequences worth remembering:
- **`ROLE_ADMINISTRATIVE` is a different role and was not renamed.** It is granted only by
the hierarchy, never stored, and it shares the old `ROLE_ADMIN` prefix — so any
search-and-replace over role names must match on a word boundary
(`ROLE_TEAM_ADMIN(?![A-Z_])`) or it will corrupt ~100 call sites silently.
- **`ELIGIBLE_ROLES` in `MyEpAuthenticator` is a wire contract**, not an internal name: it is
compared directly against the IdP's `roles` claim. It only works while MyE&P emits
`ROLE_TEAM_ADMIN`, so the two sides have to move together.
`User::PENDING_ROLES` holds a marker for each of the three administrative roles, keyed by
the role it stands for:
| Marker | Meaning |
|--------|---------|
| `ROLE_ADMIN_PENDING` | the CRM claims this person is an admin, nobody has confirmed it |
| `ROLE_TEAM_ADMIN_PENDING` | the CRM claims this person is an admin, nobody has confirmed it |
| `ROLE_MANAGER_PENDING` | likewise for Reisemanager |
| `ROLE_HOUSE_MANAGER_PENDING` | likewise for Hausleitung |
@@ -50,10 +68,10 @@ effects are cosmetic (rendered as "Admin (nicht freigeschaltet)") and organisati
put the user on the approval list). `ROLE_TEAMER` has no marker: it needs no approval.
Two further roles are synthesized by `User::getRoles()` and never stored: `ROLE_USER` for
everybody, and `ROLE_SUPER_ADMIN` when the separate `superAdmin` boolean column is set. A
everybody, and `ROLE_TEAM_SUPER_ADMIN` when the separate `superAdmin` boolean column is set. A
validation callback (`User::validateSuperAdmin()`) refuses `superAdmin` without
`ROLE_ADMIN` alongside it — super admin is an elevation, never a standalone grant. The sync
enforces the same rule from the other side: revoking `ROLE_ADMIN` clears the flag, or the one
`ROLE_TEAM_ADMIN` alongside it — super admin is an elevation, never a standalone grant. The sync
enforces the same rule from the other side: revoking `ROLE_TEAM_ADMIN` clears the flag, or the one
role that outranks every check in the application would outlive the role it depends on.
### Storage and accessors
@@ -63,7 +81,7 @@ slice it, and picking the right one matters:
| Accessor | Returns |
|----------|---------|
| `getRoles()` | the column **plus** synthesized `ROLE_USER` / `ROLE_SUPER_ADMIN` — what Symfony authorises against |
| `getRoles()` | the column **plus** synthesized `ROLE_USER` / `ROLE_TEAM_SUPER_ADMIN` — what Symfony authorises against |
| `getAssignedRoles()` | only the four real roles from the column — what the sync works on |
| `getPendingRoles()` | only the markers |
| `getNominatedRoles()` | the roles behind those markers, as `role => label` — what an approver acts on |
@@ -77,7 +95,7 @@ slice it, and picking the right one matters:
| CRM attribute | Recognised by | Sets |
|---------------|---------------|------|
| admin | attribute id `%bpn_crm_id_admin%`, selected | `isAdmin` |
| admin | attribute id `%bpn_crm_id_team_admin%`, selected | `isAdmin` |
| Reisemanager | attribute id `%bpn_crm_id_manager%`, selected | `isManager` |
| teamer | attribute id `%bpn_crm_id_teamer%`, selected | `isTeamer` |
| Hausleitung | attribute id listed in `%bpn_crm_house_manager_ids%`, selected | `isHouseManager` + the hotel code that id maps to |
@@ -111,12 +129,26 @@ roles were revoked.
>
> | Parameter | Attribute |
> |-----------|-----------|
> | `APP_BPN_CRM_ID_ADMIN` | `Admin` |
> | `APP_BPN_CRM_ID_TEAM_ADMIN` | the team-admin selection — **not** the old portal-wide `Admin` (1292) |
> | `APP_BPN_CRM_ID_MANAGER` | `Manager` |
> | `APP_BPN_CRM_ID_TEAMER` | `E&P Teamer - allg. Merkmal` |
>
> Matching is by id and never by label, so `Preisrechner Admin` does not trip the admin flag.
>
> `APP_BPN_CRM_ID_TEAM_ADMIN` was renamed from `APP_BPN_CRM_ID_ADMIN` with the
> `ROLE_TEAM_ADMIN` rename, and its **value has to change too**. The old value 1292 is the
> portal-wide admin selection, which still means `ROLE_ADMIN` in the sibling portal — keeping
> it would have left this app granting its admin off the very selection the rename was meant
> to stop sharing. It ships as `0` — a valid int that matches no attribute, so the container
> boots but nobody is granted the role — and must be set to the new selection's id.
>
> **This is a hard cutover.** `revokeUnclaimedRoles()` withdraws any granted role the CRM no
> longer claims, and `revokeSuperAdminWithoutRoleAdmin()` takes the super admin flag down with
> `ROLE_TEAM_ADMIN`. So the new selection must exist **and already be assigned to every admin**
> in BusPro before this is deployed; otherwise each of them is demoted on their next login and
> needs a super admin to re-approve. Admins who also hold `ROLE_TEAMER` degrade to teamer
> access; an admin without it is blocked outright by `disableForRevokedCrmRoles()`.
>
> `bpn_crm_house_manager_ids` (`config/services.yaml`) is deployment-critical for the same
> reason, and more sharply so: since roles are synced, an id missing from that map does not
> merely fail to nominate a Hausleitung, it **revokes** the role from everyone holding it, one
@@ -137,7 +169,7 @@ roles were revoked.
`BpnAuthenticator::getOrCreateLocalUser()``UserDataHandler::createLocalUser()` writes
`collectRoles()` verbatim, together with the hotel codes from the Hausleitung attributes.
A CRM admin who is not also a teamer therefore starts with `['ROLE_ADMIN_PENDING']` and no
A CRM admin who is not also a teamer therefore starts with `['ROLE_TEAM_ADMIN_PENDING']` and no
privileges at all: they can authenticate, but `UserChecker` refuses the session until a
super admin approves them.
@@ -148,7 +180,7 @@ the roles to **`syncRoles()`**, which is the whole policy in four steps:
1. **revoke** every granted role the CRM no longer claims. This is what makes BusPro the
source of truth, and it applies to `ROLE_TEAMER` as much as to the administrative roles.
2. **clear the super admin flag** when `ROLE_ADMIN` was among them — `ROLE_SUPER_ADMIN` is
2. **clear the super admin flag** when `ROLE_TEAM_ADMIN` was among them — `ROLE_TEAM_SUPER_ADMIN` is
synthesized from a separate column and would otherwise survive its own precondition.
3. **`refreshPendingRoles()`** recomputes the marker set from the current claims. A marker
whose real role is already granted is dropped — an approved role is never marked again.
@@ -189,7 +221,7 @@ runs again on submit to catch a sync that revoked the claim while the dialog was
A denial is not recorded anywhere: as long as the CRM keeps claiming the role, the
nomination is back on the next login.
**Super admin** is only offered to somebody who already holds `ROLE_ADMIN` — approve first,
**Super admin** is only offered to somebody who already holds `ROLE_TEAM_ADMIN` — approve first,
elevate afterwards. The one exception is a flag that outlived its role, which stays editable
so the account can be saved at all while `User::validateSuperAdmin()` is violated; the sync
clears it (see 2), so it should never occur in practice.
@@ -243,7 +275,7 @@ administrative users, teamers are an admin's business:
| Surface | Who | Notes |
|---------|-----|-------|
| `/admin/teamer/disable-user/{uuid}` and `/administrative/teamer/enable-user/{uuid}` | `ROLE_ADMIN` | teamers; public reason mandatory, internal optional |
| `/admin/teamer/disable-user/{uuid}` and `/administrative/teamer/enable-user/{uuid}` | `ROLE_TEAM_ADMIN` | teamers; public reason mandatory, internal optional |
| "Account gesperrt" checkbox on the user edit form | super admin (`UserVoter`) | everyone else; both reasons optional |
Both go through `User::setDisabled()`, which is a no-op when the state is unchanged — saving
+24 -5
View File
@@ -162,17 +162,36 @@ class ApiClient
$this->config['max_retries']
);
$this->send($socket, $body);
$response = $this->receive($socket);
$this->disconnect($socket);
// message length (10 bytes) is prepended to actual message
$xml = substr($response, 10);
try {
// the length header is consumed by receive(), this is the payload
$xml = $this->receive($socket);
} finally {
$this->disconnect($socket);
}
if (true === $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $xml);
}
return $this->responseParser->parseXmlString($type, $xml);
try {
return $this->responseParser->parseXmlString($type, $xml);
} catch (ResponseParserException $e) {
// Keep the evidence even outside debug mode: without the raw response a parse
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
if (true !== $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $xml);
}
$this->logger->error('Unable to parse BusProNet response', [
'request_id' => $requestId,
'type' => $type,
'response_length' => strlen($xml),
'error_message' => $e->getMessage(),
]);
throw $e;
}
}
private function dumpXmlToFile(string $type, string $requestId, string $body): void
+47 -4
View File
@@ -58,15 +58,58 @@ trait ApiClientTrait
fwrite($socket, $send);
}
/**
* Reads a single response message. The protocol prepends the payload length as a
* 10 byte header, so read exactly that many bytes rather than guessing at EOF:
* a peer that closes mid-stream would otherwise yield a silently truncated body.
*
* @throws ApiClientException
*/
private function receive($socket): string
{
$response = '';
$header = $this->readBytes($socket, 10);
while (false === feof($socket)) {
$response .= fread($socket, 4096);
if (10 !== strlen($header)) {
throw new ApiClientException(sprintf('Incomplete response header, got %d of 10 bytes', strlen($header)));
}
return $response;
$expectedLength = (int) trim($header);
if (1 > $expectedLength) {
throw new ApiClientException(sprintf('Response announced an empty body (header "%s")', trim($header)));
}
$body = $this->readBytes($socket, $expectedLength);
if (strlen($body) !== $expectedLength) {
throw new ApiClientException(sprintf('Truncated response, got %d of %d announced bytes', strlen($body), $expectedLength));
}
return $body;
}
/**
* @throws ApiClientException
*/
private function readBytes($socket, int $length): string
{
$buffer = '';
while (strlen($buffer) < $length && false === feof($socket)) {
$chunk = fread($socket, min(4096, $length - strlen($buffer)));
if (false === $chunk || '' === $chunk) {
if (true === (stream_get_meta_data($socket)['timed_out'] ?? false)) {
throw new ApiClientException(sprintf('Timed out reading response after %d of %d bytes', strlen($buffer), $length));
}
break;
}
$buffer .= $chunk;
}
return $buffer;
}
private function disconnect($socket): void
@@ -6,6 +6,7 @@ use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\Country;
use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\ResponseParserException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@@ -35,7 +36,8 @@ class CountryDataProvider
return $response->getItems();
});
} catch (ApiClientException $e) {
} catch (ApiClientException|ResponseParserException $e) {
$this->logger->error('Unable to fetch country base data from BusProNet: '.$e->getMessage());
$countries = [];
}
@@ -6,6 +6,7 @@ use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\ResponseParserException;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
@@ -36,7 +37,8 @@ class HotelDataProvider
return $response->getItems();
});
} catch (ApiClientException|InvalidArgumentException $e) {
} catch (ApiClientException|InvalidArgumentException|ResponseParserException $e) {
$this->logger->error('Unable to fetch hotel base data from BusProNet: '.$e->getMessage());
$hotels = [];
}
@@ -6,6 +6,7 @@ use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\Model\Pickup;
use App\BusProNet\ResponseParserException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@@ -35,7 +36,8 @@ class PickupDataProvider
return $response->getItems();
});
} catch (ApiClientException $e) {
} catch (ApiClientException|ResponseParserException $e) {
$this->logger->error('Unable to fetch pickup base data from BusProNet: '.$e->getMessage());
$pickups = [];
}
+3 -1
View File
@@ -39,7 +39,9 @@ class Communication
public function setEmail(?string $email): static
{
$this->email = $email;
// BusPro compares addresses case-insensitively; normalize on the way in so the DTO
// carries the same shape the entities store.
$this->email = null === $email ? null : mb_strtolower(trim($email));
return $this;
}
+49 -8
View File
@@ -30,10 +30,7 @@ class ResponseParser
*/
public function parseXmlString(string $type, string $content): mixed
{
$xml = simplexml_load_string($content);
if (false === $xml) {
throw new ResponseParserException('Unable to parse XML response');
}
$xml = $this->loadXml($content);
// Override type when present in XML to catch error responses
$responseType = $type;
@@ -65,7 +62,7 @@ class ResponseParser
return $this->createHotelsResponse($xml);
}
throw new ResponseParserException('Unable to parse XML response');
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType));
}
public function createNotificationResponse(\SimpleXMLElement $xml): NotificationResponse
@@ -200,7 +197,7 @@ class ResponseParser
$isHouseManager = true;
$hotelCodes[] = $houseManagerCode;
}
if ($this->config['bpn_crm_id_admin'] === $attribute->getId() && true === $attribute->isSelected()) {
if ($this->config['bpn_crm_id_team_admin'] === $attribute->getId() && true === $attribute->isSelected()) {
$isAdmin = true;
}
if ($this->config['bpn_crm_id_manager'] === $attribute->getId() && true === $attribute->isSelected()) {
@@ -302,14 +299,58 @@ class ResponseParser
return new BaseDataResponse($hotels);
}
/**
* The BusProNet endpoint can answer with an empty or truncated body. Keep the libxml
* reason instead of collapsing every shape of broken response into one message.
*
* @throws ResponseParserException
*/
private function loadXml(string $content): \SimpleXMLElement
{
if ('' === trim($content)) {
throw new ResponseParserException(sprintf('Unable to parse XML response (%d bytes): empty response', strlen($content)));
}
$previousUseErrors = libxml_use_internal_errors(true);
libxml_clear_errors();
try {
$xml = simplexml_load_string($content);
if (false === $xml) {
throw new ResponseParserException(sprintf('Unable to parse XML response (%d bytes): %s', strlen($content), $this->describeLibxmlErrors()));
}
return $xml;
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previousUseErrors);
}
}
private function describeLibxmlErrors(): string
{
$messages = [];
foreach (libxml_get_errors() as $error) {
$messages[] = sprintf('%s (line %d, column %d)', trim($error->message), $error->line, $error->column);
}
if ([] === $messages) {
return 'unknown XML error';
}
return implode('; ', array_unique($messages));
}
private function resolveOptions(array $options): array
{
$optionsResolver = new OptionsResolver();
$optionsResolver->setRequired(['bpn_crm_id_admin', 'bpn_crm_id_manager', 'bpn_crm_id_teamer', 'bpn_crm_house_manager_ids']);
$optionsResolver->setRequired(['bpn_crm_id_team_admin', 'bpn_crm_id_manager', 'bpn_crm_id_teamer', 'bpn_crm_house_manager_ids']);
$optionsResolver->setDefaults([
'bpn_default_hotel_code' => null,
]);
$optionsResolver->setAllowedTypes('bpn_crm_id_admin', 'int');
$optionsResolver->setAllowedTypes('bpn_crm_id_team_admin', 'int');
$optionsResolver->setAllowedTypes('bpn_crm_id_manager', 'int');
$optionsResolver->setAllowedTypes('bpn_crm_id_teamer', 'int');
$optionsResolver->setAllowedTypes('bpn_crm_house_manager_ids', 'array');
+7 -7
View File
@@ -69,7 +69,7 @@ class UserDataHandler
$claimedRoles = [];
if ($crmAttributes->isAdmin()) {
$claimedRoles[] = 'ROLE_ADMIN';
$claimedRoles[] = 'ROLE_TEAM_ADMIN';
}
if ($crmAttributes->isManager()) {
@@ -370,7 +370,7 @@ class UserDataHandler
* The whole policy, in the order it has to run:
*
* 1. revoke what is no longer claimed - the identity source leads;
* 2. drop the super admin flag along with ROLE_ADMIN, or the highest privilege in the
* 2. drop the super admin flag along with ROLE_TEAM_ADMIN, or the highest privilege in the
* application would outlive the role it depends on;
* 3. refresh the pending markers, after the revocation so that a role just revoked is
* not immediately marked again - it is unclaimed in both steps;
@@ -424,7 +424,7 @@ class UserDataHandler
*
* Only the roles of User::ROLES are touched: getAssignedRoles() excludes the pending
* markers as well as the implicit ROLE_USER, and the markers are dealt with by
* refreshPendingRoles(). ROLE_SUPER_ADMIN is not a stored role at all but a flag, so
* refreshPendingRoles(). ROLE_TEAM_SUPER_ADMIN is not a stored role at all but a flag, so
* it is handled separately below.
*
* @param string[] $claimedRoles
@@ -450,22 +450,22 @@ class UserDataHandler
}
/**
* Takes the super admin flag down with ROLE_ADMIN.
* Takes the super admin flag down with ROLE_TEAM_ADMIN.
*
* The flag is stored on its own and getRoles() turns it into ROLE_SUPER_ADMIN whatever
* The flag is stored on its own and getRoles() turns it into ROLE_TEAM_SUPER_ADMIN whatever
* else the user holds, so without this a person the CRM no longer calls an admin would
* keep the one role that outranks every check in the application. User::validateSuperAdmin()
* enforces the same rule on the edit form, but only there.
*/
private function revokeSuperAdminWithoutRoleAdmin(User $user): void
{
if (false === $user->isSuperAdmin() || true === $user->hasRole('ROLE_ADMIN')) {
if (false === $user->isSuperAdmin() || true === $user->hasRole('ROLE_TEAM_ADMIN')) {
return;
}
$user->setSuperAdmin(false);
$this->logger->info('Revoke super admin flag along with ROLE_ADMIN', [
$this->logger->info('Revoke super admin flag along with ROLE_TEAM_ADMIN', [
'user_id' => $user->getId(),
'user_email' => $user->getEmail(),
]);
+21 -2
View File
@@ -56,11 +56,30 @@ class BpnImportCommand extends Command
return Command::FAILURE;
}
// Resolve the BusProNet base data once, before touching a single row. A destination
// cannot be written without its hotel, so an unavailable hotel list would otherwise
// skip every record and still report success. Holding both lists locally also keeps
// the loop off the providers: on a failed fetch nothing is cached, and a per-lookup
// ->get() would re-open the socket for every pickup of all 174 files.
$hotels = $this->hotelDataProvider->getAll();
$pickups = $this->pickupDataProvider->getAll();
if ([] === $hotels) {
$io->error('Hotel-Stammdaten konnten nicht von BusProNet geladen werden Import abgebrochen.');
$this->logger->error('BPN import aborted: hotel base data unavailable');
return Command::FAILURE;
}
$io->info('Found '.$totalCount.' XML files');
$addedCount = 0;
$updatedCount = 0;
$warnings = [];
if ([] === $pickups) {
$warnings[] = 'Zustiegs-Stammdaten konnten nicht von BusProNet geladen werden';
}
$progressBar = $io->createProgressBar($totalCount);
$progressBar->setFormat(" %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%\n %message%");
$progressBar->setMessage('Starting');
@@ -92,7 +111,7 @@ class BpnImportCommand extends Command
foreach ($destinationXml->xpath('zustiege/zustieg') as $pickupXml) {
$pickupBusProId = (int) $pickupXml->attributes()['idbuspro'];
if ($pickupBusProId) {
if (null === $pickup = $this->pickupDataProvider->get($pickupBusProId)) {
if (null === $pickup = $pickups[$pickupBusProId] ?? null) {
$warnings[] = 'Pickup with busProId '.$pickupBusProId.' not found';
continue;
}
@@ -109,7 +128,7 @@ class BpnImportCommand extends Command
// Iterate over all hotel entries
foreach ($destinationXml->xpath('hotel') as $hotelXml) {
$hotelBusProId = (int) $hotelXml->attributes()['idbuspro'];
if (null === $hotel = $this->hotelDataProvider->get($hotelBusProId)) {
if (null === $hotel = $hotels[$hotelBusProId] ?? null) {
$warnings[] = 'Hotel with busProId '.$hotelBusProId.' not found';
continue;
}
+2 -4
View File
@@ -91,10 +91,8 @@ class TeamerStatusCommand extends Command
$qb->expr()->eq('disposition.status', ':disposition_status')
))
->groupBy('teamer.id')
->setParameters([
'teamer_status' => Teamer::STATUS_NEW,
'disposition_status' => Disposition::STATUS_COMPLETED,
])
->setParameter('teamer_status', Teamer::STATUS_NEW)
->setParameter('disposition_status', Disposition::STATUS_COMPLETED)
->getQuery()
->getResult()
;
@@ -16,7 +16,7 @@ class UserController extends AbstractController
}
#[Route('/admin/autocomplete/user', name: 'app_admin_autocomplete_user')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): JsonResponse
{
try {
@@ -22,7 +22,7 @@ class ApproveController extends AbstractController
}
#[Route('/admin/feedback/approve/{uuid}', name: 'app_admin_feedback_approve')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Feedback $feedback, Request $request): Response
{
$form = $this->createForm(FeedbackApproveType::class, $feedback);
@@ -24,7 +24,7 @@ class ProvideController extends AbstractController
}
#[Route('/admin/feedback/provide', name: 'app_admin_feedback_provide')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$form = $this->getFeedbackForm();
@@ -58,7 +58,7 @@ class ProvideController extends AbstractController
}
#[Route('/admin/feedback/provide/form', name: 'app_admin_feedback_provide_form')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function form(Request $request): Response
{
$form = $this->getFeedbackForm();
+1 -1
View File
@@ -21,7 +21,7 @@ class IndexController extends AbstractController
}
#[Route('/admin', name: 'app_admin_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
$applicationRepository = $this->entityManager->getRepository(Application::class);
+1 -1
View File
@@ -20,7 +20,7 @@ class IndexController extends AbstractController
}
#[Route('/admin/log', name: 'app_admin_log_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$qb = $this
@@ -22,7 +22,7 @@ class CreateController extends AbstractController
}
#[Route('/admin/system/availability/create', name: 'app_admin_system_availability_create')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$availability = new Availability();
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
}
#[Route('/admin/system/availability/delete/{id}', name: 'app_admin_system_availability_delete', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Availability $availability, Request $request): Response
{
if (true === $request->isMethod('POST')) {
@@ -22,7 +22,7 @@ class DuplicateController extends AbstractController
}
#[Route('/admin/system/availability/duplicate/{id}', name: 'app_admin_system_availability_duplicate')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Availability $availability, Request $request): Response
{
$copy = Availability::duplicate($availability);
@@ -22,7 +22,7 @@ class EditController extends AbstractController
}
#[Route('/admin/system/availability/edit/{id}', name: 'app_admin_system_availability_edit')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Availability $availability, Request $request): Response
{
$form = $this->createForm(AvailabilityType::class, $availability);
@@ -19,7 +19,7 @@ class IndexController extends AbstractController
}
#[Route('/admin/system/availability', name: 'app_admin_system_availability_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$query = $this
@@ -37,13 +37,13 @@ class CreateController extends AbstractController
/** @var User $user */
$user = $this->getUser();
$photo = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_PHOTO);
$photo = Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_PHOTO)->first(), $user, Upload::TYPE_PHOTO);
$contact->setPhoto($photo);
$this->entityManager->flush();
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_PHOTO, $uploadSession);
$this->uploadHandler->destroyUploadSession();
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_PHOTO);
}
$form = $this->createForm(ContactType::class, $contact, ['upload_session' => $uploadSession]);
@@ -34,14 +34,14 @@ class EditController extends AbstractController
if (true === $request->isMethod('POST') && 0 < $uploadSession->getCount()) {
/** @var User $user */
$user = $this->getUser();
$photo = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_PHOTO);
$photo = Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_PHOTO)->first(), $user, Upload::TYPE_PHOTO);
if (null !== $existingPhoto = $contact->getPhoto()) {
$this->entityManager->remove($existingPhoto);
}
$contact->setPhoto($photo);
$this->entityManager->flush();
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_PHOTO, $uploadSession);
$this->uploadHandler->destroyUploadSession();
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_PHOTO);
}
$form = $this->createForm(ContactType::class, $contact, ['upload_session' => $uploadSession]);
@@ -28,7 +28,7 @@ class EditController extends AbstractController
}
#[Route('/admin/system/email-text/edit/{key}', name: 'app_admin_system_email_text_edit')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(EmailTextKey $key, Request $request): Response
{
$definition = $this->catalog->get($key);
@@ -18,7 +18,7 @@ class IndexController extends AbstractController
}
#[Route('/admin/system/email-text', name: 'app_admin_system_email_text_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
// The list is driven by the catalogue, not by the table: a mail that has never
@@ -25,7 +25,7 @@ class PreviewController extends AbstractController
* a real mail is being assembled.
*/
#[Route('/admin/system/email-text/preview/{key}', name: 'app_admin_system_email_text_preview')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(EmailTextKey $key): Response
{
$definition = $this->catalog->get($key);
@@ -27,7 +27,7 @@ class PreviewDraftController extends AbstractController
* the submitted values instead of the stored ones.
*/
#[Route('/admin/system/email-text/preview-draft/{key}', name: 'app_admin_system_email_text_preview_draft', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(EmailTextKey $key, Request $request): Response
{
$definition = $this->catalog->get($key);
@@ -25,7 +25,7 @@ class ResetController extends AbstractController
}
#[Route('/admin/system/email-text/reset/{key}', name: 'app_admin_system_email_text_reset')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(EmailTextKey $key, Request $request): Response
{
$emailText = $this->emailTextRepository->findByKey($key);
@@ -22,7 +22,7 @@ class CreateController extends AbstractController
}
#[Route('/admin/system/fee/create', name: 'app_admin_system_fee_create')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$fee = new Fee();
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
}
#[Route('/admin/system/fee/delete/{id}', name: 'app_admin_system_fee_delete')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Fee $fee, Request $request): Response
{
if (true === $request->isMethod('POST')) {
@@ -22,7 +22,7 @@ class DuplicateController extends AbstractController
}
#[Route('/admin/system/fee/duplicate/{id}', name: 'app_admin_system_fee_duplicate')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Fee $fee, Request $request): Response
{
$copy = Fee::duplicate($fee);
@@ -22,7 +22,7 @@ class EditController extends AbstractController
}
#[Route('/admin/system/fee/edit/{id}', name: 'app_admin_system_fee_edit')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Fee $fee, Request $request): Response
{
$form = $this->createForm(FeeType::class, $fee);
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
}
#[Route('/admin/system/fee', name: 'app_admin_system_fee_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
$fees = $this->feeRepository->getList();
@@ -21,7 +21,7 @@ class CreateController extends AbstractController
}
#[Route('/admin/system/feedback-set/create', name: 'app_admin_system_feedback_set_create')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$feedbackSet = new FeedbackSet();
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
}
#[Route('/admin/system/feedback-set/delete/{id}', name: 'app_admin_system_feedback_set_delete')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
#[IsGranted('DELETE', subject: 'feedbackSet')]
public function index(FeedbackSet $feedbackSet, Request $request): Response
{
@@ -21,7 +21,7 @@ class EditController extends AbstractController
}
#[Route('/admin/system/feedback-set/edit/{id}', name: 'app_admin_system_feedback_set_edit')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(FeedbackSet $feedbackSet, Request $request): Response
{
$form = $this->createForm(FeedbackSetType::class, $feedbackSet);
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
}
#[Route('/admin/system/feedback-set', name: 'app_admin_system_feedback_set_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
$feedbackSets = $this
@@ -21,7 +21,7 @@ class CreateController extends AbstractController
}
#[Route('/admin/system/job-profile/create', name: 'app_admin_system_job_profile_create')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$jobProfile = new JobProfile();
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
}
#[Route('/admin/system/job-profile/delete/{id}', name: 'app_admin_system_job_profile_delete')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(JobProfile $jobProfile, Request $request): Response
{
if (true === $request->isMethod('POST')) {
@@ -21,7 +21,7 @@ class EditController extends AbstractController
}
#[Route('/admin/system/job-profile/edit/{id}', name: 'app_admin_system_job_profile_edit')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(JobProfile $jobProfile, Request $request): Response
{
$form = $this->createForm(JobProfileType::class, $jobProfile);
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
}
#[Route('/admin/system/job-profile', name: 'app_admin_system_job_profile_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
$jobProfiles = $this
@@ -22,7 +22,7 @@ class CreateController extends AbstractController
}
#[Route('/admin/system/training/create', name: 'app_admin_system_training_create')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$training = new Training();
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
}
#[Route('/admin/system/training/delete/{id}', name: 'app_admin_system_training_delete')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Training $training, Request $request): Response
{
if (true === $request->isMethod('POST')) {
@@ -22,7 +22,7 @@ class EditController extends AbstractController
}
#[Route('/admin/system/training/edit/{id}', name: 'app_admin_system_training_edit')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Training $training, Request $request): Response
{
$form = $this->createForm(TrainingType::class, $training);
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
}
#[Route('/admin/system/training', name: 'app_admin_system_training_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
$trainings = $this
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
}
#[Route('/admin/system/user', name: 'app_admin_system_user_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
$users = $this->userRepository->getAdministrativeUsers();
@@ -11,7 +11,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class CrmSelectionsController extends AbstractController
{
#[Route('/admin/teamer/crm-selections/{uuid}', name: 'app_admin_teamer_crm_selections')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Teamer $teamer): Response
{
return $this->render('admin/teamer/crm_selections.html.twig', [
@@ -23,7 +23,7 @@ class DeleteAccountController extends AbstractController
}
#[Route('/admin/teamer/delete-account/{uuid}', name: 'app_admin_teamer_delete_account')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Teamer $teamer, Request $request): Response
{
if (true === $request->isMethod(Request::METHOD_POST)) {
@@ -44,7 +44,7 @@ class DeleteAccountController extends AbstractController
}
#[Route('/admin/teamer/restore-account/{uuid}', name: 'app_admin_teamer_restore_account')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function restore(Teamer $teamer, Request $request): Response
{
if (true === $request->isMethod(Request::METHOD_POST)) {
@@ -22,7 +22,7 @@ class DisableUserController extends AbstractController
}
#[Route('/admin/teamer/disable-user/{uuid}', name: 'app_admin_teamer_disable_user')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Teamer $teamer, Request $request): Response
{
$user = $teamer->getUser();
@@ -50,7 +50,7 @@ class DisableUserController extends AbstractController
}
#[Route('/administrative/teamer/enable-user/{uuid}', name: 'app_admin_teamer_enable_user')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function enable(Teamer $teamer, Request $request): Response
{
$user = $teamer->getUser();
@@ -39,7 +39,7 @@ class MailingController extends AbstractController
* the real send goes through the confirmation modal below.
*/
#[Route('/admin/teamer/mailing', name: 'app_admin_teamer_mailing')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$form = $this->createMailingForm($request);
@@ -82,7 +82,7 @@ class MailingController extends AbstractController
* one that was parked.
*/
#[Route('/admin/teamer/mailing/draft', name: 'app_admin_teamer_mailing_draft', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function draft(Request $request): Response
{
$mailingDto = $this->createMailingForm($request)->getData();
@@ -93,7 +93,7 @@ class MailingController extends AbstractController
}
#[Route('/admin/teamer/mailing/discard', name: 'app_admin_teamer_mailing_discard')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function discard(): Response
{
$this->draftHandler->resetDraft();
@@ -102,7 +102,7 @@ class MailingController extends AbstractController
}
#[Route('/admin/teamer/mailing/confirm', name: 'app_admin_teamer_mailing_confirm', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function confirm(Request $request): Response
{
$form = $this->createMailingForm($request);
@@ -119,7 +119,7 @@ class MailingController extends AbstractController
}
#[Route('/admin/teamer/mailing/send', name: 'app_admin_teamer_mailing_send', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function send(Request $request): Response
{
$form = $this->createMailingForm($request);
@@ -21,7 +21,7 @@ class RemarksController extends AbstractController
}
#[Route('/admin/teamer/remarks/{uuid}', name: 'app_admin_teamer_remarks_internal')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Teamer $teamer, Request $request): Response
{
$returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index');
@@ -25,7 +25,7 @@ class SkillsController extends AbstractController
}
#[Route('/admin/teamer/skills/{uuid}', name: 'app_admin_teamer_skills')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Teamer $teamer, Request $request): Response
{
$trainings = $this->trainingRepository->getList();
@@ -34,7 +34,7 @@ class CreateController extends AbstractController
path: '/admin/teamer/skills/training-attendance/create/{training_id}/{teamer_id}',
name: 'app_admin_teamer_skills_training_attendance_create'
)]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(
#[MapEntity(mapping: ['training_id' => 'id'])]
Training $training,
@@ -30,7 +30,7 @@ class DeleteController extends AbstractController
path: '/admin/teamer/skills/training-attendance/delete/{uuid}',
name: 'app_admin_teamer_skills_training_attendance_delete'
)]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(TrainingAttendance $attendance, Request $request): Response
{
if (true === $request->isMethod('POST')) {
@@ -25,7 +25,7 @@ class CallOffController extends AbstractController
}
#[Route('/administrative/assignment/call-off/{uuid}', name: 'app_administrative_assignment_call_off')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
#[IsGranted('CALL_OFF', subject: 'assignment')]
public function index(Assignment $assignment, Request $request): Response
{
if (true === $request->isMethod('POST')) {
@@ -11,6 +11,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class PublishController extends AbstractController
{
@@ -23,6 +24,7 @@ class PublishController extends AbstractController
}
#[Route('/administrative/assignment/publish/{uuid}', name: 'app_administrative_assignment_publish')]
#[IsGranted('PUBLISH', subject: 'assignment')]
public function index(Assignment $assignment, Request $request): Response
{
$returnUrl = $this->getReturnUrl($request, 'app_administrative_assignment_index');
@@ -89,7 +89,7 @@ class DocumentUploadController extends AbstractController
$uploadSession = $this->uploadHandler->getUploadSession();
$upload = Upload::fromUploadDto(
$uploadSession->getUploads()->first(),
$uploadSession->getUploadsByType($type)->first(),
$teamer->getUser(),
$type
);
@@ -113,7 +113,7 @@ class DocumentUploadController extends AbstractController
$this->entityManager->flush();
$this->uploadHandler->moveUploadSessionFilesFromOrphanage($type, $uploadSession);
$this->uploadHandler->destroyUploadSession();
$this->uploadHandler->removeUploadSessionUploadsByType($type);
if (true === $this->featureManager->isActive('sanitize_uploads')) {
$this->uploadHandler->ensurePdf($upload);
@@ -94,6 +94,9 @@ class CheckController extends AbstractController
return $this->render('administrative/document/modal_check.html.twig', [
'document' => $document,
'form' => $form->createView(),
'receipts' => Upload::TYPE_INVOICE === $document->getType()
? $document->getDisposition()?->getDocumentsByType(Upload::TYPE_RECEIPT)
: null,
]);
}
@@ -117,6 +120,25 @@ class CheckController extends AbstractController
$this->entityManager->flush();
}
private function removeReceipts(Upload $document): void
{
$receipts = $document->getDisposition()?->getDocumentsByType(Upload::TYPE_RECEIPT);
if (null === $receipts || 0 === $receipts->count()) {
return;
}
foreach ($receipts as $receipt) {
$this->entityManager->remove($receipt);
}
$this->logger->info('Delete receipts of accepted invoice', [
'document_id' => $document->getId(),
'disposition_id' => $document->getDisposition()->getId(),
'count' => $receipts->count(),
]);
}
private function confirmDocument(Upload $document, string $transition, string $status, DocumentCheckDto $formData): void
{
/** @var User $user */
@@ -147,6 +169,14 @@ class CheckController extends AbstractController
if (true === $this->workflow->can($document->getDisposition(), $transition)) {
$this->workflow->apply($document->getDisposition(), $transition);
// Belege only have to survive until the Honorarnote is accepted. Removing them here
// and not before the guard means a blocked confirmation - which still persists the
// new document status below - cannot destroy the evidence for an invoice that was in
// fact not approved. DeleteUploadListener removes the files from disk on preRemove.
if (Upload::TYPE_INVOICE === $document->getType() && Upload::STATUS_PAID === $status) {
$this->removeReceipts($document);
}
} else {
$blockers = $this->workflow->buildTransitionBlockerList($document->getDisposition(), $transition);
foreach ($blockers as $blocker) {
@@ -13,6 +13,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class EditController extends AbstractController
{
@@ -27,6 +28,7 @@ class EditController extends AbstractController
}
#[Route('/administrative/destination/edit/{id}', name: 'app_administrative_system_destination_edit')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Destination $destination, Request $request): Response
{
$destinationDto = DestinationDto::fromEntity($destination);
@@ -32,10 +32,10 @@ class ReplaceController extends AbstractController
if (true === $request->isMethod('POST') && 0 < $uploadSession->getCount()) {
/** @var User $user */
$user = $this->getUser();
Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_DOCUMENT, $upload);
Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_DOCUMENT)->first(), $user, Upload::TYPE_DOCUMENT, $upload);
$this->entityManager->flush();
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_DOCUMENT, $uploadSession);
$this->uploadHandler->destroyUploadSession();
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_DOCUMENT);
}
$filenameBefore = $upload->getOriginalFilename();
@@ -39,14 +39,14 @@ class UploadController extends AbstractController
if (true === $request->isMethod('POST') && 0 < $uploadSession->getCount()) {
/** @var User $user */
$user = $this->getUser();
foreach ($uploadSession->getUploads() as $upload) {
foreach ($uploadSession->getUploadsByType(Upload::TYPE_DOCUMENT) as $upload) {
$document = Upload::fromUploadDto($upload, $user, Upload::TYPE_DOCUMENT);
$uploadedDocuments[] = $document;
$this->entityManager->persist($document);
}
$this->entityManager->flush();
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_DOCUMENT, $uploadSession);
$this->uploadHandler->destroyUploadSession();
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_DOCUMENT);
}
if ($form->isSubmitted() && $form->isValid()) {
@@ -32,6 +32,7 @@ class IndexController extends AbstractController
}
#[Route('/administrative/system/faq/sort', name: 'app_administrative_system_faq_sort', methods: ['POST'])]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function sort(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
@@ -55,6 +55,7 @@ class ApplicationFilterController extends AbstractController
}
#[Route('/common/application/filter/reset', name: 'app_common_application_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
@@ -53,6 +53,7 @@ class AssignmentFilterController extends AbstractController
}
#[Route('/common/assignment/filter/reset', name: 'app_common_assignment_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
@@ -47,6 +47,7 @@ class DocumentFilterController extends AbstractController
}
#[Route('/common/document/filter/reset', name: 'app_common_document_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
@@ -39,6 +39,7 @@ class FeedbackFilterController extends AbstractController
}
#[Route('/common/feedback/filter/reset', name: 'app_common_feedback_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
@@ -42,6 +42,7 @@ class TeamerFilterController extends AbstractController
}
#[Route('/common/teamer/filter/reset', name: 'app_common_teamer_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
@@ -52,6 +52,7 @@ class TimelineFilterController extends AbstractController
}
#[Route('/common/timeline/filter/reset', name: 'app_common_timeline_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
@@ -8,6 +8,7 @@ use App\Repository\UploadRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController
{
@@ -18,6 +19,7 @@ class IndexController extends AbstractController
}
#[Route('/management', name: 'app_manager_index')]
#[IsGranted('ROLE_MANAGER')]
public function index(): Response
{
$dispositions = $this->dispositionRepository->getNew();
+3 -4
View File
@@ -22,10 +22,9 @@ class OAuth2Controller extends AbstractController
{
$this->denyUnlessFeatureIsActive();
$provider = $this->client->getProvider();
$url = $provider->getAuthorizationUrl();
$state = $provider->getState();
$request->getSession()->set('oauth2state', $state);
// the state and the PKCE verifier belong to the client, which is what consumes
// them again on the callback
$url = $this->client->createAuthorizationUrl($request);
return $this->redirect($url);
}
@@ -133,13 +133,13 @@ class DriverLicenseController extends AbstractController
$this->entityManager->remove($existingUpload);
}
$upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_DRIVER_LICENSE);
$upload = Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_DRIVER_LICENSE)->first(), $user, Upload::TYPE_DRIVER_LICENSE);
$upload->setStatus(Upload::STATUS_NEW);
$teamer->setDriverLicenseUpload($upload);
$this->entityManager->flush();
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_DRIVER_LICENSE, $uploadSession);
$this->uploadHandler->destroyUploadSession();
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_DRIVER_LICENSE);
}
}
@@ -7,6 +7,7 @@ use App\Repository\ContactRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ContactController extends AbstractController
{
@@ -15,6 +16,7 @@ class ContactController extends AbstractController
}
#[Route('/teamer/contact', name: 'app_teamer_contact')]
#[IsGranted('ROLE_USER')]
public function index(): Response
{
$allContacts = $this
@@ -86,14 +86,18 @@ class DetailController extends AbstractController
/** @var User $user */
$user = $this->getUser();
$upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_CONTRACT);
$upload = Upload::fromUploadDto(
$uploadSession->getUploadsByType(Upload::TYPE_CONTRACT)->first(),
$user,
Upload::TYPE_CONTRACT
);
$upload->setStatus(Upload::STATUS_NEW);
$disposition->addDocument($upload);
$this->workflow->apply($disposition, 'upload_contract');
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_CONTRACT, $uploadSession);
$this->uploadHandler->destroyUploadSession();
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_CONTRACT);
if (true === $this->featureManager->isActive('sanitize_uploads')) {
$this->uploadHandler->ensurePdf($upload);
@@ -119,14 +123,18 @@ class DetailController extends AbstractController
/** @var User $user */
$user = $this->getUser();
$upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_INVOICE);
$upload = Upload::fromUploadDto(
$uploadSession->getUploadsByType(Upload::TYPE_INVOICE)->first(),
$user,
Upload::TYPE_INVOICE
);
$upload->setStatus(Upload::STATUS_NEW);
$disposition->addDocument($upload);
$this->workflow->apply($disposition, 'upload_invoice');
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_INVOICE, $uploadSession);
$this->uploadHandler->destroyUploadSession();
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_INVOICE);
if (true === $this->featureManager->isActive('sanitize_uploads')) {
$this->uploadHandler->ensurePdf($upload);
@@ -0,0 +1,156 @@
<?php
namespace App\Controller\Teamer\Disposition;
use App\Entity\Disposition;
use App\Entity\Upload;
use App\Entity\User;
use App\Htmx\HxRedirectResponse;
use App\Model\UploadDto;
use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Flagception\Manager\FeatureManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Belege backing a Honorarnote.
*
* Deliberately separate from DetailController and from the disposition workflow: a receipt is
* evidence for the invoice, never a document in its own right. It is not approved or rejected, it
* carries no status, and it applies no transition - which is what lets a teamer keep managing
* receipts while the invoice is being checked, a phase in which the state machine offers no
* transition at all. The window is guarded by the MANAGE_RECEIPTS attribute on the disposition.
*/
class ReceiptController extends AbstractController
{
public function __construct(
private readonly UploadHandler $uploadHandler,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly FeatureManagerInterface $featureManager,
) {
}
#[Route('/teamer/disposition/receipt/{uuid}', name: 'app_teamer_disposition_receipt_upload')]
#[IsGranted('ROLE_TEAMER')]
#[IsGranted('MANAGE_RECEIPTS', subject: 'disposition')]
public function upload(Disposition $disposition, Request $request): Response
{
// Dummy form without fields, as elsewhere: the files travel in the upload session, not in
// the form itself.
$form = $this->createFormBuilder()->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$count = $this->processUpload($disposition);
$this->addFlash(
'success',
1 === $count ? 'Der Beleg wurde hochgeladen' : 'Die Belege wurden hochgeladen'
);
return new HxRedirectResponse($this->generateUrl('app_teamer_disposition_detail', [
'uuid' => $disposition->getUuid(),
'r' => $request->query->get('r'),
]));
}
return $this->render('teamer/disposition/modal_receipt_upload.html.twig', [
'disposition' => $disposition,
'form' => $form->createView(),
]);
}
#[Route('/teamer/disposition/receipt/delete/{uuid}', name: 'app_teamer_disposition_receipt_delete')]
#[IsGranted('ROLE_TEAMER')]
#[IsGranted('DELETE', subject: 'receipt')]
public function delete(Upload $receipt, Request $request): Response
{
if (Upload::TYPE_RECEIPT !== $receipt->getType()) {
throw $this->createNotFoundException('Not a receipt');
}
// UploadVoter::DELETE only asserts ownership, which would still hold once the invoice has
// been accepted. The window itself is the disposition's to answer.
$this->denyAccessUnlessGranted('MANAGE_RECEIPTS', $receipt->getDisposition());
if (true === $request->isMethod('POST')) {
$disposition = $receipt->getDisposition();
// Doctrine's DeleteUploadListener removes the file from disk on preRemove.
$this->entityManager->remove($receipt);
$this->entityManager->flush();
$this->addFlash('success', 'Der Beleg wurde gelöscht');
$this->logger->info('Delete receipt', [
'disposition_id' => $disposition->getId(),
'receipt_filename' => $receipt->getOriginalFilename(),
'owner' => $receipt->getOwner()->getFullName(),
]);
return new HxRedirectResponse($this->generateUrl('app_teamer_disposition_detail', [
'uuid' => $disposition->getUuid(),
'r' => $request->query->get('r'),
]));
}
return $this->render('teamer/disposition/modal_receipt_delete.html.twig', [
'receipt' => $receipt,
]);
}
private function processUpload(Disposition $disposition): int
{
/** @var User $user */
$user = $this->getUser();
$uploadSession = $this->uploadHandler->getUploadSession();
// Only this dropzone's files: on the detail page in place "ended" the invoice dropzone is
// live at the same time and shares the one session.
$uploadDtos = $uploadSession->getUploadsByType(Upload::TYPE_RECEIPT);
$uploads = [];
/** @var UploadDto $uploadDto */
foreach ($uploadDtos as $uploadDto) {
$upload = Upload::fromUploadDto($uploadDto, $user, Upload::TYPE_RECEIPT);
// No status on purpose. UploadVoter grants CHECK only for "new" and "pending", so a
// null status is what keeps a receipt out of the checking process by construction.
$upload->setStatus(null);
$disposition->addDocument($upload);
$this->entityManager->persist($upload);
$uploads[] = $upload;
}
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_RECEIPT, $uploadSession);
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_RECEIPT);
if (true === $this->featureManager->isActive('sanitize_uploads')) {
foreach ($uploads as $upload) {
$this->uploadHandler->ensurePdf($upload);
}
}
$this->entityManager->flush();
$this->logger->info('Upload receipts', [
'user' => $user->getUserIdentifier(),
'disposition_id' => $disposition->getId(),
'count' => count($uploads),
]);
// No DocumentUploadedEvent: its listener mails the managers about a document waiting to be
// checked, which is exactly what a receipt is not.
return count($uploads);
}
}
@@ -96,11 +96,11 @@ class IndexController extends AbstractController
$this->entityManager->remove($existingUpload);
}
$upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_PHOTO);
$upload = Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_PHOTO)->first(), $user, Upload::TYPE_PHOTO);
$teamer->setPhoto($upload);
$this->entityManager->flush();
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_PHOTO, $uploadSession);
$this->uploadHandler->destroyUploadSession();
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_PHOTO);
$this->addFlash('success', 'Dein Profilbild wurde aktualisiert');
$this->logger->info('Update teamer photo', [
@@ -73,11 +73,11 @@ class AddController extends AbstractController
private function updateCertificate(User $user, License $license, UploadSessionDto $uploadSession): void
{
$upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_CERTIFICATE);
$upload = Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_CERTIFICATE)->first(), $user, Upload::TYPE_CERTIFICATE);
$license->setCertificate($upload);
$this->entityManager->flush();
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_CERTIFICATE, $uploadSession);
$this->uploadHandler->destroyUploadSession();
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_CERTIFICATE);
$this->addFlash('success', 'Der Nachweis wurde hochgeladen');
}
+1 -1
View File
@@ -75,7 +75,7 @@ class Contact
public function setEmail(string $email): static
{
$this->email = $email;
$this->email = mb_strtolower(trim($email));
return $this;
}
+14
View File
@@ -188,6 +188,20 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
return null;
}
/**
* Unlike getDocumentByType() this returns every match, for the types a disposition can hold
* more than one of. Belege are the only such type today: there is exactly one Honorarvertrag
* and one Honorarnote, but any number of receipts backing the latter.
*
* @return Collection<int, Upload>
*/
public function getDocumentsByType(string $type): Collection
{
return $this->documents->filter(function (Upload $upload) use ($type) {
return $type === $upload->getType();
});
}
public function addDocument(Upload $document): static
{
if (!$this->documents->contains($document)) {
+1 -1
View File
@@ -108,7 +108,7 @@ class Communication
public function setEmail(?string $email): static
{
$this->email = $email;
$this->email = null === $email ? null : mb_strtolower(trim($email));
return $this;
}
+1 -1
View File
@@ -95,7 +95,7 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
private ?string $remarksInternal = null;
#[ORM\ManyToMany(targetEntity: Availability::class, inversedBy: 'teamers')]
#[ORM\OrderBy(['dateFrom' => 'ASC'])]
#[ORM\OrderBy(['dateFrom' => \SortDirection::Ascending])]
private Collection $availabilities;
#[ORM\OneToMany(mappedBy: 'owner', targetEntity: Availability::class)]
+2
View File
@@ -22,6 +22,7 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
public const TYPE_INVOICE = 'invoice';
public const TYPE_DOCUMENT = 'document';
public const TYPE_DRIVER_LICENSE = 'driver_license';
public const TYPE_RECEIPT = 'receipt';
public const STATUS_NEW = 'new';
public const STATUS_PENDING = 'pending';
@@ -135,6 +136,7 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
self::TYPE_INVOICE => 'Honorarnote',
self::TYPE_DOCUMENT => 'Info',
self::TYPE_DRIVER_LICENSE => 'Führerschein',
self::TYPE_RECEIPT => 'Beleg',
default => 'Dokument',
};
}
+7 -7
View File
@@ -22,7 +22,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
* Assignable roles and their labels.
*/
public const ROLES = [
'ROLE_ADMIN' => 'Admin',
'ROLE_TEAM_ADMIN' => 'Team Admin',
'ROLE_MANAGER' => 'Reisemanager',
'ROLE_HOUSE_MANAGER' => 'Hausleitung',
'ROLE_TEAMER' => 'Teamer',
@@ -35,7 +35,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
* a super admin.
*/
public const PENDING_ROLES = [
'ROLE_ADMIN' => 'ROLE_ADMIN_PENDING',
'ROLE_TEAM_ADMIN' => 'ROLE_TEAM_ADMIN_PENDING',
'ROLE_MANAGER' => 'ROLE_MANAGER_PENDING',
'ROLE_HOUSE_MANAGER' => 'ROLE_HOUSE_MANAGER_PENDING',
];
@@ -194,7 +194,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
$roles = ['ROLE_USER', ...$this->roles];
if (true === $this->isSuperAdmin()) {
$roles[] = 'ROLE_SUPER_ADMIN';
$roles[] = 'ROLE_TEAM_SUPER_ADMIN';
}
return array_unique($roles);
@@ -223,7 +223,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
/**
* The manually assignable roles held by the user, i.e. without the implicit ROLE_USER
* and ROLE_SUPER_ADMIN added by getRoles() and without any pending marker. Used to
* and ROLE_TEAM_SUPER_ADMIN added by getRoles() and without any pending marker. Used to
* edit role assignments: saving them resolves the pending approvals.
*/
public function getAssignedRoles(): array
@@ -283,12 +283,12 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
}
/**
* Super admin is an elevation of ROLE_ADMIN, never a standalone grant.
* Super admin is an elevation of ROLE_TEAM_ADMIN, never a standalone grant.
*/
#[Assert\Callback]
public function validateSuperAdmin(ExecutionContextInterface $context): void
{
if (true === $this->superAdmin && false === in_array('ROLE_ADMIN', $this->roles, true)) {
if (true === $this->superAdmin && false === in_array('ROLE_TEAM_ADMIN', $this->roles, true)) {
$context
->buildViolation('Nur Admins können zu Superadmins ernannt werden.')
->atPath('superAdmin')
@@ -311,7 +311,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
public function getDefaultRoute(): string
{
if ($this->hasRole('ROLE_ADMIN')) {
if ($this->hasRole('ROLE_TEAM_ADMIN')) {
return 'app_admin_index';
} elseif ($this->hasRole('ROLE_MANAGER')) {
return 'app_manager_index';
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Enum;
/**
* What became of an application that was hard deleted.
*
* Applications are removed on the way to a placement just as they are removed when they come
* to nothing, and the row is gone either way. Without this, staffing somebody would read as
* an application being thrown away, and "how many applications did we lose" would count every
* success along with every loss.
*/
enum ApplicationDeletionOutcome: string
{
/** It became a placement: a disposition was created from it in the same flush. */
case DISPOSED = 'disposed';
/** Nobody was staffed from it - withdrawn by the teamer, deleted by the office, or purged. */
case REMOVED = 'removed';
}
+2
View File
@@ -19,6 +19,7 @@ enum StatisticsEventName: string
case DISPOSITION_CALLED_OFF = 'disposition.called_off';
case DISPOSITION_DELETED = 'disposition.deleted';
case APPLICATION_CREATED = 'application.created';
case APPLICATION_DELETED = 'application.deleted';
public function label(): string
{
@@ -28,6 +29,7 @@ enum StatisticsEventName: string
self::DISPOSITION_CALLED_OFF => 'Einsatz abgesagt',
self::DISPOSITION_DELETED => 'Einteilung gelöscht',
self::APPLICATION_CREATED => 'Bewerbung eingegangen',
self::APPLICATION_DELETED => 'Bewerbung gelöscht',
};
}
}
+4 -1
View File
@@ -25,12 +25,15 @@ class UploadSessionListener
// Get original filename from Dropzone request
$originalFileName = $request->request->get('originalFilename');
// The mapping name, which is 1:1 with the Upload::TYPE_* values. Without it the session
// is a flat list and a page with two dropzones cannot tell its own files apart.
$upload = new UploadDto(
$uuid,
$uploadedFile->getFilename(),
$originalFileName,
$uploadedFile->getMimeType(),
$uploadedFile->getSize()
$uploadedFile->getSize(),
$event->getType()
);
$uploadSession = $this->uploadHandler->addUploadToSession($upload);
+1 -1
View File
@@ -118,7 +118,7 @@ class AssignmentType extends AbstractType
return $qb
->where($qb->expr()->like('user.roles', ':role'))
->setParameter('role', '%ROLE_MANAGER%')
->orderBy('user.firstName', 'ASC')
->orderBy('user.firstName', \SortDirection::Ascending)
;
},
])
+1 -1
View File
@@ -37,7 +37,7 @@ class JobProfileType extends AbstractType
'expanded' => true,
'query_builder' => function (EntityRepository $repository) {
return $repository->createQueryBuilder('job_profile')
->orderBy('job_profile.name', 'ASC')
->orderBy('job_profile.name', \SortDirection::Ascending)
;
},
])
+1 -1
View File
@@ -48,7 +48,7 @@ class TeamerFilterType extends AbstractType
'empty_label' => 'nicht filtern',
'query_builder' => function (EntityRepository $repository) {
return $repository->createQueryBuilder('job_profile')
->orderBy('job_profile.name', 'ASC');
->orderBy('job_profile.name', \SortDirection::Ascending);
},
])
->add('noTrainings', CheckboxType::class, [

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