Compare commits

...
190 Commits
Author SHA1 Message Date
fromme 3c34cfa76a feat: offer the questionnaire for placements ended within a rolling window 2026-11-10 09:23:23 +01:00
fromme 9d0cdd7f52 chore: update inline documentation of feedback config files 2026-11-08 14:41:38 +01:00
fromme 9583612fdc refactor: use english naming for the n8n assignments api 2026-11-07 09:33:54 +01:00
fromme 0bc77b7463 feat: expose winter placements to n8n over an api endpoint 2026-11-06 10:09:56 +01:00
fromme 4ff963d1e2 feat: let managers edit the staffing matrix 2026-11-05 17:18:26 +01:00
fromme f9d2ff084d feat: decide the staffing split per ski level 2026-11-05 17:04:24 +01:00
fromme 7aefe88610 chore: replace contract template pdf 2026-11-05 10:57:18 +01:00
fromme 09eea86183 chore: update project dependencies 2026-11-04 16:29:04 +01:00
fromme ddfaa4b440 chore: streamlined naming of feedback menu items 2026-10-28 11:23:21 +01:00
fromme d3b9b0611b chore: update project dependencies 2026-10-28 09:19:05 +01:00
fromme fe0c854f1d refactor: offer the questionnaire test from the assignment overview 2026-10-27 17:37:47 +01:00
fromme 0b736b51fb feat: calmer questionnaire form with selects and clickable answer rows 2026-10-27 17:32:41 +01:00
fromme af97468b2d feat: put the placement in one line above the questionnaire 2026-10-27 17:27:40 +01:00
fromme a0170f5952 refactor: split the questionnaire into teamer and bus feedback 2026-10-27 17:22:22 +01:00
fromme 2f4f9800dd fix: offer the questionnaire after every placement, the bus part to escorts 2026-10-27 17:12:24 +01:00
fromme 9fedc5b5d3 feat: let the office test the questionnaire on any assignment 2026-10-27 17:07:17 +01:00
fromme a35f91ac63 feat: list and show submitted questionnaires to the office 2026-10-27 17:02:51 +01:00
fromme 890e04f8a6 feat: remind teamers of open questionnaires on the dashboard 2026-10-27 16:59:44 +01:00
fromme 5e4e574076 feat: let teamers fill in and read their questionnaire 2026-10-27 16:56:49 +01:00
fromme 2a041d25a0 feat: let the teamer fill in the questionnaire after a bus escort 2026-10-27 16:53:23 +01:00
fromme df6734c939 feat: store questionnaire responses per disposition 2026-10-27 16:51:56 +01:00
fromme 8070fda64d feat: build the questionnaire form from its definition 2026-10-27 16:47:38 +01:00
fromme d06b647dac feat: decide when a conditional questionnaire question is shown 2026-10-27 16:43:14 +01:00
fromme a84275b40e feat: add questionnaire definitions and their catalog 2026-10-27 16:41:46 +01:00
fromme 2ec2a4c93f chore: fix typo in menu item 2026-10-20 17:14:41 +02:00
fromme de4aedd26e refactor: name the per-teamer disposition lists correctly 2026-10-20 17:14:13 +02:00
frommeandClaude Opus 5.5 5090f72fab fix: log a called-off disposition under its own id key
The disposition call-off logged the disposition's id as "assignment_id" and
set the status from the Assignment constant; both now use the disposition's
own name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 17:13:53 +02:00
fromme c00dd86eb4 feat: list upcoming season assignments among a teamer's dispositions 2026-10-20 17:13:45 +02:00
fromme 300bb2058f feat: apply season task deadlines to all open checklist entries 2026-10-20 16:52:45 +02:00
fromme 3fe510e64e fix: render form help texts smaller and muted 2026-10-20 16:52:32 +02:00
fromme 48691227cb chore: move menu entry to better suiting submenu 2026-10-20 16:34:12 +02:00
fromme 34ff630b75 chore: update project dependencies 2026-10-20 14:21:22 +02:00
fromme 3f806669c6 fix: show scrollbars only when an inner area overflows 2026-10-20 14:17:58 +02:00
fromme e31f607f66 chore: declare browser targets in .browserslistrc 2026-10-20 14:05:05 +02:00
fromme 8c53676133 feat: let the remaining textareas grow with their content 2026-10-20 14:04:55 +02:00
fromme 2de985b009 chore: upgrade webpack encore from 4.7 to 7.2 2026-10-20 14:04:43 +02:00
fromme 1cda9d378a chore: upgrade tailwind css from 3.4 to 4.3 2026-10-20 14:04:24 +02:00
fromme 9fcba26942 chore: bump node to 24 in ddev 2026-10-16 10:46:40 +02:00
fromme 13e5b6c3af feat: boost pagination and sorting links with htmx 2026-10-16 10:39:47 +02:00
fromme 493a43d952 fix: give the empty-field test case its own data provider 2026-10-16 10:12:30 +02:00
fromme 7493afde45 chore: update project dependencies 2026-10-16 10:06:22 +02:00
fromme d159cb212c feat: paginate the staffing matrix preview 2026-10-16 10:05:42 +02:00
fromme c7e0ad67e6 fix: put the guiding fee on one guide place only 2026-10-16 09:56:04 +02:00
fromme f0ad5374e9 docs: remove the myep api consumer guide 2026-10-16 09:15:04 +02:00
fromme 96d49fc488 docs: drop code references to local plan files 2026-10-16 09:14:44 +02:00
fromme 2a554bb7bf feat: stop keeping buspro password hash in the session 2026-10-16 09:00:00 +02:00
fromme c16e4bf53f feat: update profile in buspro without the teamer's password 2026-10-16 08:59:39 +02:00
fromme 18fb3382c6 feat: redirect to the deeplink after the form login 2026-10-16 08:38:05 +02:00
fromme 0e2021e96d refactor: derive the bus escort from the travel date 2026-10-16 08:36:54 +02:00
fromme 2fc9806c69 feat: generate draft assignments from a staffing matrix 2026-10-15 18:11:51 +02:00
fromme 3116d23199 fix: refuse deleting a document assigned to a season task 2026-10-15 13:02:13 +02:00
fromme 4aa2d7a5e6 fix: keep upload files until their deletion is committed 2026-10-15 12:49:48 +02:00
frommeandClaude Opus 5.5 7e182e3d51 feat: per-disposition feedback waiver
The overdue-feedback lists are a live query without stored state, so a
feedback nobody will provide any more could not be written off. The new
disposition.feedback_waived flag takes a placement out of the admin and
manager dashboards, the house-manager list and the reminder mail.

Statistics still count a waived placement as a missing feedback. The
flag has no UI yet and is set by SQL.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 13:53:25 +02:00
fromme c04d9e0faf chore: update project dependencies 2026-10-13 12:53:44 +02:00
fromme 3188f5f3da feat: additional 'add' button on top of list view 2026-10-13 12:52:28 +02:00
fromme 57781ba347 fix: teamer dashboard crashing on isContractDue() without deadline 2026-10-06 15:04:17 +02:00
fromme 849da8d471 chore: upgrade phpunit to 12.5 2026-10-01 10:07:05 +02:00
fromme da039d0527 fix: resolve the remaining deprecations reported by phpunit 2026-10-01 10:06:59 +02:00
fromme 1446211d22 chore: bump project to php 8.5 2026-10-01 09:55:34 +02:00
fromme 838b8944a2 feat: rearranged admin menu, proper gendering 2026-10-01 08:23:06 +02:00
fromme a4206f51eb chore: update project dependencies 2026-09-29 15:43:21 +02:00
fromme 2329fe17a0 fix: report an unresolvable hotel instead of failing on it 2026-09-29 15:27:44 +02:00
fromme 72be779038 chore: name the current import command in the probe's docblock 2026-09-29 15:27:34 +02:00
fromme d8ff148f86 fix: adopt changed export api url 2026-09-29 14:45:51 +02:00
fromme db529d2dc9 refactor: slim the hotel and country base-data models 2026-09-29 13:06:20 +02:00
fromme aa66c50979 refactor: delete the BusProNet base-data read path 2026-09-29 13:06:12 +02:00
fromme 0f76c5a7b6 feat: import destinations from the MyE&P travel export 2026-09-29 12:57:54 +02:00
fromme 5c7e702eab fix: accept the ISO-2 country codes when authoring a destination 2026-09-29 12:47:25 +02:00
fromme 169a17e19c chore: verify the ISO-2 country move and renumber its migration 2026-09-29 12:47:25 +02:00
fromme 6c0a512c05 fix: refuse the import while MyE&P reports no cost unit or country code 2026-09-29 11:08:04 +02:00
Björn Fromme 2e9328fc51 feat: read BusProNet base data from the MyE&P API 2026-09-29 11:08:04 +02:00
fromme 1505f9b50b chore: update project dependencies 2026-09-27 18:10:15 +02:00
fromme 392e67448e feat: order and format the pickup fields 2026-09-27 18:05:12 +02:00
fromme 8da9af46f5 chore: update project dependencies 2026-09-26 12:13:59 +02:00
fromme ca4a73eb4f chore: cleanup ddev config 2026-09-26 12:13:59 +02:00
fromme 1a7c272414 feat: import work hours from SaaS and exchange signed time sheet 2026-09-26 12:13:59 +02:00
fromme 7cf39850e6 feat: email recipient whitelist 2026-09-26 12:13:37 +02:00
fromme 4cab57d136 feat: additional functionality and layout adjustments for season teamers 2026-09-26 12:13:16 +02:00
fromme 80c6016da3 fix: replace only missing uploads with dummy document 2026-09-26 12:13:16 +02:00
fromme 69b8e549ee feat: gate the upload replacement file behind a feature flag 2026-09-26 12:13:16 +02:00
fromme 77986776c2 feat: send season deadline summary to the office address 2026-09-26 12:13:16 +02:00
Björn Fromme cf876ed224 feat: all-season assignments 2026-09-26 12:13:16 +02:00
fromme 675cec1b69 chore: rename menu entry and view title 2026-09-26 10:32:42 +02:00
fromme e460b18661 fix: accept the German W as a gender on the application endpoint 2026-09-26 10:25:48 +02:00
fromme 5141673bcd chore: drop the teamer provisioning feature flag 2026-09-25 14:51:39 +02:00
fromme b530679658 feat: provision a teamer from a received application 2026-09-25 14:36:24 +02:00
fromme d5f4b12f0a chore: rename local project to align with prod 2026-09-25 09:36:59 +02:00
fromme aa728ddc99 feat: answer received form submissions by mail 2026-09-24 16:35:31 +02:00
fromme 19364df2db feat: mark a library document for a specific purpose 2026-09-24 16:35:24 +02:00
fromme cb2d64f2cd feat: review received form submissions in the admin ui 2026-09-24 16:35:18 +02:00
fromme f073a2014d fix: keep the log processors working on a stateless firewall 2026-09-24 16:35:12 +02:00
fromme 30ad5e8488 feat: accept form submissions from the websites over an API endpoint 2026-09-24 16:35:06 +02:00
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
fromme 3d4d92b5c4 chore: update wording of maintenance mode message 2026-09-08 13:53:36 +02:00
fromme 7acfbb9d17 chore: update project dependencies 2026-09-08 13:03:53 +02:00
fromme 3af2e42ecf chore: cleanup obsolete environment variable 2026-09-08 12:56:21 +02:00
fromme 7ed5bcd29a feat: maintenance mode 2026-09-08 12:38:43 +02:00
fromme bc2af4d8e7 feat: switch to sftp source for buspro xml data 2026-09-04 12:12:05 +02:00
fromme 332406532f chore: prepare switching to sftp source for buspro xml data 2026-09-03 16:33:17 +02:00
fromme 357c43eead feat: import xml export from buspro over sftp 2026-09-03 10:39:38 +02:00
fromme df1e38b008 chore: fix deprecations after bumping doctrine 2026-09-01 15:46:14 +02:00
fromme 10ab2bd04b fix: doctrine native lazy objects 2026-09-01 13:58:30 +02:00
fromme a60e8357e8 fix: align php-versions between dev and prod 2026-09-01 12:52:15 +02:00
fromme 2459af122f feat: upgrade to symfony 7.4 2026-09-01 11:24:44 +02:00
fromme 8ed20db90c chore: merge historic migrations 2026-09-01 11:01:56 +02:00
fromme 496a707a16 feat: feedback export to excel
addresses #869dv9k9z
2026-09-01 10:20:31 +02:00
fromme 2f747fc22f fix: clear date fields of statistics views on filter reset 2026-09-01 09:53:33 +02:00
fromme 0d06bc4271 fix: drop status breakdown from applications-by-destination stats 2026-09-01 09:46:01 +02:00
fromme f6667dcfc7 feat: default statistics filter to a rolling 12-month window 2026-09-01 09:45:53 +02:00
fromme 410c99a9b0 chore: remove obsolete deployer rsync fix 2026-09-01 09:33:23 +02:00
fromme b2cc6e2dcf feat: applications by destination statistics
addresses #869dv9fh6
2026-09-01 09:28:41 +02:00
fromme e66351a2cc fix: correct label colors of percentage values in statistics 2026-09-01 09:11:43 +02:00
fromme 84441d9e69 feat: record disposition deleted event for statistics 2026-08-31 17:49:21 +02:00
Björn Fromme 53465cc66a fix: enforce lowercase email addresses 2026-08-31 17:11:13 +02:00
fromme 703d41d6ae feat: improve layout of assignment detail view 2026-08-31 17:06:09 +02:00
fromme 127ced4378 feat: remove obsolete date range override 2026-08-31 16:56:36 +02:00
fromme 86c5d23181 feat: teamer season disposition count in info modal
addresses #869dv94n4
2026-08-31 16:26:53 +02:00
fromme 39e6f35374 fix: cleanup stale teamer pickups 2026-08-31 15:56:12 +02:00
fromme e4d64558b9 feat: render called off dispositions stroke through in teamer info modal
addresses #869dv944g
2026-08-31 15:34:24 +02:00
fromme b2488e2ff4 feat: include jobprofile placeholder in email texts
addresses #869armp40
2026-08-31 15:30:22 +02:00
fromme 702f04d946 chore: update project dependencies 2026-08-31 15:27:49 +02:00
fromme 7691a8bb03 fix: adjust deployer config to prevent symlink loop issue with rsync 2026-08-31 14:22:06 +02:00
fromme 15a4af93f9 feat: collect teamers' meal preferences 2026-08-27 18:27:17 +02:00
fromme e7f3233dbc fix: type mismatches and handle nullable values 2026-08-27 10:16:03 +02:00
fromme c33c1b6bf0 feat: assignments with disabled formalities
addresses #869dnvm8c
2026-08-26 16:15:55 +02:00
fromme 5979988dcc chore: update project dependencies 2026-08-26 15:19:23 +02:00
fromme 2662d56ac1 feat: collect assignment and disposition events for statistics 2026-08-26 15:17:17 +02:00
fromme e210021fe3 fix: use correct event name in subscriber 2026-08-26 09:41:47 +02:00
Björn Fromme f3eae5462d fix: called-off dispositions no longer block teamers 2026-08-26 09:11:27 +02:00
Björn Fromme cc280907e5 feat: replace contract pdf template 2026-08-25 16:49:32 +02:00
Björn Fromme dfa3e63926 feat: link ratings widget with feedback list filtered by teamer
addresses #869at5mdu
2026-08-24 13:50:07 +02:00
Björn Fromme 43968e4e7b feat: selectable feedback author for administrative users
addresses #869at5nbp
2026-08-24 12:46:19 +02:00
Björn Fromme 4ae23bf8a1 feat: filter availabilities
addresses #869at5k4f
2026-08-24 12:22:34 +02:00
Björn Fromme 6795a668da chore: update project dependencies, fix deprecations 2026-08-24 09:34:49 +02:00
Björn Fromme e6e6e041a9 docs: include previously ignored documentation files 2026-08-24 09:34:33 +02:00
Björn Fromme c9de66df9c chore: update project dependencies 2026-08-20 14:04:08 +02:00
Björn Fromme 5912a75c81 feat: markdown in email body for mailing and transactional 2026-08-20 14:00:53 +02:00
Björn Fromme f0e850978b feat: bpn as single source of truth for role and hotel code assignments 2026-08-18 11:33:42 +02:00
Björn Fromme 1fd0fbc21e feat: map house manager roles and houses by crm ids instead of labels 2026-08-18 09:08:01 +02:00
Björn Fromme 25f5840085 feat: sort applications by creation timestamp by default 2026-08-17 17:18:42 +02:00
Björn Fromme 31149fdd13 fix: correctly calculate due dates for contract and invoice uploads 2026-08-17 12:49:36 +02:00
Björn Fromme 0e1df6da7c feat: instant preview of edited email template 2026-08-17 12:17:05 +02:00
Björn Fromme 489aafed3e fix: correctly apply due dates for contract and invoice upload reminders 2026-08-17 11:55:02 +02:00
Björn Fromme 3e08965e48 feat: admin editable email templates
addresses #869eg7ptr
2026-08-17 10:39:19 +02:00
Björn Fromme 804dab335f fix: adjust detection of overlapping applications
addresses #869ar02xk
2026-08-13 18:47:20 +02:00
Björn Fromme bdd06f0187 feat: additional categories for job profiles
addresses #869dv9m9g
2026-08-13 17:56:32 +02:00
Björn Fromme 644bfa225d feat: view and edit comments of uploaded documents
addresses #869eg7tv1
2026-08-13 17:20:17 +02:00
Björn Fromme 44fab8bf6c chore: reference for user roles and for worker operations 2026-08-12 17:55:37 +02:00
Björn Fromme 3b116add75 fix: stop MyE&P from granting roles and matching the wrong account 2026-08-12 17:55:26 +02:00
Björn Fromme 450f5b0515 feat: share the role policy across identity sources 2026-08-12 17:55:16 +02:00
Björn Fromme 8b09845d96 fix: repair the date of birth import in Teamer::fromUserinfo 2026-08-12 17:55:04 +02:00
Björn Fromme 642102263e fix: make the MyE&P login routes unreachable while the feature is off 2026-08-12 17:54:56 +02:00
Björn Fromme 07f0bf3f3e chore: add missing feature flag defaults to .env 2026-08-12 17:54:49 +02:00
Björn Fromme e0a9562fc6 feat: send mailings over MailJet smtp in dedicated worker process 2026-08-12 15:47:04 +02:00
Björn Fromme a5c98025d0 feat: feature flag for oauth2 login via MyE&P 2026-08-12 10:35:08 +02:00
Björn Fromme 74ebefcf65 feat: proper logging to dedicated audit channel 2026-08-12 10:34:41 +02:00
Björn Fromme 898a7c7505 feat: oauth2 login via myep 2026-08-12 08:14:13 +02:00
Björn Fromme 078ef8b9a6 chore: update frontend tooling 2026-08-11 16:27:39 +02:00
Björn Fromme 696d3f8798 fix: vertically align button icons with label 2026-08-11 16:26:19 +02:00
Björn Fromme 6511f821ac feat: mailing to filtered teamer list
addresses #869dv9bur
2026-08-11 16:25:53 +02:00
Björn Fromme 48cd2d6b67 fix: properly forward return urls where missing 2026-08-11 14:34:46 +02:00
Björn Fromme 102e1c1c3c fix: properly gate menus by role 2026-08-11 14:20:15 +02:00
Björn Fromme d654ce77fb feat: soft-delete for teamer accounts
addresses #869dv9br3
2026-08-11 12:26:13 +02:00
Björn Fromme 388ecf9603 feat: financial data check for teamers, improved enforced checks logic
addresses #869at5rtp
2026-08-10 18:06:25 +02:00
Björn Fromme fd5d478a5c feat: streamlined role-revocation logic 2026-08-10 16:24:41 +02:00
904 changed files with 64690 additions and 13267 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Tailwind 4's floor: browsers without cascade layers, or Chrome before 111, can't render the
# app's CSS, so the JS isn't transpiled for them either.
defaults and fully supports css-cascade-layers
not chrome < 111
+48 -33
View File
@@ -1,24 +1,26 @@
type: php type: symfony
docroot: public docroot: public
php_version: "8.3" php_version: "8.5"
webserver_type: apache-fpm webserver_type: apache-fpm
xdebug_enabled: false xdebug_enabled: false
additional_hostnames: [] additional_hostnames: []
additional_fqdns: [] additional_fqdns: []
database: database:
type: mysql type: mariadb
version: "8.4" version: "11.8"
hooks:
post-start:
- exec: mysql -uroot -proot -hdb -e "CREATE DATABASE IF NOT EXISTS db_test CHARACTER SET utf8mb4; GRANT ALL ON db_test.* TO 'db'@'%';"
- exec: php bin/console doctrine:schema:update --force --env=test --no-interaction
omit_containers: [ddev-ssh-agent]
use_dns_when_possible: true use_dns_when_possible: true
timezone: Europe/Berlin timezone: Europe/Berlin
composer_version: "2" composer_version: "2"
web_environment: [] web_environment: []
nodejs_version: "18" nodejs_version: "24"
corepack_enable: false corepack_enable: false
disable_upload_dirs_warning: true disable_upload_dirs_warning: true
xhgui_https_port: "8142"
xhgui_http_port: "8143"
# Key features of DDEV's config.yaml: # Key features of DDEV's config.yaml:
# name: <projectname> # Name of the project, automatically provides # name: <projectname> # Name of the project, automatically provides
@@ -26,16 +28,16 @@ xhgui_http_port: "8143"
# If the name is omitted, the project will take the name of the enclosing directory, # 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. # 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 # See https://docs.ddev.com/en/stable/users/quickstart/ for more
# information on the different project types # information on the different project types
# docroot: <relative_path> # Relative path to the directory containing index.php. # 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 # 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. # so this can break upgrades.
# webimage: <docker_image> # webimage: <docker_image>
@@ -45,10 +47,18 @@ xhgui_http_port: "8143"
# database: # database:
# type: <dbtype> # mysql, mariadb, postgres # type: <dbtype> # mysql, mariadb, postgres
# version: <version> # database version, like "10.11" or "8.0" # version: <version> # database version, like "10.11" or "8.0"
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8 # 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 # MySQL versions can be 5.5-8.0, 8.4, 9.7
# PostgreSQL versions can be 9-18 # 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>
# It’s unusual to change this option, and we don’t 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_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) # router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
@@ -68,8 +78,7 @@ xhgui_http_port: "8143"
# bind_all_ports is used (normally with router disabled) # bind_all_ports is used (normally with router disabled)
# xhprof_mode: [prepend|xhgui|global] # xhprof_mode: [prepend|xhgui|global]
# Set to "xhgui" to enable XHGui features # Default is "xhgui"
# "xhgui" will become default in a future major release
# webserver_type: nginx-fpm, apache-fpm, generic # webserver_type: nginx-fpm, apache-fpm, generic
@@ -87,22 +96,24 @@ xhgui_http_port: "8143"
# commands are executed. # commands are executed.
# composer_version: "2" # 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. # 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) # - 2.2 (latest Composer LTS version)
# - stable # - stable
# - preview # - preview
# - snapshot # - snapshot
# Alternatively, an explicit Composer version may be specified, for example "2.2.18". # 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. # 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 # 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, # 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'.
# corepack_enable: false # corepack_enable: false
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm # Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
@@ -162,9 +173,7 @@ xhgui_http_port: "8143"
# - "global": uses the value from the global config. # - "global": uses the value from the global config.
# - "none": disables performance optimization for this project. # - "none": disables performance optimization for this project.
# - "mutagen": enables Mutagen 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 # See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
# fail_on_hook_fail: False # fail_on_hook_fail: False
@@ -194,10 +203,10 @@ xhgui_http_port: "8143"
# The mailpit port is not normally bound on the host at all, instead being routed # 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. # 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 # 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 # Extra Debian packages that are needed in the dbimage can be added here
# use_dns_when_possible: true # use_dns_when_possible: true
@@ -209,12 +218,15 @@ xhgui_http_port: "8143"
# project_tld: ddev.site # project_tld: ddev.site
# The top-level domain used for project URLs # The top-level domain used for project URLs
# The default "ddev.site" allows DNS lookup via a wildcard # 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 # share_default_provider: ngrok
# Provide extra flags to the "ngrok http" command, see # The default share provider to use for "ddev share"
# https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h" # 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 # disable_settings_management: false
# If true, DDEV will not create CMS-specific settings files like # If true, DDEV will not create CMS-specific settings files like
@@ -276,7 +288,7 @@ xhgui_http_port: "8143"
# override_config: false # override_config: false
# By default, config.*.yaml files are *merged* into the configuration # By default, config.*.yaml files are *merged* into the configuration
# But this means that some things can't be overridden # 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. # and you can't erase existing hooks or all environment variables.
# However, with "override_config: true" in a particular config.*.yaml file, # However, with "override_config: true" in a particular config.*.yaml file,
# 'use_dns_when_possible: false' can override the existing values, and # 'use_dns_when_possible: false' can override the existing values, and
@@ -286,7 +298,7 @@ xhgui_http_port: "8143"
# web_environment: [] # web_environment: []
# or # or
# additional_hostnames: [] # 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. # config.*.yaml file it exists in.
# Many DDEV commands can be extended to run tasks before or after the # Many DDEV commands can be extended to run tasks before or after the
@@ -296,3 +308,6 @@ xhgui_http_port: "8143"
# information on the commands that can be extended and the tasks you can define # information on the commands that can be extended and the tasks you can define
# for them. Example: # for them. Example:
#hooks: #hooks:
## Un-comment to consume async message.
# post-start:
# - exec: symfony run --daemon --watch=config,src,templates,vendor bin/console messenger:consume async -vv
+70 -9
View File
@@ -26,7 +26,11 @@ APP_SECRET=b3cb0285aea14bbffb57df0cc3aa84f4
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4" # 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="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="postgresql://app:[email protected]:5432/app?serverVersion=15&charset=utf8"
# 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 ### ###< doctrine/doctrine-bundle ###
###> symfony/messenger ### ###> symfony/messenger ###
@@ -38,9 +42,17 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###> symfony/mailer ### ###> symfony/mailer ###
MAILER_DSN=null://null MAILER_DSN=null://null
# Has to resolve in every environment or the container does not compile, see the
# mailjet section below for the production value
MAILING_MAILER_DSN=null://null
###< symfony/mailer ### ###< symfony/mailer ###
APP_BASE_URI=https://myep-team.ddev.site APP_BASE_URI=https://epteam.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=^epteam\.ddev\.site$
APP_BPN_USER= APP_BPN_USER=
APP_BPN_PASSWORD= APP_BPN_PASSWORD=
@@ -51,18 +63,63 @@ APP_BPN_DEBUG=false
# This hotel code will be assigned to admin users together with ROLE_HOTEL_MANAGER # This hotel code will be assigned to admin users together with ROLE_HOTEL_MANAGER
# in dev and staging environments for testing purposes # in dev and staging environments for testing purposes
APP_BPN_DEFAULT_HOTEL_CODE= 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_MANAGER=1293
APP_BPN_CRM_ID_TEAMER=1070 APP_BPN_CRM_ID_TEAMER=1070
APP_DEFAULT_EMAIL_FROM=[email protected] APP_DEFAULT_EMAIL_FROM=[email protected]
APP_DEFAULT_EMAIL_TO=[email protected] APP_DEFAULT_EMAIL_TO=[email protected]
XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport" # Regular expressions of the only recipients a mail may actually reach. Everything else is
# stripped from the envelope and a mail left without a recipient is not sent at all, see
# App\EventListener\EmailRecipientWhitelistSubscriber. Empty means no filtering, which is what
# production runs with. Set this on staging: it runs the real transports against real teamer
# addresses. The patterns have to match the full address.
# Example: APP_MAIL_RECIPIENT_WHITELIST='.*@ep-reisen\.de,qa@example\.com'
APP_MAIL_RECIPIENT_WHITELIST=
# Machine to machine access to the MyE&P API. This is a separate confidential client
# from the login one below, so it can be rotated without locking anyone out. The base
# url is the host root: both the token endpoint and the api paths hang off it.
MYEP_API_BASE_URL=https://my.ep-reisen.de/
MYEP_API_CLIENT_ID=
MYEP_API_CLIENT_SECRET=
MYEP_OAUTH2_CLIENT_ID=
MYEP_OAUTH2_CLIENT_SECRET=
MYEP_OAUTH2_URL_AUTHORIZE=https://my.ep-reisen.de/authorize
MYEP_OAUTH2_URL_ACCESS_TOKEN=https://my.ep-reisen.de/token
MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS=https://my.ep-reisen.de/api/userinfo
MYEP_OAUTH2_SCOPES=email,id,roles,profile
# Shared key the sibling websites send in the X-Api-Key header. Empty here: set it per stage.
APPLICATION_FORM_API_KEY=
# Shared key n8n reads the assignments feed with, in the X-Api-Key header. Empty here: set it per stage.
N8N_API_KEY=
# Booking link for the Team-Infocall, substituted for {bookingUrl} in the application form
# receipt mail. Empty here: set it per stage. While it is empty that mail is not sent at all -
# an invitation with a dead booking link is worse than none.
APPLICATION_FORM_BOOKING_URL=
# SaaS time tracking. Season teamers clock their hours here; app:saas:work-hours imports them.
# The API key travels in the URL path, so it never belongs in the committed file - set both
# values in .env.local / the server environment.
APP_SAAS_BASE_URL=
APP_SAAS_API_KEY=
###> symfony/mailjet-mailer ### ###> symfony/mailjet-mailer ###
# MAILER_DSN=mailjet+api://PUBLIC_KEY:[email protected] # Bulk teamer mailings only, everything else goes out over MAILER_DSN. Set the real
# #MAILER_DSN=mailjet+smtp://PUBLIC_KEY:[email protected] # credentials in .env.local - the sending domain has to be verified in Mailjet (SPF,
# DKIM, DMARC) or the mail is delivered but lands in spam.
#
# Use smtp. The api transport does not send the message we built: it takes the parts as
# json fields and has Mailjet assemble the mail, which drops the embedded logo into an
# ordinary attachment. Over smtp our own MIME goes out unchanged and the mail arrives as
# it was rendered. Deliverability is identical either way, it is the same account, the
# same ip pool and the same dkim signature. The api is only worth revisiting if the host
# ever blocks outbound port 587, since it talks https instead.
# MAILING_MAILER_DSN=mailjet+smtp://PUBLIC_KEY:[email protected]
# MAILING_MAILER_DSN=mailjet+api://PUBLIC_KEY:PRIVATE_KEY@default
###< symfony/mailjet-mailer ### ###< symfony/mailjet-mailer ###
BIN_GS=/usr/bin/gs BIN_GS=/usr/bin/gs
@@ -73,7 +130,11 @@ [email protected]
FEATURE_SANITIZE_UPLOADS=false FEATURE_SANITIZE_UPLOADS=false
FEATURE_STAMP_INVOICES=false FEATURE_STAMP_INVOICES=false
FEATURE_DATEV_EMAIL=false
FEATURE_MYEP_LOGIN=false
FEATURE_UPLOAD_REPLACEMENT=false
UPLOAD_REPLACEMENT_FILE=assets/pdf/chicken.pdf MAINTENANCE_MODE_ENABLED=false
MAINTENANCE_MODE_IP_WHITELIST=188.245.62.21
PERSONAL_DATA_CHECK_DEADLINES=04-01,10-01 MAINTENANCE_MODE_ROUTE_WHITELIST=
MAINTENANCE_MODE_PATH_WHITELIST=
+11 -1
View File
@@ -1,6 +1,16 @@
# define your env variables for the test env here # define your env variables for the test env here
KERNEL_CLASS='App\Kernel' KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st' APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999 SYMFONY_DEPRECATIONS_HELPER=max[self]=0
PANTHER_APP_ENV=panther PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
# the test client requests http://localhost/
APP_TRUSTED_HOSTS='^localhost$'
# The api firewall rejects every request while no key is configured, and .env.local is not
# loaded in the test environment - so the throwaway key the tests send belongs here.
# Keep it in sync with ApplicationFormControllerTest::KEY.
APPLICATION_FORM_API_KEY=test-application-form-api-key
# Keep in sync with Api\Assignment\ListControllerTest::KEY.
N8N_API_KEY=test-n8n-api-key
# The receipt mail refuses to render without it, and several tests send that mail.
APPLICATION_FORM_BOOKING_URL=https://bookings.example.com/team-infocall
+5 -2
View File
@@ -4,6 +4,7 @@
/.env.local.php /.env.local.php
/.env.*.local /.env.*.local
/config/secrets/prod/prod.decrypt.private.php /config/secrets/prod/prod.decrypt.private.php
/config/reference.php
/public/bundles/ /public/bundles/
/var/ /var/
/vendor/ /vendor/
@@ -11,11 +12,11 @@
###> phpunit/phpunit ### ###> phpunit/phpunit ###
/phpunit.xml /phpunit.xml
.phpunit.result.cache /.phpunit.cache/
###< phpunit/phpunit ### ###< phpunit/phpunit ###
###> symfony/phpunit-bridge ### ###> symfony/phpunit-bridge ###
.phpunit.result.cache /.phpunit.cache/
/phpunit.xml /phpunit.xml
###< symfony/phpunit-bridge ### ###< symfony/phpunit-bridge ###
@@ -34,3 +35,5 @@ yarn-error.log
/.php-cs-fixer.php /.php-cs-fixer.php
/.php-cs-fixer.cache /.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ### ###< friendsofphp/php-cs-fixer ###
/http-client.private.env.json
+1 -1
View File
@@ -1 +1 @@
18 24
+57
View File
@@ -0,0 +1,57 @@
### Full submission, as the TYPO3 ApplicationApiFinisher sends it.
### The key is APPLICATION_FORM_API_KEY of the stage being called; keep the real values in
### http-client.private.env.json, which is not in the repository.
POST https://myep-team.ddev.site/api/application-forms
Content-Type: application/json
X-Api-Key: {{applicationFormApiKey}}
{
"form": "applicationform",
"formLabel": "Bewerbungsformular",
"submittedAt": "2026-09-22T12:30:00+02:00",
"pageUid": 1421,
"pageUrl": "https://www.ep-reisen.de/jobs/bewerbung",
"firstName": "Anna",
"lastName": "Muster",
"email": "[email protected]",
"phone": "+49 30 1234567",
"mobile": "+49 170 1234567",
"birthDate": "01.01.1990",
"street": "Musterstraße 1",
"zipCode": "10115",
"city": "Berlin",
"details": {
"Nationalität": "deutsch",
"Führerschein": "ja",
"Ausbildung/Studium": "Tourismusmanagement",
"Bewerbung als": ["Reiseleitung", "Skilehrer"],
"Ich fahre": ["Ski", "Snowboard"],
"Wie gut fahre ich Ski": "fortgeschritten",
"Skifahrer seit": "2004",
"Einsatz datum": "ab 20.12.2026"
}
}
### A form that maps nothing but the basics - valid, everything else is absent, not empty.
POST https://myep-team.ddev.site/api/application-forms
Content-Type: application/json
X-Api-Key: {{applicationFormApiKey}}
{
"form": "contactform",
"formLabel": "Kontaktformular",
"submittedAt": "2026-09-22T12:30:00+02:00",
"details": {}
}
### Wrong key: 401, and the submission is dropped on the TYPO3 side without a retry.
POST https://myep-team.ddev.site/api/application-forms
Content-Type: application/json
X-Api-Key: definitely-not-the-key
{
"form": "applicationform",
"formLabel": "Bewerbungsformular",
"submittedAt": "2026-09-22T12:30:00+02:00",
"details": {}
}
+9
View File
@@ -3,6 +3,15 @@ htmx.config.includeIndicatorStyles = false
htmx.config.historyCacheSize = 0 htmx.config.historyCacheSize = 0
htmx.config.allowScriptTags = false htmx.config.allowScriptTags = false
// A boosted link is a page navigation: show the error page like the browser would, instead of
// swallowing the 4xx/5xx and leaving the click without effect.
document.addEventListener('htmx:beforeSwap', (event) => {
if (true === event.detail.boosted && event.detail.xhr.status >= 400) {
event.detail.shouldSwap = true
event.detail.isError = false
}
})
import './bootstrap.js'; import './bootstrap.js';
/* /*
* Welcome to your app's main JavaScript file! * Welcome to your app's main JavaScript file!
@@ -0,0 +1,74 @@
import { Controller } from '@hotwired/stimulus'
/*
* Shows a questionnaire question only while its show_if holds, mirroring
* App\Model\Questionnaire\Definition::isVisible(): the condition must be met and the question it
* depends on must itself be shown.
*
* Every question row carries data-question-key; a conditional one also data-show-if, the JSON
* {question, operator, value} that App\Form\QuestionnaireType writes. A hidden row stays in the
* form and is submitted: a draft keeps its answer, and the server drops it on submit.
*/
export default class extends Controller {
connect() {
this.update()
}
update() {
const visible = {}
// Rows come in definition order, and a condition may only name an earlier question, so
// the question a row depends on has always been decided by the time the row is reached.
for (const row of this.element.querySelectorAll('[data-question-key]')) {
const key = row.dataset.questionKey
const condition = row.dataset.showIf ? JSON.parse(row.dataset.showIf) : null
visible[key] = null === condition
|| (true === visible[condition.question] && this.isMet(condition))
row.hidden = false === visible[key]
}
}
isMet({ question, operator, value }) {
const answer = this.answer(question)
if (null === answer) {
return false
}
switch (operator) {
case 'equals':
return answer === value
case 'in':
return value.includes(answer)
case 'lte':
return Number(answer) <= value
default:
return false
}
}
answer(key) {
const row = this.element.querySelector(`[data-question-key="${key}"]`)
if (null === row) {
return null
}
const checked = row.querySelector('input[type="radio"]:checked')
if (null !== checked) {
return checked.value
}
const select = row.querySelector('select')
if (null !== select) {
return '' === select.value ? null : select.value
}
return null
}
}
@@ -1,13 +1,18 @@
import { Controller } from '@hotwired/stimulus' import { Controller } from '@hotwired/stimulus'
export default class extends Controller { export default class extends Controller {
// the default of one row keeps every existing textarea exactly as it was, fields that
// are written into at length can ask for a taller floor to grow from
static values = { minRows: { type: Number, default: 1 } }
connect() { connect() {
this.element.rows = 1 this.element.rows = this.minRowsValue
this.minHeight = this.element.scrollHeight
this.resize() this.resize()
} }
resize() { resize() {
this.element.style.height = 'auto' this.element.style.height = 'auto'
this.element.style.height = `${this.element.scrollHeight}px` this.element.style.height = `${Math.max(this.element.scrollHeight, this.minHeight)}px`
} }
} }
+8 -4
View File
@@ -198,11 +198,11 @@
<path d="M24 0C10.7 0 0 10.7 0 24S10.7 48 24 48h8V67c0 40.3 16 79 44.5 107.5L158.1 256 76.5 337.5C48 366 32 404.7 32 445v19H24c-13.3 0-24 10.7-24 24s10.7 24 24 24H360c13.3 0 24-10.7 24-24s-10.7-24-24-24h-8V445c0-40.3-16-79-44.5-107.5L225.9 256l81.5-81.5C336 146 352 107.3 352 67V48h8c13.3 0 24-10.7 24-24s-10.7-24-24-24H24zM192 289.9l81.5 81.5C293 391 304 417.4 304 445v19H80V445c0-27.6 11-54 30.5-73.5L192 289.9zm0-67.9l-81.5-81.5C91 121 80 94.6 80 67V48H304V67c0 27.6-11 54-30.5 73.5L192 222.1z"/> <path d="M24 0C10.7 0 0 10.7 0 24S10.7 48 24 48h8V67c0 40.3 16 79 44.5 107.5L158.1 256 76.5 337.5C48 366 32 404.7 32 445v19H24c-13.3 0-24 10.7-24 24s10.7 24 24 24H360c13.3 0 24-10.7 24-24s-10.7-24-24-24h-8V445c0-40.3-16-79-44.5-107.5L225.9 256l81.5-81.5C336 146 352 107.3 352 67V48h8c13.3 0 24-10.7 24-24s-10.7-24-24-24H24zM192 289.9l81.5 81.5C293 391 304 417.4 304 445v19H80V445c0-27.6 11-54 30.5-73.5L192 289.9zm0-67.9l-81.5-81.5C91 121 80 94.6 80 67V48H304V67c0 27.6-11 54-30.5 73.5L192 222.1z"/>
</symbol> </symbol>
<symbol id="icon-car" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="64" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="192" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="88" y1="176" x2="168" y2="176" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M216,176h24a8,8,0,0,0,8-8V128a8,8,0,0,0-8-8H208L162.34,74.34A8,8,0,0,0,156.69,72H44.28a8,8,0,0,0-6.65,3.56L8,120v48a8,8,0,0,0,8,8H40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="208" y1="120" x2="8" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol> <symbol id="icon-car" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="64" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="192" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="88" y1="176" x2="168" y2="176" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M216,176h24a8,8,0,0,0,8-8V128a8,8,0,0,0-8-8H208L162.34,74.34A8,8,0,0,0,156.69,72H44.28a8,8,0,0,0-6.65,3.56L8,120v48a8,8,0,0,0,8,8H40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="208" y1="120" x2="8" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-flag-a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#fff" d="M640 480H0V0h640z"/><path fill="#ed2939" d="M640 480H0V320h640zm0-319.9H0V.1h640z"/></g></symbol> <symbol id="icon-flag-at" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#fff" d="M640 480H0V0h640z"/><path fill="#ed2939" d="M640 480H0V320h640zm0-319.9H0V.1h640z"/></g></symbol>
<symbol id="icon-flag-ch" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#d52b1e" d="M0 0h640v480H0z"/><g fill="#fff"><path d="M170 195h300v90H170z"/><path d="M275 90h90v300h-90z"/></g></g></symbol> <symbol id="icon-flag-ch" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#d52b1e" d="M0 0h640v480H0z"/><g fill="#fff"><path d="M170 195h300v90H170z"/><path d="M275 90h90v300h-90z"/></g></g></symbol>
<symbol id="icon-flag-d" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><path fill="#ffce00" d="M0 320h640v160H0z"/><path d="M0 0h640v160H0z"/><path fill="#d00" d="M0 160h640v160H0z"/></symbol> <symbol id="icon-flag-de" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><path fill="#ffce00" d="M0 320h640v160H0z"/><path d="M0 0h640v160H0z"/><path fill="#d00" d="M0 160h640v160H0z"/></symbol>
<symbol id="icon-flag-f" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#00267f" d="M0 0h213.3v480H0z"/><path fill="#f31830" d="M426.7 0H640v480H426.7z"/></g></symbol> <symbol id="icon-flag-fr" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#00267f" d="M0 0h213.3v480H0z"/><path fill="#f31830" d="M426.7 0H640v480H426.7z"/></g></symbol>
<symbol id="icon-flag-i" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#009246" d="M0 0h213.3v480H0z"/><path fill="#ce2b37" d="M426.7 0H640v480H426.7z"/></g></symbol> <symbol id="icon-flag-it" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#009246" d="M0 0h213.3v480H0z"/><path fill="#ce2b37" d="M426.7 0H640v480H426.7z"/></g></symbol>
<symbol id="icon-flag-nl" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#21468b" d="M0 320h640v160H0z"/><path fill="#fff" d="M0 160h640v160H0z"/><path fill="#ae1c28" d="M0 0h640v160H0z"/></g></symbol> <symbol id="icon-flag-nl" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#21468b" d="M0 320h640v160H0z"/><path fill="#fff" d="M0 160h640v160H0z"/><path fill="#ae1c28" d="M0 0h640v160H0z"/></g></symbol>
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL --> <!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
<symbol id="icon-spinner" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="currentColor"> <symbol id="icon-spinner" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
@@ -241,4 +241,8 @@
</circle> </circle>
</g> </g>
</symbol> </symbol>
<symbol id="icon-typo3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
<!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
<path d="M178.7 78.4c0-24.7 5.4-32.4 13.9-39.4-69.5 8.5-149.3 34-176.3 66.4-5.4 7.7-9.3 20.8-9.3 37.1C7 246 113.8 480 191.1 480c36.3 0 97.3-59.5 146.7-139-7 2.3-11.6 2.3-18.5 2.3-57.2 0-140.6-198.5-140.6-264.9zM301.5 32c-30.1 0-41.7 5.4-41.7 36.3 0 66.4 53.8 198.5 101.7 198.5 26.3 0 78.8-99.7 78.8-182.3 0-40.9-67-52.5-138.8-52.5z"/>
</symbol>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

+38 -2
View File
@@ -19,7 +19,15 @@
font-size: 20px; font-size: 20px;
line-height: 24px; line-height: 24px;
margin-bottom: 0; margin-bottom: 0;
padding-bottom: 0; /* Was 0 while an h2 only ever preceded a p, which brings its own spacing. */
padding-bottom: 8px;
}
h3 {
font-weight: bold;
font-size: 17px;
line-height: 22px;
margin-bottom: 0;
padding-bottom: 4px;
} }
p { p {
font-size: 16px; font-size: 16px;
@@ -48,6 +56,18 @@
strong { strong {
font-weight: bold; font-weight: bold;
} }
em {
font-style: italic;
}
ul, ol {
/* Margin, not padding: Outlook on Windows renders list padding unreliably. */
margin: 0 0 16px 24px;
padding: 0;
font-size: 16px;
}
li {
padding-bottom: 4px;
}
</mj-style> </mj-style>
</mj-head> </mj-head>
<mj-body background-color="#666666"> <mj-body background-color="#666666">
@@ -70,8 +90,19 @@
</h2> </h2>
<p> <p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto culpa delectus dolores Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto culpa delectus dolores
<strong>earum eius fugiat in nesciunt quas quidem vitae</strong>? <strong>earum eius fugiat in nesciunt quas quidem vitae</strong>, <em>eius fugiat</em>?
</p> </p>
<ul>
<li>Lorem ipsum dolor</li>
<li>Consectetur adipisicing elit</li>
</ul>
<h3>
Lorem ipsum
</h3>
<ol>
<li>Lorem ipsum dolor</li>
<li>Consectetur adipisicing elit</li>
</ol>
<p> <p>
<a href="#" class="button"> <a href="#" class="button">
Button Button
@@ -81,6 +112,11 @@
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Doloribus, eveniet! Lorem ipsum dolor sit amet, consectetur adipisicing elit. Doloribus, eveniet!
</p> </p>
{% endblock %} {% endblock %}
{% block button %}
<p>
<a href="{{ url('app_index') }}" class="button">Zum Portal</a>
</p>
{% endblock %}
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
Binary file not shown.
+9 -8
View File
@@ -1,8 +1,9 @@
@import "components/badge.css"; @import "./components/badge.css";
@import "components/autocomplete.css"; @import "./components/autocomplete.css";
@import "components/button.css"; @import "./components/button.css";
@import "components/datatable.css"; @import "./components/datatable.css";
@import "components/datepicker.css"; @import "./components/datepicker.css";
@import "components/toast.css"; @import "./components/toast.css";
@import "components/tooltip.css"; @import "./components/tooltip.css";
@import "components/timeline.css"; @import "./components/timeline.css";
@import "./components/mail-body.css";
+44 -5
View File
@@ -1,7 +1,46 @@
@import "tailwindcss/base"; @import "tailwindcss";
@import "_base.css"; @import "./_base.css" layer(base);
@import "./_components.css" layer(components);
@import "tailwindcss/components"; @plugin "@tailwindcss/forms";
@import "_components.css";
@import "tailwindcss/utilities"; @source inline("rotate-90");
@theme {
--font-sans: Lato, sans-serif;
--font-weight-semibold: 700;
--font-weight-bold: 900;
--color-primary: #3d8ccb;
--color-secondary: #f9c700;
}
/*
* Tailwind 3 defaults that Tailwind 4 changed: borders were gray-200 instead of currentColor,
* and buttons had a pointer cursor.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
/*
* Thin scrollbars without a track for inner scrolling areas. The page scrollbar takes its
* style from <html>, which body * leaves out. Firefox and Chrome/Edge 121+ honour these;
* Safari keeps its own overlay scrollbars.
*/
@layer base {
body * {
scrollbar-width: thin;
scrollbar-color: var(--color-gray-300) transparent;
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
.btn { .btn {
@apply inline-flex space-x-1 justify-center rounded-lg text-sm uppercase font-semibold py-2.5 px-4; @apply inline-flex space-x-1 justify-center items-center rounded-lg text-sm uppercase font-semibold py-2.5 px-4;
@apply bg-primary text-white hover:bg-primary/80 shadow-md disabled:opacity-50 disabled:cursor-not-allowed; @apply bg-primary text-white hover:bg-primary/80 shadow-md disabled:opacity-50 disabled:cursor-not-allowed;
} }
+3 -3
View File
@@ -3,11 +3,11 @@
.flatpickr-day.selected, .flatpickr-day.selected,
.flatpickr-day.selected:hover, .flatpickr-day.selected:hover,
.flatpickr-day.selected:focus { .flatpickr-day.selected:focus {
background-color: theme('colors.secondary'); background-color: var(--color-secondary);
border-color: theme('colors.secondary'); border-color: var(--color-secondary);
} }
.flatpickr-monthDropdown-months, .flatpickr-monthDropdown-months,
.flatpickr-current-month .flatpickr-monthDropdown-months { .flatpickr-current-month .flatpickr-monthDropdown-months {
@apply form-select pt-0 pb-0 focus:outline-none focus:shadow-none; @apply form-select pt-0 pb-0 focus:outline-hidden focus:shadow-none;
} }
+41
View File
@@ -0,0 +1,41 @@
/*
* Mirrors the rules the mail layout carries in assets/mjml/layout.mjml, so both admin
* previews show what the rendered mail will look like. Kept out of Tailwind's typography
* plugin on purpose: prose brings its own type scale, and the point here is to match the
* mail rather than to look good on its own.
*/
.mail-body > p {
@apply pb-4;
}
.mail-body h2 {
@apply text-xl pb-2;
}
.mail-body h3 {
@apply text-lg font-bold pb-1;
}
.mail-body strong {
@apply font-bold;
}
.mail-body em {
@apply italic;
}
.mail-body ul {
@apply list-disc ml-6 pb-4;
}
.mail-body ol {
@apply list-decimal ml-6 pb-4;
}
.mail-body li {
@apply pb-1;
}
.mail-body a {
@apply underline;
}
+1 -1
View File
@@ -1,4 +1,4 @@
.scroll-container { .scroll-container {
@apply overflow-scroll; @apply overflow-auto;
max-height: calc(100vh - 320px); max-height: calc(100vh - 320px);
} }
+4 -4
View File
@@ -1,20 +1,20 @@
@import "toastify-js/src/toastify.css"; @import "toastify-js/src/toastify.css";
.toastify { .toastify {
@apply shadow-lg rounded; @apply shadow-lg rounded-sm;
} }
.toastify--success { .toastify--success {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.emerald.600'); background: var(--color-emerald-600);
} }
.toastify--warning { .toastify--warning {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.red.600'); background: var(--color-red-600);
} }
.toastify--info { .toastify--info {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.blue.600'); background: var(--color-blue-600);
} }
+5 -5
View File
@@ -1,18 +1,18 @@
@import "tippy.js/dist/tippy.css"; @import "tippy.js/dist/tippy.css";
.tippy-box { .tippy-box {
background-color: theme('colors.primary'); background-color: var(--color-primary);
} }
.tippy-box[data-placement^='top'] > .tippy-arrow::before { .tippy-box[data-placement^='top'] > .tippy-arrow::before {
border-top-color: theme('colors.primary'); border-top-color: var(--color-primary);
} }
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before { .tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
border-bottom-color: theme('colors.primary'); border-bottom-color: var(--color-primary);
} }
.tippy-box[data-placement^='left'] > .tippy-arrow::before { .tippy-box[data-placement^='left'] > .tippy-arrow::before {
border-left-color: theme('colors.primary'); border-left-color: var(--color-primary);
} }
.tippy-box[data-placement^='right'] > .tippy-arrow::before { .tippy-box[data-placement^='right'] > .tippy-arrow::before {
border-right-color: theme('colors.primary'); border-right-color: var(--color-primary);
} }
+5 -11
View File
@@ -5,15 +5,9 @@ if (!ini_get('date.timezone')) {
ini_set('date.timezone', 'UTC'); ini_set('date.timezone', 'UTC');
} }
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) { if (!is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php'); echo "Unable to find PHPUnit in `vendor/phpunit/phpunit/`. Run `composer install`.\n";
require PHPUNIT_COMPOSER_INSTALL; exit(1);
PHPUnit\TextUI\Command::main();
} else {
if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) {
echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n";
exit(1);
}
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php';
} }
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
+49 -41
View File
@@ -10,26 +10,30 @@
} }
], ],
"require": { "require": {
"php": ">=8.1", "php": ">=8.4",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"ext-simplexml": "*", "ext-simplexml": "*",
"ext-sockets": "*", "ext-sockets": "*",
"beberlei/doctrineextensions": "^1.5", "beberlei/doctrineextensions": "^1.5",
"doctrine/doctrine-bundle": "^2.10", "doctrine/doctrine-bundle": "^2.15",
"doctrine/doctrine-migrations-bundle": "^3.2", "doctrine/doctrine-migrations-bundle": "^3.2",
"doctrine/orm": "^2.15", "doctrine/orm": "^3.6",
"dompdf/dompdf": "^3.1",
"flagception/flagception-bundle": "^6.0", "flagception/flagception-bundle": "^6.0",
"knplabs/knp-menu-bundle": "^3.2", "knplabs/knp-menu-bundle": "^3.2",
"knplabs/knp-paginator-bundle": "^6.2", "knplabs/knp-paginator-bundle": "^6.2",
"league/commonmark": "^2.10",
"league/flysystem-bundle": "^3.4", "league/flysystem-bundle": "^3.4",
"league/flysystem-sftp-v3": "^3.29", "league/flysystem-sftp-v3": "^3.29",
"league/oauth2-client": "^2.9",
"liip/imagine-bundle": "^2.11", "liip/imagine-bundle": "^2.11",
"maennchen/zipstream-php": "^3.1", "maennchen/zipstream-php": "^3.1",
"nelmio/security-bundle": "^3.0", "nelmio/security-bundle": "^3.0",
"nesbot/carbon": "^2.70", "nesbot/carbon": "^3.8",
"oneup/uploader-bundle": "^4.0", "oneup/uploader-bundle": "^5.1",
"phpdocumentor/reflection-docblock": "^5.3", "phpdocumentor/reflection-docblock": "^5.3",
"phpoffice/phpspreadsheet": "^5.9",
"phpstan/phpdoc-parser": "^1.22", "phpstan/phpdoc-parser": "^1.22",
"scienta/doctrine-json-functions": "^6.3", "scienta/doctrine-json-functions": "^6.3",
"setasign/fpdf": "^1.8", "setasign/fpdf": "^1.8",
@@ -38,38 +42,38 @@
"spatie/blink": "^1.4", "spatie/blink": "^1.4",
"spatie/icalendar-generator": "^2.5", "spatie/icalendar-generator": "^2.5",
"symfony/apache-pack": "^1.0", "symfony/apache-pack": "^1.0",
"symfony/asset": "6.4.*", "symfony/asset": "7.4.*",
"symfony/console": "6.4.*", "symfony/console": "7.4.*",
"symfony/doctrine-messenger": "6.4.*", "symfony/doctrine-messenger": "7.4.*",
"symfony/dotenv": "6.4.*", "symfony/dotenv": "7.4.*",
"symfony/expression-language": "6.4.*", "symfony/expression-language": "7.4.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "6.4.*", "symfony/form": "7.4.*",
"symfony/framework-bundle": "6.4.*", "symfony/framework-bundle": "7.4.*",
"symfony/http-client": "6.4.*", "symfony/http-client": "7.4.*",
"symfony/intl": "6.4.*", "symfony/intl": "7.4.*",
"symfony/mailer": "6.4.*", "symfony/mailer": "7.4.*",
"symfony/mailjet-mailer": "6.4.*", "symfony/mailjet-mailer": "7.4.*",
"symfony/mime": "6.4.*", "symfony/mime": "7.4.*",
"symfony/monolog-bundle": "^3.0", "symfony/monolog-bundle": "^3.0",
"symfony/notifier": "6.4.*", "symfony/notifier": "7.4.*",
"symfony/process": "6.4.*", "symfony/process": "7.4.*",
"symfony/property-access": "6.4.*", "symfony/property-access": "7.4.*",
"symfony/property-info": "6.4.*", "symfony/property-info": "7.4.*",
"symfony/runtime": "6.4.*", "symfony/runtime": "7.4.*",
"symfony/security-bundle": "6.4.*", "symfony/security-bundle": "7.4.*",
"symfony/serializer": "6.4.*", "symfony/serializer": "7.4.*",
"symfony/stimulus-bundle": "^2.11", "symfony/stimulus-bundle": "^2.11",
"symfony/string": "6.4.*", "symfony/string": "7.4.*",
"symfony/translation": "6.4.*", "symfony/translation": "7.4.*",
"symfony/twig-bundle": "6.4.*", "symfony/twig-bundle": "7.4.*",
"symfony/uid": "6.4.*", "symfony/uid": "7.4.*",
"symfony/ux-twig-component": "^2.16", "symfony/ux-twig-component": "^2.16",
"symfony/validator": "6.4.*", "symfony/validator": "7.4.*",
"symfony/web-link": "6.4.*", "symfony/web-link": "7.4.*",
"symfony/webpack-encore-bundle": "^2.0", "symfony/webpack-encore-bundle": "^2.0",
"symfony/workflow": "6.4.*", "symfony/workflow": "7.4.*",
"symfony/yaml": "6.4.*", "symfony/yaml": "7.4.*",
"twig/extra-bundle": "^2.12|^3.0", "twig/extra-bundle": "^2.12|^3.0",
"twig/html-extra": "^3.7", "twig/html-extra": "^3.7",
"twig/intl-extra": "^3.7", "twig/intl-extra": "^3.7",
@@ -83,7 +87,10 @@
"symfony/flex": true, "symfony/flex": true,
"symfony/runtime": true "symfony/runtime": true
}, },
"sort-packages": true "sort-packages": true,
"platform": {
"php": "8.5.9"
}
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@@ -123,7 +130,7 @@
"extra": { "extra": {
"symfony": { "symfony": {
"allow-contrib": true, "allow-contrib": true,
"require": "6.4.*" "require": "7.4.*"
} }
}, },
"require-dev": { "require-dev": {
@@ -131,14 +138,15 @@
"fakerphp/faker": "*", "fakerphp/faker": "*",
"friendsofphp/php-cs-fixer": "^3.84", "friendsofphp/php-cs-fixer": "^3.84",
"marcocesarato/php-conventional-changelog": "^1.17", "marcocesarato/php-conventional-changelog": "^1.17",
"phpunit/phpunit": "^9.5", "phpunit/phpunit": "^12.5",
"symfony/browser-kit": "6.4.*", "rector/rector": "^2.6",
"symfony/css-selector": "6.4.*", "symfony/browser-kit": "7.4.*",
"symfony/debug-bundle": "6.4.*", "symfony/css-selector": "7.4.*",
"symfony/debug-bundle": "7.4.*",
"symfony/maker-bundle": "^1.0", "symfony/maker-bundle": "^1.0",
"symfony/phpunit-bridge": "^6.3", "symfony/phpunit-bridge": "^7.4",
"symfony/stopwatch": "6.4.*", "symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "6.4.*" "symfony/web-profiler-bundle": "7.4.*"
}, },
"version": "0.2.5" "version": "0.2.5"
} }
Generated
+4112 -2315
View File
File diff suppressed because it is too large. Load diff
+276
View File
@@ -0,0 +1,276 @@
# Default wording of the admin-editable mails (App\Config\EmailTextKey), used until an
# admin overrides a mail under /admin/system/email-text. Removing that override is what
# "Zurücksetzen" does, so a wording fix made here reaches every mail nobody has edited.
#
# Parsed on demand by App\Config\EmailTextCatalog. This file is NOT loaded by the
# container, which is why it does not live in config/packages/.
#
# The body is Markdown, never markup: **fett**, *kursiv*, "## Ueberschrift", "- Liste"
# and "1. Nummerierung" (write \* for a literal asterisk), a blank line starts a new
# paragraph, a single newline stays a line break, and bare URLs and mail addresses become
# links by themselves. Rendered by App\Email\MailBodyRenderer. The headline is plain text
# and takes no Markdown. {platzhalter} tokens are substituted after rendering, escaped, so
# what a teamer wrote can never become markup. A placeholder without a value prints "-".
#
# Every mail closes with the same "Zum Portal" button, rendered by email/layout.html.twig.
texts:
application_accepted:
label: 'Bewerbung angenommen'
placeholders: ['destination', 'specialAgreements', 'contractUploadDeadlineDays']
subject: 'Dein Einsatz wurde angenommen'
headline: 'Herzlichen Glückwunsch!'
body: |-
**Dein Einsatz {destination} wurde angenommen!**
Zusätzliche Absprachen: {specialAgreements}
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.
Schön, dass du dabei bist und ganz viel Spaß in den Bergen! 😊
Bitte wende dich bei Rückfragen an [email protected].
application_accepted_no_contract:
label: 'Bewerbung angenommen (ohne Vertrag)'
placeholders: ['destination', 'specialAgreements']
subject: 'Dein Einsatz wurde angenommen'
headline: 'Herzlichen Glückwunsch!'
body: |-
**Dein Einsatz {destination} wurde angenommen!**
Zusätzliche Absprachen: {specialAgreements}
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 E&P-Team Portal, um die Einsatzdetails einzusehen.
Schön, dass du dabei bist und ganz viel Spaß in den Bergen! 😊
Bitte wende dich bei Rückfragen an [email protected].
application_form_received:
label: 'Eingegangenes Formular - Eingangsbestätigung'
# Only bookingUrl, which is not caller-supplied like the others: it comes from
# APPLICATION_FORM_BOOKING_URL and is substituted into the Markdown before it is parsed,
# which is what lets it be a link target (see App\Email\EmailTextRenderer). The mailer
# also passes firstName, lastName and formLabel, so an admin who adds one of those tokens
# by hand still gets it substituted - they are just not advertised, because the wording
# greets from Köln rather than by name and EmailTextCatalogTest insists every declared
# placeholder appears in the default text.
placeholders: ['bookingUrl', 'season']
subject: 'Einladung zum Team-Infocall'
headline: 'Hallo aus Köln,'
body: |-
vielen Dank für deine Bewerbung für die kommende Wintersaison {season}!
Die Teamplanung der Wintersaison läuft wieder an und um herauszufinden, wie du zu E&P Reisen passt, laden wir dich hiermit zum Team-Infocall ein.
Beim Team-Infocall erhältst du allgemeine Infos über E&P Reisen, welche Reisekonzepte und Destinationen wir anbieten und welche Jobs es dort gibt. Natürlich hast du auch die Gelegenheit offene Fragen zu stellen.
Der Termin wird ca. eine Stunde in Anspruch nehmen und mit mehreren Teilnehmenden als Videocall über Teams stattfinden. Nach der Buchung des Termins erhältst du alle Infos, um an dem Call teilzunehmen, das geht auch als Gast über einen Webbrowser.
Bitte buche dir einen passenden Termin unter [diesem Buchungslink]({bookingUrl}). Bis zu Beginn der Saison werden noch weitere Termine für eine Teilnahme online gestellt.
Folge uns doch gerne schon auf Instagram unter team_ep_reisen für kurzfristige Einsätze, relevante News und coolen Ski- und Boardcontent.
Vielleicht hast du auch Lust im Sommer schon für uns zu arbeiten? Schreibe uns gerne in einer separaten Mail 🙂
Wir freuen uns darauf, dich kennenzulernen und hoffen, dich bald in unserem Team begrüßen zu können!
application_form_follow_up:
label: 'Eingegangenes Formular - Nachfassen'
# No placeholders, same as the receipt: the wording greets from Köln, not by name, and
# EmailTextCatalogTest insists every declared placeholder appears in the default text.
#
# This wording goes stale by design - it names concrete call dates and a Bookings outage.
# It is the fallback for a fresh database only: the moment somebody edits the mail under
# /admin/system/email-text, that override wins and this block stops being sent. Maintain
# it there, not here.
placeholders: []
subject: 'Termine für die Wochenteam-Calls'
headline: 'Hallo aus Köln,'
body: |-
schön, dass du bei unserem Digitalen Infotreffen dabei warst! Ich hoffe wir konnten bereits einige deiner Fragen beantworten. Aufgrund der Fülle an Informationen habe ich ein kleines [Handout](https://PLATZHALTER-HANDOUT) zusammengestellt, damit du die wichtigsten Infos nochmal auf einen Blick hast.
Nun geht es für dich in die 2. Runde! Wir wollen dich besser kennenlernen.
## Wochenteam
Leider gibt es aktuell technische Probleme mit Bookings, sodass ihr momentan keine Termine für die Wochenteam-Calls einsehen oder buchen könnt. Deshalb organisieren wir die Anmeldung vorübergehend manuell. Hier findest du alle Termine für die Wochenteam-Calls. Wenn du an einem der Termine teilnehmen möchtest, antworte bitte einfach auf diese E-Mail und teile uns deinen Wunschtermin mit. Wir melden dich dann manuell an und senden dir rechtzeitig die Zugangsdaten für den entsprechenden Call zu. **Wichtig:** Sobald das Wochenteam-Seminar vollständig besetzt ist, werden keine weiteren Wochenteam-Calls mehr angeboten.
Folgende Termine sind möglich:
- Mi 30.9 14:30-15:15Uhr
- Fr 9.10 9:30-10:15Uhr
- Fr 16.10 14:30-15:15Uhr
- Mi 21.10 14:30-15:15Uhr
- Fr 30.10 11-11:45Uhr
## Saisonpersonal
Interessierst du dich dafür als Saisonmitarbeiter\*in (die ganze Saison) in den Bergen mitzuarbeiten? Wir haben unser Saisonpersonal für diesen Winter bereits vollständig besetzt. Bei Interesse kannst du uns gerne eine E-Mail mit deinem Interesse und dem gewünschten Zeitraum schicken – falls es durch kurzfristige Änderungen, wie z. B. Verletzungen, zu einer Ausnahme kommt, würden wir darauf zurückkommen. Um dir sicherer noch in diesem Winter einen Platz in unserem Team und in den Bergen zu ermöglichen, empfehlen wir dir, Teil unseres Wochenteams zu werden.
Wenn du in der Zwischenzeit eine Frage hast, dann kannst du dich gerne per E-Mail oder telefonisch melden! Wir freuen uns darauf, dich besser kennenzulernen. 😊
Schöne Grüße aus Köln
Eure Personalabteilung
application_rejected:
label: 'Bewerbung abgelehnt'
placeholders: ['destination']
subject: 'Deine Bewerbung wurde abgelehnt'
headline: 'Hallo aus Köln,'
body: |-
leider konnte deine Bewerbung für den Einsatz '{destination}' nicht angenommen werden. Dies kann unterschiedliche Gründe haben, wie bspw. Unpassende Buszustiege, zu späte Bewerbung, unpassendes Jobprofil, zu viele andere Bewerbungen o.Ä.
Bewirb dich doch gerne auf weitere Einsätze, vielleicht hast du diesmal Glück! 😊
assignment_called_off:
label: 'Reise abgesagt'
placeholders: ['destination', 'jobProfile']
subject: 'Die Reise zu deinem Einsatz wurde abgesagt'
headline: 'Hallo aus Köln,'
body: |-
leider mussten wir die Reise zu deinem Einsatz als {jobProfile} ('{destination}') absagen, sodass dein Einsatz nicht stattfinden kann.
Bitte entschuldige die Umstände!
Bei Fragen melde dich gerne unter [email protected]
disposition_called_off:
label: 'Einsatz abgesagt'
placeholders: ['destination', 'jobProfile', 'reason']
subject: 'Dein Einsatz wurde abgesagt'
headline: 'Hallo aus Köln,'
body: |-
leider mussten wir deinen Einsatz als {jobProfile} ('{destination}') absagen, sodass dein Einsatz nicht stattfinden kann:
{reason}
Bei Fragen melde dich gerne unter [email protected]
document_rejected:
label: 'Dokument abgelehnt'
# documentType arrives capitalised, because subject and body both open with it.
placeholders: ['destination', 'documentType', 'comment']
subject: '{documentType} wurde abgelehnt'
headline: 'Hallo aus Köln,'
body: |-
{documentType} wurde für den Einsatz {destination} leider mit folgender Begründung abgelehnt:
{comment}
Überarbeite das Dokument bitte und lade es erneut hoch.
reminder_contract_upload:
label: 'Erinnerung: Honorarvertrag hochladen'
placeholders: ['destination']
subject: 'Reminder: Fehlende Dokumente'
headline: 'Hallo aus Köln,'
body: |-
bitte denke daran, deinen ausgefüllten Honorarvertrag für folgenden Einsatz hochzuladen. Vorher können wir nicht fest mit dir planen und behalten uns vor den Einsatz anderweitig zu vergeben.
**{destination}**
# Weekly, for as long as an entry of the season checklist is not ticked off. It is not tied
# to a deadline, so it must not name one.
reminder_season_tasks:
label: 'Erinnerung: Saisonunterlagen'
placeholders: ['destination', 'tasks']
subject: 'Reminder: Fehlende Saisonunterlagen'
headline: 'Hallo aus Köln,'
body: |-
für deinen Saisoneinsatz **{destination}** fehlen uns noch folgende Unterlagen:
{tasks}
Im E&P-Team Portal findest du unter "Saisonunterlagen" die Vorlagen zum Herunterladen und kannst deine unterschriebenen Unterlagen direkt hochladen.
reminder_invoice_upload:
label: 'Erinnerung: Honorarnote hochladen'
placeholders: ['destination', 'invoiceUploadDeadlineDays']
subject: 'Reminder: Fehlende Dokumente'
headline: 'Hallo aus Köln,'
body: |-
vielen Dank für deinen Einsatz {destination}. Bitte lade deine Honorarnote innerhalb der nächsten {invoiceUploadDeadlineDays} Tage hoch, damit wir dein Honorar bearbeiten können. Solltest du deine Honorarnote zu spät hochladen, können wir sie eventuell nicht mehr annehmen.
# Goes out on the last day of the upload period, so it must not promise any time left.
reminder_invoice_upload_final:
label: 'Letzte Erinnerung: Honorarnote hochladen'
placeholders: ['destination', 'invoiceUploadDeadlineDays']
subject: 'Letzte Erinnerung: Deine Honorarnote fehlt'
headline: 'Hallo aus Köln,'
body: |-
für deinen Einsatz {destination} liegt uns noch immer keine Honorarnote vor.
Heute endet die Frist von {invoiceUploadDeadlineDays} Tagen nach deinem Einsatz. **Bitte lade deine Honorarnote noch heute hoch** - danach können wir sie eventuell nicht mehr annehmen.
Bei Fragen melde dich gerne unter [email protected]
reminder_disposition:
label: 'Erinnerung: Einsatz steht bevor'
placeholders: ['destination', 'jobProfile', 'product', 'dateFrom', 'dateTo', 'diffInDays', 'invoiceUploadDeadlineDays']
subject: 'Reminder: Dein Einsatz {destination}'
headline: 'Hallo aus Köln,'
body: |-
dein Einsatz als {jobProfile}, {product} vom {dateFrom} bis {dateTo} beginnt **in {diffInDays} Tagen**.
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 E&P Team hochzuladen.
Liebe Grüße,
dein Team Personalabteilung
# Descriptions shown next to the form fields. They describe the placeholder itself rather
# than any one mail, so they are shared: a name means the same thing wherever it appears.
# Daily, for as long as a work month has no signed Arbeitszeitnachweis on it. The month
# is named rather than a deadline: there is none, the office simply wants it back.
reminder_work_month_upload:
label: 'Erinnerung: Arbeitszeitnachweis hochladen'
placeholders: ['workMonth']
subject: 'Reminder: Fehlender Arbeitszeitnachweis'
headline: 'Hallo aus Köln,'
body: |-
bitte denke daran, deinen unterschriebenen Arbeitszeitnachweis hochzuladen. Ohne ihn können wir den Monat nicht abrechnen.
**{workMonth}**
Du findest ihn im Portal unter "Mein Profil - Arbeitszeiten": dort herunterladen, unterschreiben und wieder hochladen.
work_month_rejected:
label: 'Arbeitszeitnachweis abgelehnt'
placeholders: ['workMonth', 'comment']
subject: 'Dein Arbeitszeitnachweis wurde abgelehnt'
headline: 'Hallo aus Köln,'
body: |-
leider können wir deinen Arbeitszeitnachweis für **{workMonth}** so nicht annehmen.
Begründung: {comment}
Bitte lade ihn im Portal unter "Mein Profil - Arbeitszeiten" noch einmal hoch.
placeholders:
bookingUrl: 'Buchungslink für den Team-Infocall, kommt aus der Konfiguration (APPLICATION_FORM_BOOKING_URL)'
season: 'Kommende Saison in Kurzform, z. B. "26/27" - wird automatisch eingesetzt'
firstName: 'Vorname der Person, die das Formular abgeschickt hat'
lastName: 'Nachname der Person, die das Formular abgeschickt hat'
formLabel: 'Name des Formulars, z. B. "Bewerbungsformular"'
destination: 'Einsatz, z. B. "12.01.25 - 19.01.25 Skireise"'
specialAgreements: 'Zusätzliche Absprachen zum Einsatz'
contractUploadDeadlineDays: 'Frist zum Hochladen des Honorarvertrags in Tagen'
reason: 'Begründung der Absage'
documentType: 'Art des Dokuments, z. B. "Dein Honorarvertrag"'
comment: 'Begründung der Ablehnung'
invoiceUploadDeadlineDays: 'Frist zum Hochladen der Honorarnote in Tagen'
jobProfile: 'Jobprofil des Einsatzes'
product: 'Produkt der Reise'
dateFrom: 'Beginn des Einsatzes'
dateTo: 'Ende des Einsatzes'
diffInDays: 'Tage bis zum Beginn des Einsatzes'
tasks: 'Noch offene Punkte der Saison-Checkliste, kommagetrennt'
workMonth: 'Monat der Arbeitszeiten, z. B. "März 2026"'
+15 -4
View File
@@ -7,8 +7,14 @@ doctrine:
# either here or in the DATABASE_URL env var (see .env file) # either here or in the DATABASE_URL env var (see .env file)
#server_version: '15' #server_version: '15'
orm: orm:
auto_generate_proxy_classes: true enable_native_lazy_objects: true
enable_lazy_ghost_objects: true # Keep implicit controller-argument entity resolution on (many actions type-hint
# an entity against a matching {id}/{uuid} route param without #[MapEntity]).
# Setting this explicitly silences the "default will flip to false" deprecation;
# the separate "automapping is deprecated" notice stays until those actions are
# migrated to #[MapEntity], which is its own task.
controller_resolver:
auto_mapping: true
report_fields_where_declared: true report_fields_where_declared: true
validate_xml_mapping: true validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
@@ -31,12 +37,17 @@ when@test:
dbal: dbal:
# "TEST_TOKEN" is typically set by ParaTest # "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%' dbname_suffix: '_test%env(default::TEST_TOKEN)%'
# schema:create otherwise emits MySQL 8's platform default (utf8mb4_0900_ai_ci),
# while the migrations that built dev and production declare utf8mb4_unicode_ci.
# Different collation means different string comparison and ORDER BY semantics,
# so tests would sort and match differently than production.
default_table_options:
charset: utf8mb4
collation: utf8mb4_unicode_ci
when@prod: when@prod:
doctrine: doctrine:
orm: orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
query_cache_driver: query_cache_driver:
type: pool type: pool
pool: doctrine.system_cache_pool pool: doctrine.system_cache_pool
+6
View File
@@ -10,3 +10,9 @@ flagception:
datev_email: datev_email:
default: false default: false
env: FEATURE_DATEV_EMAIL env: FEATURE_DATEV_EMAIL
myep_login:
default: false
env: FEATURE_MYEP_LOGIN
upload_replacement:
default: false
env: FEATURE_UPLOAD_REPLACEMENT
+1 -12
View File
@@ -1,17 +1,6 @@
# Read the documentation at https://github.com/thephpleague/flysystem-bundle/blob/master/docs/1-getting-started.md # Read the documentation at https://github.com/thephpleague/flysystem-bundle/blob/master/docs/1-getting-started.md
flysystem: flysystem:
storages: storages:
xml_export.storage:
adapter: 'local'
options:
directory: '%env(resolve:XML_EXPORT_PATH)%'
xml_dump.storage: xml_dump.storage:
adapter: 'local' local:
options:
directory: '%kernel.project_dir%/var/bpn' directory: '%kernel.project_dir%/var/bpn'
# adapter: 'sftp'
# options:
# host: '%env(SFTP_XML_EXPORT_HOST)%'
# port: '%env(int:SFTP_XML_EXPORT_PORT)%'
# username: '%env(SFTP_XML_EXPORT_USER)%'
# password: '%env(SFTP_XML_EXPORT_PASSWORD)%'
+21
View File
@@ -5,6 +5,11 @@ framework:
http_method_override: false http_method_override: false
handle_all_throwables: true 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. # 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. # Remove or comment this section to explicitly disable session support.
session: session:
@@ -23,6 +28,22 @@ framework:
router: router:
default_uri: '%env(APP_BASE_URI)%' default_uri: '%env(APP_BASE_URI)%'
# Every call to the sibling MyE&P application, both the token request and the API
# itself. Paths are given relative to the base uri, so without a leading slash.
http_client:
scoped_clients:
myep.client:
base_uri: '%env(MYEP_API_BASE_URL)%'
timeout: 10
max_duration: 30
headers:
Accept: application/json
retry_failed:
max_retries: 2
# 401 is deliberately absent: it needs the cached token dropped
# before the retry, which App\MyEp\Api\ApiClient does itself.
http_codes: [423, 425, 429, 500, 502, 503, 504]
when@test: when@test:
framework: framework:
test: true test: true
+7 -1
View File
@@ -17,7 +17,13 @@ liip_imagine:
format: jpeg format: jpeg
jpeg_quality: 75 jpeg_quality: 75
filters: filters:
thumbnail: { size: [ 128, 128 ], mode: outbound } # Phone photos carry their orientation in EXIF rather than in the pixels, so
# without this the table renders them sideways while every other filter set,
# which has always had it, shows them upright.
auto_rotate: ~
# outbound crops to the box but never scales up, so a photo narrower than
# 128px came out oblong and the rounded-full avatar with it.
thumbnail: { size: [ 128, 128 ], mode: outbound, allow_upscale: true }
profile: profile:
format: jpeg format: jpeg
jpeg_quality: 75 jpeg_quality: 75
+6 -1
View File
@@ -1,3 +1,8 @@
framework: framework:
mailer: mailer:
dsn: '%env(MAILER_DSN)%' # "main" has to stay first: it is the transport every mail without an explicit
# X-Transport header goes out over, which is all of them apart from the teamer
# mailing. Reordering this quietly moves the whole application to Mailjet.
transports:
main: '%env(MAILER_DSN)%'
mailing: '%env(MAILING_MAILER_DSN)%'
+36
View File
@@ -12,6 +12,29 @@ framework:
retry_strategy: retry_strategy:
max_retries: 3 max_retries: 3
multiplier: 2 multiplier: 2
# Bulk teamer mailings, kept off "async" so that a mailing of several hundred
# recipients cannot delay a password reset. Its own cron worker consumes this
# queue and nothing else; the two never see each other's messages, because the
# doctrine transport filters on the queue_name column. See docs/operations.md
# for both cron entries.
#
# The retry delays are spelled out because the default is one second: with a
# multiplier alone, all three retries of a mail are spent within seconds, so a
# short hiccup at the mail provider would drop a whole mailing into "failed" at
# once - the very thing sending one message per recipient is meant to avoid.
# A minute, three and nine outlive any realistic blip and span several runs of
# a worker that only lives for a few minutes at a time.
mailing:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: mailing
use_notify: true
check_delayed_interval: 60000
retry_strategy:
max_retries: 3
delay: 60000
multiplier: 3
max_delay: 900000
failed: 'doctrine://default?queue_name=failed' failed: 'doctrine://default?queue_name=failed'
sync: 'sync://' sync: 'sync://'
@@ -20,6 +43,9 @@ framework:
Symfony\Component\Notifier\Message\ChatMessage: async Symfony\Component\Notifier\Message\ChatMessage: async
Symfony\Component\Notifier\Message\SmsMessage: async Symfony\Component\Notifier\Message\SmsMessage: async
# only the fan-out, the mails it produces carry an X-Bus-Transport header
App\Message\SendTeamerMailing: mailing
# Route your messages to the transports # Route your messages to the transports
# 'App\Message\YourMessage': async # 'App\Message\YourMessage': async
@@ -30,3 +56,13 @@ when@dev:
Symfony\Component\Mailer\Messenger\SendEmailMessage: sync Symfony\Component\Mailer\Messenger\SendEmailMessage: sync
Symfony\Component\Notifier\Message\ChatMessage: sync Symfony\Component\Notifier\Message\ChatMessage: sync
Symfony\Component\Notifier\Message\SmsMessage: sync Symfony\Component\Notifier\Message\SmsMessage: sync
App\Message\SendTeamerMailing: sync
when@test:
framework:
messenger:
routing:
Symfony\Component\Mailer\Messenger\SendEmailMessage: sync
Symfony\Component\Notifier\Message\ChatMessage: sync
Symfony\Component\Notifier\Message\SmsMessage: sync
App\Message\SendTeamerMailing: sync
+61 -8
View File
@@ -2,7 +2,10 @@ monolog:
channels: channels:
- deprecation - deprecation
- bpn - bpn
- saas
- myep - myep
- audit
- api
handlers: handlers:
main: main:
@@ -11,7 +14,7 @@ monolog:
handler: exceptions handler: exceptions
excluded_http_codes: [404, 405] excluded_http_codes: [404, 405]
buffer_size: 50 buffer_size: 50
channels: ["!bpn", "!myep"] channels: ["!bpn", "!saas", "!myep", "!audit", "!api"]
exceptions: exceptions:
type: rotating_file type: rotating_file
path: "%kernel.logs_dir%/framework.%kernel.environment%.log" path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
@@ -23,20 +26,52 @@ monolog:
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log' path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
max_files: 5 max_files: 5
level: info level: info
saas:
channels: ["saas"]
type: rotating_file
path: '%kernel.logs_dir%/saas.%kernel.environment%.log'
max_files: 5
level: info
myep: myep:
channels: [ "myep" ] channels: ["myep"]
type: rotating_file type: rotating_file
path: '%kernel.logs_dir%/myep.%kernel.environment%.log' path: '%kernel.logs_dir%/myep.%kernel.environment%.log'
max_files: 5 max_files: 5
level: info level: info
audit:
channels: [ "audit" ]
type: rotating_file
path: '%kernel.logs_dir%/audit.%kernel.environment%.log'
max_files: 5
level: info
# Form submissions posted by the sibling websites. Own file because it is machine
# traffic nobody watches between incidents, and it must stay out of the audit table.
api:
channels: [ "api" ]
type: rotating_file
path: '%kernel.logs_dir%/api.%kernel.environment%.log'
max_files: 30
level: info
database: database:
channels: ["myep"] channels: ["audit"]
type: service type: service
id: App\Logging\DatabaseHandler id: App\Logging\DatabaseHandler
console: console:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false
channels: ["!event", "!bpn", "!doctrine", "!console"] channels: ["!event", "!bpn", "!saas", "!myep", "!api", "!doctrine", "!console"]
when@dev:
monolog:
handlers:
# Single catch-all file so everything is visible in one place during
# development. The handlers above are error-level or channel-bound,
# which makes ordinary app output invisible locally.
dev:
type: stream
path: '%kernel.logs_dir%/%kernel.environment%.log'
level: debug
channels: ["!event", "!doctrine"]
when@test: when@test:
monolog: monolog:
@@ -61,7 +96,7 @@ when@prod:
handler: exceptions handler: exceptions
excluded_http_codes: [404, 405] excluded_http_codes: [404, 405]
buffer_size: 50 buffer_size: 50
channels: ["!bpn", "!myep"] channels: ["!bpn", "!saas", "!myep", "!audit", "!api"]
exceptions: exceptions:
type: rotating_file type: rotating_file
path: "%kernel.logs_dir%/framework.%kernel.environment%.log" path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
@@ -73,17 +108,35 @@ when@prod:
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log' path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
max_files: 5 max_files: 5
level: info level: info
saas:
channels: ["saas"]
type: rotating_file
path: '%kernel.logs_dir%/saas.%kernel.environment%.log'
max_files: 5
level: info
myep: myep:
channels: [ "myep" ] channels: ["myep"]
type: rotating_file type: rotating_file
path: '%kernel.logs_dir%/myep.%kernel.environment%.log' path: '%kernel.logs_dir%/myep.%kernel.environment%.log'
max_files: 5 max_files: 5
level: info level: info
audit:
channels: [ "audit" ]
type: rotating_file
path: '%kernel.logs_dir%/audit.%kernel.environment%.log'
max_files: 5
level: info
api:
channels: [ "api" ]
type: rotating_file
path: '%kernel.logs_dir%/api.%kernel.environment%.log'
max_files: 30
level: info
database: database:
channels: ["myep"] channels: ["audit"]
type: service type: service
id: App\Logging\DatabaseHandler id: App\Logging\DatabaseHandler
console: console:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false
channels: ["!event", "!doctrine", "!bpn", "!myep"] channels: ["!event", "!doctrine", "!bpn", "!saas", "!myep", "!audit", "!api"]
-6
View File
@@ -8,12 +8,6 @@ nelmio_security:
content_type: content_type:
nosniff: true nosniff: true
# forces Microsoft's XSS-Protection with
# its block mode
xss_protection:
enabled: true
mode_block: true
# Send a full URL in the `Referer` header when performing a same-origin request, # Send a full URL in the `Referer` header when performing a same-origin request,
# only send the origin of the document to secure destination (HTTPS->HTTPS), # only send the origin of the document to secure destination (HTTPS->HTTPS),
# and send no header to a less secure destination (HTTPS->HTTP). # and send no header to a less secure destination (HTTPS->HTTP).
+32
View File
@@ -36,6 +36,38 @@ oneup_uploader:
namer: app.upload_namer namer: app.upload_namer
storage: storage:
directory: '%kernel.project_dir%/uploads/driver_license' 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'
# Saisonunterlagen. Like receipt above, both the mapping key and the directory have to
# be exactly the Upload::TYPE_* value - out for what the office issues to one teamer,
# in for what that teamer sends back signed.
season_document_out:
frontend: dropzone
use_orphanage: true
namer: app.upload_namer
storage:
directory: '%kernel.project_dir%/uploads/season_document_out'
season_document_in:
frontend: dropzone
use_orphanage: true
namer: app.upload_namer
storage:
directory: '%kernel.project_dir%/uploads/season_document_in'
# Signed Arbeitszeitnachweise coming back from a teamer. Same rule again: the key and
# the directory are exactly Upload::TYPE_WORK_MONTH.
work_month:
frontend: dropzone
use_orphanage: true
namer: app.upload_namer
storage:
directory: '%kernel.project_dir%/uploads/work_month'
chunks: chunks:
maxage: 86400 maxage: 86400
storage: storage:
+5
View File
@@ -0,0 +1,5 @@
framework:
property_info:
# Keep the current (7.x) default; enabling the constructor extractor is a
# behaviour change to evaluate on its own, not part of the 6.4 -> 7.4 bump.
with_constructor_extractor: false
+22 -1
View File
@@ -7,19 +7,38 @@ security:
entity: entity:
class: App\Entity\User class: App\Entity\User
property: email property: email
# The sibling websites post as this one; it exists in configuration only. An API caller
# must never be a row in the user table, where it would be a login to the UI as well.
api_user_provider:
memory:
users:
api: { roles: [ ROLE_API ] }
n8n: { roles: [ ROLE_API_ASSIGNMENTS ] }
role_hierarchy: role_hierarchy:
ROLE_ADMIN: [ ROLE_ADMINISTRATIVE ] ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ]
ROLE_MANAGER: [ ROLE_ADMINISTRATIVE ] ROLE_MANAGER: [ ROLE_ADMINISTRATIVE ]
firewalls: firewalls:
dev: dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/ pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false security: false
# Stateless and key-authenticated: a machine posts here inside a visitor's form
# submission on another site, so there is no session, no login form and no redirect.
# Must stay above "main", whose pattern is ^/.
api:
pattern: ^/api
stateless: true
provider: api_user_provider
access_token:
token_handler: App\Security\AccessToken\ApiKeyTokenHandler
token_extractors:
- App\Security\AccessToken\ApiKeyExtractor
main: main:
lazy: true lazy: true
provider: bpn_user_provider provider: bpn_user_provider
custom_authenticators: custom_authenticators:
- App\Security\MyEpAuthenticator
- App\Security\BpnAuthenticator - App\Security\BpnAuthenticator
user_checker: App\Security\UserChecker user_checker: App\Security\UserChecker
switch_user: switch_user:
@@ -28,6 +47,8 @@ security:
path: app_security_logout path: app_security_logout
target: app_security_login target: app_security_login
access_control: access_control:
# The firewall authenticates, the controller requires ROLE_API
- { path: '^/api', roles: PUBLIC_ACCESS, requires_channel: https }
- { path: '^/_uploader', roles: ROLE_USER, requires_channel: https } - { path: '^/_uploader', roles: ROLE_USER, requires_channel: https }
- { path: '^/', roles: PUBLIC_ACCESS, requires_channel: https } - { path: '^/', roles: PUBLIC_ACCESS, requires_channel: https }
+7
View File
@@ -0,0 +1,7 @@
# Enabled for #[MapRequestPayload] on the api controllers - nothing else in this application
# serializes. ALLOW_EXTRA_ATTRIBUTES stays at its default (true) on purpose: the TYPO3 form
# payload grows keys without a release here, and rejecting an unknown one would turn every
# later mapping change into a 400.
framework:
serializer:
name_converter: ~
+2 -1
View File
@@ -14,4 +14,5 @@ when@test:
intercept_redirects: false intercept_redirects: false
framework: framework:
profiler: { collect: false } profiler:
collect_serializer_data: true
+59 -20
View File
@@ -3,31 +3,70 @@ zenstruck_schedule:
mailer: mailer:
service: mailer service: mailer
default_to: [email protected] default_to: [email protected]
default_from: info@ep-reisen.de default_from: noreply@ep-reisen.de
subject_prefix: "[MyE&P-Team]" subject_prefix: "[E&P-Team]"
schedule_extensions: schedule_extensions:
email_on_failure: email_on_failure:
to: [email protected] enabled: true
to: [email protected]
tasks: when@prod:
- task: app:bpn-import zenstruck_schedule:
frequency: '0 3 * * *' tasks:
description: "Imports dates, products and hotels from BusProNet" # 03:20 rather than 03:00: MyE&P mirrors from BusProNet hourly overnight, on the
# hour, so an import at 03:00 can read a half written export.
- task: app:myep-import
frequency: '20 3 * * *'
description: "Imports travel dates, products and hotels from the MyE&P API"
- task: app:cron - task: app:cron
frequency: '0 1 * * *' frequency: '0 1 * * *'
description: "Executes several tasks triggered on a daily basis" description: "Executes several tasks triggered on a daily basis"
- task: app:teamer-status - task: app:teamer-status
frequency: '30 1 * * *' frequency: '30 1 * * *'
description: "Updates status of teamers depending on their disposition" description: "Updates status of teamers depending on their disposition"
- task: oneup:uploader:clear-orphans - task: app:saas:work-hours
frequency: '30 * * * *' frequency: '0 4 10 * *'
description: "Removes orphaned uploads" description: "Imports last month's working hours of season teamers from SaaS"
- task: app:cleanup:xml-dumps - task: oneup:uploader:clear-orphans
frequency: "0 2 * * *" frequency: '30 * * * *'
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging" 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"
when@staging:
zenstruck_schedule:
mailer:
subject_prefix: "[E&P-Team STAGING]"
tasks:
- task: app:myep-import
frequency: '50 3 * * *'
description: "Imports travel dates, products and hotels from the MyE&P API"
- 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: app:saas:work-hours
frequency: '0 4 10 * *'
description: "Imports last month's working hours of season teamers from SaaS"
- 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"
+271
View File
@@ -0,0 +1,271 @@
# Questionnaire a teamer fills in after a placement with bus escort, in addition to
# teamer_feedback.yaml and on the same page (App\Service\Questionnaire\DispositionContext).
#
# Replaces the Google form "Bus-Feedback 25-26"; the question-by-question spec, with every decision
# taken, is kept locally outside the repository. Questions whose answer the
# disposition already knows (travel date, destination, Reiseleitung, first pickup) are not asked:
# they are snapshotted into the response's context instead.
#
# Labels, help texts, section titles and option labels may be edited in place. Renaming or
# removing a question or option key, or changing a type, needs a new version: copy this file to
# bus_feedback.v1.yaml first, then bump `version` here. Submitted answers are keyed by it.
#
# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it.
#
# Question fields: key, label, type (text | textarea | choice | multi | date | rating),
# required (default false), help, widget (choice only: radio | dropdown, default radio),
# options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only).
key: bus_feedback
version: 1
title: "Bus-Feedback"
sections:
- key: bus_trip
title: "Busfahrt"
questions:
- key: direction
label: "Hin- oder Rückfahrt"
type: choice
required: true
options:
- { key: outbound, label: "Hinfahrt" }
- { key: return, label: "Rückfahrt" }
- key: bus_company
label: "Bus-Unternehmen:"
type: choice
widget: dropdown
required: true
options:
- { key: boonk, label: "Boonk Reisen" }
- { key: piccolonia, label: "Piccolonia" }
- { key: heess, label: "Heeß" }
- { key: finkbeiner, label: "Finkbeiner" }
- { key: other, label: "Anderes Unternehmen" }
- key: bus_company_other
label: "Name des Busunternehmens:"
type: text
required: true
show_if: { question: bus_company, equals: other }
- key: bus_team
title: "Busteam"
questions:
- key: driver_names
label: "Busfahrer-Namen:"
type: text
required: true
- key: bus_first_pickup
title: "1. Abfahrtsort"
questions:
- key: on_time_at_first_pickup
label: "Bus pünktlich am 1. Abfahrtsort?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: delay_at_first_pickup
label: "Wieviel Verspätung hatte der Bus am 1. Abfahrtsort?"
type: text
required: true
show_if: { question: on_time_at_first_pickup, equals: "no" }
- key: bus_rating
title: "Bewertung Bus & Fahrer"
questions:
- key: overall_impression
label: "Gesamteindruck des Busses"
type: choice
widget: dropdown
required: true
options:
- { key: very_good, label: "Sehr gut" }
- { key: good, label: "Gut" }
- { key: ok, label: "OK" }
- { key: not_so_good, label: "nicht so gut" }
- { key: bad, label: "schlecht" }
- key: vehicle_age
label: "Alter des Fahrzeugs"
type: choice
widget: dropdown
required: true
options:
- { key: very_new, label: "Sehr neu" }
- { key: new, label: "neu" }
- { key: ok, label: "OK" }
- { key: somewhat_old, label: "etwas älter" }
- { key: very_old, label: "sehr alt" }
- key: seating_comfort
label: "Sitzkomfort im Bus"
type: choice
widget: dropdown
required: true
options:
- { key: very_good, label: "Sehr gut" }
- { key: good, label: "Gut" }
- { key: ok, label: "OK" }
- { key: not_so_good, label: "nicht so gut" }
- { key: bad, label: "schlecht" }
- key: driver_friendliness
label: "Freundlichkeit der Fahrer"
type: choice
widget: dropdown
required: true
options:
- { key: very_friendly, label: "Sehr freundlich" }
- { key: friendly, label: "freundlich" }
- { key: ok, label: "OK" }
- { key: not_so_friendly, label: "nicht so freundlich" }
- { key: unfriendly, label: "unfreundlich" }
- key: breaks
label: "Pausenzeiten"
type: choice
widget: dropdown
required: true
options:
- { key: very_good, label: "Sehr gut" }
- { key: good, label: "Gut" }
- { key: ok, label: "OK" }
- { key: not_so_good, label: "nicht so gut" }
- { key: unacceptable, label: "geht gar nicht" }
- key: temperature
label: "Temperatur im Bus"
type: choice
widget: dropdown
required: true
options:
- { key: very_good, label: "Sehr gut" }
- { key: good, label: "Gut" }
- { key: ok, label: "OK" }
- { key: not_so_good, label: "nicht so gut" }
- { key: unacceptable, label: "geht gar nicht" }
- key: guest_behaviour
label: "Verhalten der Gäste im Bus"
type: choice
widget: dropdown
required: true
options:
- { key: very_good, label: "Sehr gut" }
- { key: good, label: "Gut" }
- { key: ok, label: "OK" }
- { key: not_so_good, label: "nicht so gut" }
- { key: unacceptable, label: "geht gar nicht" }
- key: bus_pickups
title: "Ladestellen"
questions:
- key: drivers_knew_pickups
label: "Waren den Fahrern alle Abfahrtsorte bekannt?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: unknown_pickups
label: "Welche Abfahrtsorte waren nicht bekannt? Gab es dadurch Probleme?"
type: textarea
required: true
show_if: { question: drivers_knew_pickups, equals: "no" }
- key: bus_destination
title: "Zielkenntnis"
questions:
- key: drivers_knew_destination
label: "War den Busfahrern der Zielort und die Ausladestelle bekannt?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: bus_equipment
title: "Bus, Technik & Allgemeines"
questions:
- key: video
label: "Video/DVD-Gerät"
type: choice
widget: dropdown
required: true
options:
- { key: working, label: "funktionstüchtig" }
- { key: broken, label: "defekt" }
- { key: missing, label: "nicht vorhanden" }
- key: wc
label: "WC"
type: choice
widget: dropdown
required: true
options:
- { key: working, label: "funktionstüchtig" }
- { key: broken, label: "defekt" }
- { key: closed, label: "nicht geöffnet" }
- key: luggage
label: "Gepäck"
type: choice
widget: dropdown
required: true
options:
- { key: trailer, label: "Anhänger" }
- { key: ski_box, label: "Skikoffer" }
- { key: trailer_and_ski_box, label: "Anhänger und Skikoffer" }
- { key: none, label: "Kein Anhänger oder Skikoffer" }
- key: smoking
label: "Rauchen im Bus"
type: choice
widget: dropdown
required: true
options:
- { key: driver, label: "Busfahrer hat im Bus geraucht" }
- { key: driver_and_guests, label: "Busfahrer und Gäste haben im Bus geraucht" }
- { key: nobody, label: "Es wurde nicht geraucht" }
- key: snow_chains
label: "Schneeketten"
type: choice
widget: dropdown
required: true
options:
- { key: fitted, label: "Fahrer hat Ketten aufgezogen" }
- { key: not_needed, label: "Ketten wurden nicht gebraucht" }
- { key: not_fitted, label: "Ketten nicht aufgezogen (trotz starkem Schneefall)" }
- key: bus_damage
title: "Schäden"
questions:
- key: bus_damage
label: "Entstanden während der Fahrt Schäden am Bus?"
type: choice
widget: dropdown
required: true
options:
- { key: none, label: "Keine Schäden" }
- { key: settled_with_driver, label: "Bereits mit Fahrer abgeklärt" }
- { key: claim_expected, label: "Da wird vom Busunternehmen noch was kommen" }
- key: damage_description
label: "Was ist passiert?"
type: textarea
required: true
show_if: { question: bus_damage, equals: claim_expected }
- key: bus_remarks
title: "Anmerkungen zur Busfahrt"
questions:
- key: bus_remarks
label: "Das ging gar nicht / ist besonders hervorzuheben:"
type: textarea
+137
View File
@@ -0,0 +1,137 @@
# Questionnaire every teamer fills in after a placement (Disposition, not SeasonAssignment).
#
# Replaces the Google form "RL-Feedback 25-26"; the question-by-question spec, with every decision
# taken, is kept locally outside the repository. A teamer who escorted the bus
# also gets bus_feedback.yaml, on the same page. Questions whose answer the disposition already
# knows (name, job profile, house, arrival day) are not asked: they are snapshotted into the
# response's context instead.
#
# Labels, help texts, section titles and option labels may be edited in place. Renaming or
# removing a question or option key, or changing a type, needs a new version: copy this file to
# teamer_feedback.v1.yaml first, then bump `version` here. Submitted answers are keyed by it.
#
# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it.
#
# Question fields: key, label, type (text | textarea | choice | multi | date | rating),
# required (default false), help, widget (choice only: radio | dropdown, default radio),
# options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only).
key: teamer_feedback
version: 1
title: "Wochenteamer:innen-Feedback"
description: |-
Nur durch euer Feedback können wir besser werden! 🙂
Bei Bewertunsskalen: 1= schlecht, 5 = sehr gut
sections:
- key: organisation
title: "Organisation & Ablauf"
questions:
- key: office_briefing_rating
label: "Wie gut hast du dich vorab vom Büro gebrieft gefühlt?"
type: rating
required: true
- key: office_briefing_low_reason
label: "Wenn 1 - 3 Sterne Bewertung, warum?"
type: textarea
required: true
show_if: { question: office_briefing_rating, lte: 3 }
- key: team_week_seminar
label: "War das Wochenteam-Seminar hilfreich für dich? Warst du bzw. wurdest du gut vorbereitet?"
type: choice
widget: dropdown
required: true
options:
- { key: very_helpful, label: "Ja, die Schulung war sehr hilfreich und ich fühlte mich bestens vorbereitet." }
- { key: mostly_helpful, label: "Ja, größtenteils – einige Themen hätte man noch vertiefen können." }
- { key: partly_helpful, label: "Es ging – die Schulung war teilweise hilfreich, aber nicht ausreichend für alle Aufgaben." }
- { key: rather_not_helpful, label: "Eher nein – ich fühlte mich in vielen Bereichen unvorbereitet." }
- { key: not_helpful, label: "Nein, die Schulung war kaum hilfreich und hat mich nicht auf den Arbeitsalltag vorbereitet." }
- { key: not_attended, label: "Ich habe nicht teilgenommen." }
- key: onsite_organisation_rating
label: "Wie gut lief die Reiseorganisation vor Ort?"
type: rating
required: true
- key: had_organisational_problems
label: "Gab es organisatorische Probleme?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: organisational_problems
label: "Wenn ja, welche?"
type: text
show_if: { question: had_organisational_problems, equals: "yes" }
- key: communication
title: "Kommunikation & Zusammenarbeit"
questions:
- key: teamer_collaboration_rating
label: "Wie lief die Kommunikation & Zusammenarbeit mit den E&P Teamer:innen vor Ort während der Reise?"
type: rating
required: true
- key: support_wished
label: "Welche Unterstützung hättest du dir zusätzlich gewünscht?"
type: textarea
- key: house_manager_problem_handling
label: "Wie ist deine Hausleitung mit Problemen vor Ort umgegangen?"
type: choice
widget: dropdown
required: true
options:
- { key: very_good, label: "Sehr gut – Probleme wurden schnell, fair und lösungsorientiert behandelt." }
- { key: good, label: "Gut – die meisten Anliegen wurden ernst genommen und bearbeitet." }
- { key: mixed, label: "Teils gut/teils schlecht – manche Probleme wurden gelöst, andere nicht." }
- { key: rather_poor, label: "Eher schlecht – es wurde wenig unternommen oder nicht auf Augenhöhe kommuniziert." }
- { key: very_poor, label: "Sehr schlecht – Probleme wurden ignoriert oder verschlimmert." }
- key: felt_overwhelmed
label: "Gab es Momente, in denen du dich überfordert gefühlt hast?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: overwhelmed_reason
label: "Wenn ja, wodurch?"
type: textarea
show_if: { question: felt_overwhelmed, equals: "yes" }
- key: improvement
title: "Verbesserung"
questions:
- key: wished_differently
label: "Was hättest du dir anders oder besser gewünscht?"
type: textarea
- key: would_work_again
label: "Würdest du wieder als Reiseleitung für E&P arbeiten?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: would_not_work_again_reason
label: "Wenn nein, warum nicht?"
type: textarea
show_if: { question: would_work_again, equals: "no" }
- key: other
title: "Sonstiges"
questions:
- key: anything_else
label: |-
Du hast nun schon vieles beantwortet,
aber nirgendwo konntest du folgende Aspekte loswerden:
type: textarea
+1 -1
View File
@@ -1,4 +1,4 @@
when@dev: when@dev:
_errors: _errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml' resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error prefix: /_error
+2 -2
View File
@@ -1,8 +1,8 @@
when@dev: when@dev:
web_profiler_wdt: web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'
prefix: /_wdt prefix: /_wdt
web_profiler_profiler: web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'
prefix: /_profiler prefix: /_profiler
+316 -8
View File
@@ -1,14 +1,151 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
parameters: 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_manager: '%env(int:APP_BPN_CRM_ID_MANAGER)%'
bpn_crm_id_teamer: '%env(int:APP_BPN_CRM_ID_TEAMER)%' bpn_crm_id_teamer: '%env(int:APP_BPN_CRM_ID_TEAMER)%'
bpn_default_hotel_code: '%env(default::APP_BPN_DEFAULT_HOTEL_CODE)%' bpn_default_hotel_code: '%env(default::APP_BPN_DEFAULT_HOTEL_CODE)%'
# BusPro CRM selection id => hotel code. A person holding a selected attribute from this
# list is a house manager for that house. Matched by id and not by the "Hausleitung XXX"
# label, so renaming a selection in BusPro cannot silently grant or revoke a house.
# The commented entries are BusPro selections whose code is not a house in the "houses"
# parameter below: granting them would let someone in who then sees no assignments and
# no dispositions at all. Uncomment one once its house exists. The reverse gap is fine -
# not every house has a selection.
bpn_crm_house_manager_ids:
1299: 'SSL'
1300: 'SST'
1301: 'MVK'
# 1302: 'ASB'
1303: 'LPJ'
1304: 'DKS'
1305: 'DGS'
1306: 'DPW'
1307: 'DKI'
1308: 'DWW'
1309: 'PMV'
# 1352: 'ASG'
1371: 'ASC'
1373: 'PCJ'
1374: 'SBW'
# 1375: 'UCH'
# 1376: 'SZO'
1377: 'KHH'
1459: 'SVS'
1461: 'SHM'
1462: 'AGR'
# Shared key the sibling websites authenticate their form submissions with. Empty here on
# purpose - the real value belongs in the environment of each stage, and an empty key
# rejects every request instead of letting them through.
application_form_api_key: '%env(string:APPLICATION_FORM_API_KEY)%'
# Shared key n8n reads the assignments feed with (GET /api/assignments). Same rules as above.
n8n_api_key: '%env(string:N8N_API_KEY)%'
# What the n8n assignments feed covers: assignments in these houses (matched against the first
# three characters of destination.hotelCode) for these job profiles - guide, course and
# service-team work. Job profile ids are database ids, so check them against production.
assignment_export.house_codes: [ DPW, DGS, DWW, DKI, DKS ]
assignment_export.job_profile_ids:
- 5 # Reiseleitung
- 9 # Reiseleitung Ski
- 10 # Reiseleitung Snowboard
- 3 # Kursleitung Ski Anfänger:innen
- 14 # Kursleitung Ski Fortgeschrittene
- 18 # Kursleitung Ski Anfänger:innen + Fortgeschrittene
- 36 # Kursleitung Ski Kinder Anfänger:innen & Kinderbetreuung
- 37 # Kursleitung Ski Kinder Fortgeschrittene
- 38 # Kursleitung Ski Kinder
- 4 # Kursleitung Snowboard Anfänger:innen
- 15 # Kursleitung Snowboard Fortgeschrittene
- 19 # Kursleitung Snowboard Anfänger:innen + Fortgeschrittene
- 47 # Kursleitung Snowboard Kinder/Jugendliche Anfänger:innen
- 20 # Reise- und Kursleitung Ski Anfänger:innen
- 21 # Reise- und Kursleitung Ski Fortgeschrittene
- 22 # Reise- und Kursleitung Ski Anfänger:innen + Fortgeschrittene
- 23 # Reise- und Kursleitung Snowboard Anfänger:innen
- 24 # Reise- und Kursleitung Snowboard Fortgeschrittene
- 25 # Reise- und Kursleitung Snowboard Anfänger:innen + Fortgeschrittene
- 6 # Serviceteam 40h (ST40)
- 13 # Serviceteam 25h (ST25)
- 16 # Serviceteam 10h (ST10)
# Booking link the application form receipt mail invites the applicant to. A deployment
# value rather than part of the wording, so a new booking system does not need an edit in
# every stage's mail text.
application_form_booking_url: '%env(string:APPLICATION_FORM_BOOKING_URL)%'
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%' default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
# Per deployment and not per environment: staging and production run the same compiled
# environment and differ only in their .env.local. The csv processor turns an unset
# variable into an empty list, which switches the filtering off - so production is
# unaffected without anything having to be remembered at deploy time.
mail_recipient_whitelist: '%env(csv:APP_MAIL_RECIPIENT_WHITELIST)%'
teamer_inactive_period: '-2 years' teamer_inactive_period: '-2 years'
# The stand-in PDF served instead of real uploads while the upload_replacement feature is
# on. Relative to the project dir. Which file is a fixed fact of the repo; whether it is
# used at all is the feature flag's job alone.
upload_replacement_file: 'assets/pdf/chicken.pdf'
# The contract upload period, counted from the creation of the disposition. This is the
# single definition: the reminder mail, the "Honorarvertrag fällig" flag on the teamer
# dashboard, the admin list of overdue contracts and the wording of the mails all read it,
# so they cannot mark the same disposition due on three different days.
contract_upload_deadline_days: 5
# The invoice upload period, counted from the end of the assignment. The teamer is
# reminded the day after the assignment ends and again on the last day of this period.
invoice_upload_deadline_days: 14
# Messenger transport the mails of a teamer mailing are queued on. An X-Bus-Transport
# header wins over the routing in messenger.yaml (see SendersLocator::getSenders),
# so this is what keeps a mailing off the shared queue - and what has to be switched
# back to "sync" further down, or a mailing would need a running worker to arrive
# in dev and test while every other mail is sent right away.
mailing_bus_transport: 'mailing'
# Dates (MM-DD) on which teamers must re-confirm their personal data.
personal_data_check_deadlines:
- '04-01'
- '10-01'
# The season window as MM-DD. It wraps the turn of the year and is named after the
# calendar year it starts in ("Saison 2024/25"). The teamer info modal buckets a
# teamer's dispositions by the season their assignment starts in; one whose
# assignment starts outside this window is counted as "außerhalb der Saison".
season_start: '11-01'
season_end: '04-01'
# The teamer questionnaires after a placement (config/questionnaires/teamer_feedback.yaml, plus
# bus_feedback.yaml for a bus escort) are offered for placements that ended within this many
# days. Without a window every past placement would show up as pending on the teamers' dashboards.
questionnaire_window_days: 30
# How far ahead of the destination's first travel day a season assignment's checklist
# entries fall due. A relative date expression, applied to destination.dateFrom, and the
# single definition of both deadlines: the first is this offset, the second defaults to
# the first. Per-entry overrides are stored on the task row; this is what they override.
season_task_deadline_offset: '-1 month'
# What the staffing matrix generates (App\Service\Assignment\AssignmentPlanner), keyed by
# App\Enum\StaffingRole / StaffingFee value. Ids and not names, so renaming a job profile
# or fee in the admin cannot silently change what the drafts get. A missing or deleted id
# makes App\Service\Assignment\StaffingResolver refuse rather than skip.
auto_assignment_job_profiles:
guide: 5 # Reiseleitung
ski_beginner: 3 # Kursleitung Ski Anfänger:innen
ski_advanced: 14 # Kursleitung Ski Fortgeschrittene
guide_ski_beginner: 20 # Reise- und Kursleitung Ski Anfänger:innen
guide_ski_advanced: 21 # Reise- und Kursleitung Ski Fortgeschrittene
auto_assignment_fees:
guiding: 3 # Skigebietsguiding (pro Einheit)
course: 4 # Ski-/Snowboardkurs (pro Stunde)
# Houses, hotel code: name. The name is used both as a label and to match # Houses, hotel code: name. The name is used both as a label and to match
# destination.hotel, the code to match destination.hotelCode. # destination.hotel, the code to match destination.hotelCode.
houses: houses:
@@ -38,11 +175,34 @@ services:
autoconfigure: true autoconfigure: true
bind: bind:
$tempDir: '%kernel.project_dir%/temp' $tempDir: '%kernel.project_dir%/temp'
$logger: '@monolog.logger.myep' # Application audit channel, not a MyE&P one: this is what feeds the
# log table via App\Logging\DatabaseHandler. Unbinding a service from
# it drops its records from that table.
$logger: '@monolog.logger.audit'
$houses: '%houses%' $houses: '%houses%'
$xmlExport: '@xml_export.storage'
$xmlDump: '@xml_dump.storage' $xmlDump: '@xml_dump.storage'
$teamerInactivePeriod: '%teamer_inactive_period%' $teamerInactivePeriod: '%teamer_inactive_period%'
$contractUploadDeadlineDays: '%contract_upload_deadline_days%'
$invoiceUploadDeadlineDays: '%invoice_upload_deadline_days%'
# Only the path enters the container - the file itself is parsed on demand by
# App\Config\EmailTextCatalog, since its contents matter on few requests.
$emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml'
# Likewise parsed on demand, by App\Config\QuestionnaireCatalog.
$questionnaireDir: '%kernel.project_dir%/config/questionnaires'
$questionnaireWindowDays: '%questionnaire_window_days%'
$mailingBusTransport: '%mailing_bus_transport%'
$seasonStart: '%season_start%'
$seasonEnd: '%season_end%'
$seasonTaskDeadlineOffset: '%season_task_deadline_offset%'
$autoAssignmentJobProfiles: '%auto_assignment_job_profiles%'
$autoAssignmentFees: '%auto_assignment_fees%'
# Tagged by interface rather than listed by hand: order between collectors is
# irrelevant, so the only thing an explicit list could add here is the chance to
# forget one, which would silently stop a metric being recorded.
_instanceof:
App\Service\Statistics\Collector\StatisticsCollectorInterface:
tags: ['app.statistics_collector']
App\: App\:
resource: '../src/' resource: '../src/'
@@ -59,7 +219,7 @@ services:
- name: liip_imagine.cache.resolver - name: liip_imagine.cache.resolver
resolver: format_extension resolver: format_extension
App\Command\BpnImportCommand: App\Command\MyEpImportCommand:
arguments: arguments:
$connection: '@doctrine.dbal.default_connection' $connection: '@doctrine.dbal.default_connection'
@@ -81,14 +241,61 @@ services:
bpn_api_port: '%env(APP_BPN_PORT)%' bpn_api_port: '%env(APP_BPN_PORT)%'
debug: '%env(bool:APP_BPN_DEBUG)%' debug: '%env(bool:APP_BPN_DEBUG)%'
App\Service\Teamer\TeamerProvisioner:
arguments:
$logger: '@monolog.logger.bpn'
# The same selection the login reads a teamer's role from - this is the one place that
# writes it, so it must be the same id.
$teamerSelectionId: '%bpn_crm_id_teamer%'
App\BusProNet\ResponseParser: App\BusProNet\ResponseParser:
arguments: arguments:
$options: $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_manager: '%bpn_crm_id_manager%'
bpn_crm_id_teamer: '%bpn_crm_id_teamer%' bpn_crm_id_teamer: '%bpn_crm_id_teamer%'
bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%'
bpn_default_hotel_code: '%bpn_default_hotel_code%' bpn_default_hotel_code: '%bpn_default_hotel_code%'
App\Saas\ApiClient:
arguments:
$logger: '@monolog.logger.saas'
$options:
base_url: '%env(APP_SAAS_BASE_URL)%'
api_key: '%env(APP_SAAS_API_KEY)%'
App\Saas\DataProvider\EmployeeDataProvider:
arguments:
$logger: '@monolog.logger.saas'
# The roster is slow and barely changes; six hours means one fetch per run and a
# new starter is picked up the same day.
$ttl: 21600
App\MyEp\Api\AccessTokenProvider:
arguments:
$logger: '@monolog.logger.myep'
$cache: '@cache.app'
$options:
client_id: '%env(MYEP_API_CLIENT_ID)%'
client_secret: '%env(MYEP_API_CLIENT_SECRET)%'
App\MyEp\Api\ApiClient:
arguments:
$logger: '@monolog.logger.myep'
App\MyEp\Api\ResponseMapper:
arguments:
$logger: '@monolog.logger.myep'
App\MyEp\Cache\StaleWhileErrorCache:
arguments:
$logger: '@monolog.logger.myep'
$cache: '@cache.app'
App\MyEp\Import\TravelExportClient:
arguments:
$logger: '@monolog.logger.myep'
App\Twig\AppRuntime: App\Twig\AppRuntime:
arguments: arguments:
$intlExtension: '@twig.extension.intl' $intlExtension: '@twig.extension.intl'
@@ -100,12 +307,32 @@ services:
tags: tags:
- name: monolog.processor - name: monolog.processor
# Every SaaS url carries the api key as a path segment, so the request lines Symfony's
# http client writes - and the exception messages that repeat those urls - are redacted
# on the way into the log rather than suppressed.
App\Logging\RedactSaasCredentialsProcessor:
arguments:
$apiKey: '%env(APP_SAAS_API_KEY)%'
tags:
- name: monolog.processor
App\Logging\RequestInfoProcessor: App\Logging\RequestInfoProcessor:
arguments: arguments:
- '@request_stack' - '@request_stack'
tags: tags:
- name: monolog.processor - name: monolog.processor
App\Menu\MenuBuilder:
arguments:
$factory: '@knp_menu.factory'
tags:
- name: knp_menu.menu_builder
method: createMainMenu
alias: default_main
- name: knp_menu.menu_builder
method: createTeamerMenu
alias: default_teamer
App\Menu\AdminMenuBuilder: App\Menu\AdminMenuBuilder:
arguments: arguments:
$factory: '@knp_menu.factory' $factory: '@knp_menu.factory'
@@ -148,14 +375,21 @@ services:
arguments: arguments:
$orphanageManager: '@oneup_uploader.orphanage_manager' $orphanageManager: '@oneup_uploader.orphanage_manager'
$projectDir: '%kernel.project_dir%' $projectDir: '%kernel.project_dir%'
$environment: '%kernel.environment%' $uploadReplacementFile: '%upload_replacement_file%'
$uploadReplacementFile: '%env(default::UPLOAD_REPLACEMENT_FILE)%'
App\Service\Pdf\Sanitizer: App\Service\Pdf\Sanitizer:
arguments: arguments:
$binGs: '%env(BIN_GS)%' $binGs: '%env(BIN_GS)%'
$binConvert: '%env(BIN_CONVERT)%' $binConvert: '%env(BIN_CONVERT)%'
App\EventListener\MaintenanceModeListener:
arguments:
$options:
enabled: '%env(bool:MAINTENANCE_MODE_ENABLED)%'
whitelisted_ips: '%env(csv:MAINTENANCE_MODE_IP_WHITELIST)%'
whitelisted_routes: '%env(csv:MAINTENANCE_MODE_ROUTE_WHITELIST)%'
whitelisted_paths: '%env(csv:MAINTENANCE_MODE_PATH_WHITELIST)%'
App\EventListener\UploadSessionListener: App\EventListener\UploadSessionListener:
tags: tags:
- name: kernel.event_listener - name: kernel.event_listener
@@ -173,15 +407,27 @@ services:
$datevEmailRecipient: '%env(DATEV_EMAIL_RECIPIENT)%' $datevEmailRecipient: '%env(DATEV_EMAIL_RECIPIENT)%'
$datevEmailSender: '%env(DATEV_EMAIL_SENDER)%' $datevEmailSender: '%env(DATEV_EMAIL_SENDER)%'
App\EventListener\StatisticsChangeSetListener:
arguments:
$collectors: !tagged_iterator app.statistics_collector
App\RequiredTeamerCheck\RequiredTeamerCheckRegistry: App\RequiredTeamerCheck\RequiredTeamerCheckRegistry:
arguments: arguments:
# Presentation order only. Each check must point at a page that clears
# that check alone, so a teamer can always work through them whatever
# the order — never make correctness depend on this list.
$checks: $checks:
- '@App\RequiredTeamerCheck\DriverLicenseRequiredCheck' - '@App\RequiredTeamerCheck\DriverLicenseRequiredCheck'
- '@App\RequiredTeamerCheck\FinancialDataRequiredCheck'
- '@App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck' - '@App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck'
- '@App\RequiredTeamerCheck\MealPreferenceRequiredCheck'
App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck: App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck:
arguments: arguments:
$dataVerificationDeadlines: '%env(csv:PERSONAL_DATA_CHECK_DEADLINES)%' $deadlines: !service
class: App\RequiredTeamerCheck\RecurringDeadlines
arguments:
$deadlines: '%personal_data_check_deadlines%'
app.upload_namer: app.upload_namer:
class: App\Service\Upload\UploadNamer class: App\Service\Upload\UploadNamer
@@ -202,8 +448,70 @@ services:
$options: $options:
project_dir: '%kernel.project_dir%' project_dir: '%kernel.project_dir%'
App\Service\Pdf\WorkMonthRenderer:
arguments:
$options:
project_dir: '%kernel.project_dir%'
App\Email\Mailer: App\Email\Mailer:
arguments: arguments:
$defaults: $defaults:
from: '%default_email_from%' from: '%default_email_from%'
to: '%default_email_to%' to: '%default_email_to%'
# Its own log channel, not the audit one bound by default: the audit channel writes a row
# to the log table through App\Logging\DatabaseHandler, which records what staff did here.
App\Email\EmailTextRenderer:
arguments:
$bookingUrl: '%application_form_booking_url%'
App\Security\AccessToken\ApiKeyTokenHandler:
arguments:
# user identifier in security.yaml's api_user_provider => key
$apiKeys:
api: '%application_form_api_key%'
n8n: '%n8n_api_key%'
$logger: '@monolog.logger.api'
App\Controller\Api\ApplicationFormController:
arguments:
$logger: '@monolog.logger.api'
tags: [ 'controller.service_arguments' ]
App\Controller\Api\Assignment\ListController:
arguments:
$houseCodes: '%assignment_export.house_codes%'
$jobProfileIds: '%assignment_export.job_profile_ids%'
$logger: '@monolog.logger.api'
tags: [ 'controller.service_arguments' ]
App\EventListener\EmailRecipientWhitelistSubscriber:
arguments:
$allowedRecipients: '%mail_recipient_whitelist%'
# Not the audit channel this argument is bound to by default: a mail dropped by a
# staging guard is a technical event and has no place in the application log table.
$logger: '@logger'
App\Security\OAuth2\MyEpClient:
arguments:
$options:
myep_oauth2_client_id: '%env(MYEP_OAUTH2_CLIENT_ID)%'
myep_oauth2_client_secret: '%env(MYEP_OAUTH2_CLIENT_SECRET)%'
myep_oauth2_url_authorize: '%env(MYEP_OAUTH2_URL_AUTHORIZE)%'
myep_oauth2_url_access_token: '%env(MYEP_OAUTH2_URL_ACCESS_TOKEN)%'
myep_oauth2_url_resource_owner_details: '%env(MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS)%'
myep_oauth2_scopes: '%env(csv:MYEP_OAUTH2_SCOPES)%'
# The mails of a mailing are sent right away here, like every other mail, instead of
# waiting for a worker on the dedicated queue. The mailer transport is deliberately not
# switched: a mailing still goes out over MAILING_MAILER_DSN, so the path being exercised
# locally is the one that runs in production.
when@dev:
parameters:
mailing_bus_transport: 'sync'
when@test:
parameters:
# a job profile id ListControllerTest inserts itself - db_test has no job profiles
assignment_export.job_profile_ids: [ 900001 ]
mailing_bus_transport: 'sync'
+20 -16
View File
@@ -22,7 +22,7 @@ set('keep_releases', 3);
// Rsync options, mainly files/dirs to exclude // Rsync options, mainly files/dirs to exclude
$rsyncOptions = [ $rsyncOptions = [
'exclude' => [ 'exclude' => [
'.cursor', '.claude',
'.idea', '.idea',
'.vscode', '.vscode',
'.DS_Store', '.DS_Store',
@@ -38,6 +38,7 @@ $rsyncOptions = [
'.env.test.local', '.env.test.local',
'.gitignore', '.gitignore',
'.nvmrc', '.nvmrc',
'.browserslistrc',
'.php-cs-fixer.cache', '.php-cs-fixer.cache',
'.php-cs-fixer.dist.php', '.php-cs-fixer.dist.php',
'.phpunit.result.cache', '.phpunit.result.cache',
@@ -46,11 +47,12 @@ $rsyncOptions = [
'package.json', 'package.json',
'package-lock.json', 'package-lock.json',
'*.http', '*.http',
'http-client*.json',
'postcss.config.js', 'postcss.config.js',
'phpunit.xml.dist', 'phpunit.xml.dist',
'public/.htaccess', 'public/.htaccess',
'tailwind.config.js', 'tailwind.config.js',
'webpack.config.js', 'webpack.config.mjs',
'rapidapi.paw', 'rapidapi.paw',
'bin/.phpunit', 'bin/.phpunit',
'temp/*', 'temp/*',
@@ -70,31 +72,33 @@ $rsyncOptions = [
]; ];
host('prod') host('prod')
->setHostname('185.243.135.29') ->setHostname('dedi10193.your-server.de')
->setRemoteUser('p704161') ->setRemoteUser('myepteam')
->setForwardAgent(true) ->setForwardAgent(true)
->setSshMultiplexing(true) ->setSshMultiplexing(true)
->setDeployPath('/home/www/p704161/html/myep-team') ->setDeployPath('/usr/www/users/myepteam/prod')
->set('writable_mode', 'chmod') ->set('bin/php', '/usr/bin/php')
->set('http_user', 'p704161') ->set('http_user', 'myepteam')
->set('bin/php', '/usr/local/bin/php')
->set('rsync_src', __DIR__) ->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions) ->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://myep-team.ep-reisen.de') ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://team.ep-reisen.de')
; ;
host('staging') host('staging')
->setHostname('185.243.135.29') ->setHostname('dedi10193.your-server.de')
->setRemoteUser('p704161') ->setRemoteUser('myepteam')
->setForwardAgent(true) ->setForwardAgent(true)
->setSshMultiplexing(true) ->setSshMultiplexing(true)
->setDeployPath('/home/www/p704161/html/myep-team-staging') ->setDeployPath('/usr/www/users/myepteam/staging')
->set('writable_mode', 'chmod') ->set('bin/php', '/usr/bin/php')
->set('http_user', 'p704161') ->set('http_user', 'myepteam')
->set('bin/php', '/usr/local/bin/php')
->set('rsync_src', __DIR__) ->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions) ->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.net --web-basic-auth=team:staging')
->add('shared_files', [
'public/.htaccess',
'public/.htpasswd',
])
; ;
task('deploy', [ task('deploy', [
-187
View File
@@ -1,187 +0,0 @@
# Plan: Cancellation Statistics Feature
## Objective
Enable stakeholders to see how often dispositions and applications have been canceled by either the teamer or the office, per hotel and for a given date range.
---
## Current State Analysis
### Dispositions - Already Tracked
The `Disposition` entity has comprehensive cancellation tracking:
| Field | Type | Purpose |
|-------|------|---------|
| `status` | string | `'called_off'` when cancelled |
| `calledOffBy` | string (nullable) | `'teamer'` or `'office'` |
| `calledOffReason` | text (nullable) | Reason for cancellation |
| `updatedAt` | datetime | Timestamp of last change |
**Constants:**
```php
public const STATUS_CALLED_OFF = 'called_off';
public const CALLED_OFF_BY_TEAMER = 'teamer';
public const CALLED_OFF_BY_OFFICE = 'office';
```
**Cancellation entry points:**
1. Individual disposition cancellation via `Administrative/Disposition/CallOffController`
- Form requires `calledOffBy` and `calledOffReason`
- Dispatches `DispositionCalledOffEvent`
2. Full assignment cancellation via `Administrative/Assignment/CallOffController`
- Sets all dispositions to `calledOffBy = 'office'`
- Dispatches `AssignmentCalledOffEvent`
### Applications - Missing Tracking
The `Application` entity lacks cancellation tracking:
| Current Field | Issue |
|---------------|-------|
| `status` | Only `'rejected'` - no distinction between teamer withdrawal and office rejection |
| - | No `cancelledBy` field |
| - | No `cancellationReason` field |
**Current rejection mechanisms:**
1. Manual rejection by office (no tracking of who)
2. Automatic rejection by `RejectApplicationListener` when disposition slots fill
3. Automatic removal by `InvalidateApplicationsListener` when teamer has overlapping confirmed dispositions
---
## Pending Decision
**Question for stakeholders:** Should automatic rejections be included in statistics?
| Option | Description | Impact |
|--------|-------------|--------|
| **Only user-initiated** | Count only manual cancellations by teamer or office | Simpler model, clearer accountability |
| **Include automatic** | Also track system-initiated rejections | Full picture of lost applications, requires `'system'` category |
**Status:** Awaiting stakeholder response
---
## Proposed Changes
### Application Entity
**Option A: If only user-initiated cancellations count**
Add new status and fields:
```php
public const STATUS_WITHDRAWN = 'withdrawn'; // teamer-initiated
public const STATUS_REJECTED = 'rejected'; // office-initiated (existing)
private ?string $cancellationReason = null;
```
**Option B: If automatic rejections should be tracked**
Add new status, fields, and constant:
```php
public const STATUS_WITHDRAWN = 'withdrawn'; // teamer-initiated
public const STATUS_REJECTED = 'rejected'; // office or system initiated
public const CANCELLED_BY_TEAMER = 'teamer';
public const CANCELLED_BY_OFFICE = 'office';
public const CANCELLED_BY_SYSTEM = 'system';
private ?string $cancelledBy = null;
private ?string $cancellationReason = null;
```
### Database Migration
Add columns to `application` table:
- `cancellation_reason` (LONGTEXT, nullable)
- Possibly `cancelled_by` (VARCHAR(64), nullable) if Option B
### Repository Methods
Add to `ApplicationRepository`:
```php
/**
* @return array<string, array{teamer: int, office: int}>
*/
public function getCancellationStatsByHotel(
\DateTimeImmutable $startDate,
\DateTimeImmutable $endDate
): array;
```
Add to `DispositionRepository`:
```php
/**
* @return array<string, array{teamer: int, office: int}>
*/
public function getCancellationStatsByHotel(
\DateTimeImmutable $startDate,
\DateTimeImmutable $endDate
): array;
```
**Date filtering:** Based on assignment travel dates (confirmed in discussion).
### Update Listeners (if Option B)
Modify `RejectApplicationListener` and `InvalidateApplicationsListener` to set `cancelledBy = 'system'`.
### UI Changes
Add withdrawal functionality for teamers (if not already present) that sets `STATUS_WITHDRAWN`.
---
## Implementation Steps
1. [ ] Finalize decision on automatic rejection tracking
2. [ ] Add new constants and fields to `Application` entity
3. [ ] Create database migration
4. [ ] Update `ApplicationRepository` with statistics methods
5. [ ] Update `DispositionRepository` with statistics methods
6. [ ] Update listeners if tracking automatic rejections
7. [ ] Add/update controllers for teamer withdrawal flow
8. [ ] Write tests for new repository methods
9. [ ] Run php-cs-fixer
---
## Related Files
### Entities
- `src/Entity/Application.php`
- `src/Entity/Disposition.php`
- `src/Entity/Assignment.php`
### Repositories
- `src/Repository/ApplicationRepository.php`
- `src/Repository/DispositionRepository.php`
### Controllers
- `src/Controller/Administrative/Disposition/CallOffController.php`
- `src/Controller/Administrative/Assignment/CallOffController.php`
- `src/Controller/Teamer/Disposition/DetailController.php`
### Listeners
- `src/EventListener/InvalidateApplicationsListener.php`
- `src/EventListener/RejectApplicationListener.php`
### Events
- `src/Event/DispositionCalledOffEvent.php`
- `src/Event/AssignmentCalledOffEvent.php`
### Forms
- `src/Form/DispositionCallOffType.php`
---
## Notes
- Filter statistics by **assignment travel dates**, not cancellation timestamp
- Disposition tracking already works - only need repository query methods
- Application tracking requires entity changes and migration
- Consider whether teamer self-service withdrawal exists or needs to be added
+140
View File
@@ -0,0 +1,140 @@
# Operations: messenger workers and cron
Reference for how queued work actually gets executed in production. Describes the setup as
it is — not a plan.
The governing fact, from which most of the rest follows:
> **Nothing in this repository starts a worker.** Both workers are crontab entries on the
> host, outside version control. `deploy.php` only ever *stops* workers, and even that is
> largely ineffective (see below). If you change how a queue is consumed, change it here
> too, or the next person has to reverse-engineer it from the config.
---
## Transports
Defined in `config/packages/messenger.yaml`, all backed by the same Doctrine table
(`MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0`):
| Transport | `queue_name` | Carries | Consumed by |
|-----------|--------------|---------|-------------|
| `async` | `default` | all ordinary mail, chat and SMS notifications | the async cron worker |
| `mailing` | `mailing` | bulk teamer mailings only | the mailing cron worker |
| `failed` | `failed` | anything that exhausted its retries | nobody — inspected by hand |
| `sync` | — | dev and test only | the request itself |
The two live workers are isolated **by the `queue_name` column**, not by how they are
started: the Doctrine transport's `get()` filters on it, so neither worker can see the
other's messages. Ordinary mail therefore never queues behind a mailing of several hundred
recipients.
Overlapping runs of the same worker are harmless. `Connection::get()` selects
`FOR UPDATE SKIP LOCKED` inside a transaction and stamps `delivered_at` before committing,
so two consumers can never be handed the same row. No `flock` is needed; an overlap only
costs a second process.
## The cron entries
> **The async line below is a placeholder.** Read the real one off the host
> (`crontab -l` as `p704161`) and transcribe it verbatim, including any differences in
> flags, php path or logging — those differences are information.
```cron
# ordinary mail, notifications
*/15 * * * * cd /home/www/p704161/html/myep-team/current && /usr/local/bin/php bin/console messenger:consume async --time-limit=... --memory-limit=... --quiet >> /home/www/p704161/html/myep-team/shared/var/log/cron-messenger-async.log 2>&1
# bulk teamer mailings
*/15 * * * * cd /home/www/p704161/html/myep-team/current && /usr/local/bin/php bin/console messenger:consume mailing --time-limit=300 --memory-limit=128M --quiet >> /home/www/p704161/html/myep-team/shared/var/log/cron-messenger-mailing.log 2>&1
```
Why each part is the way it is:
- **`/usr/local/bin/php`, not bare `php`.** `deploy.php` sets `bin/php` to this path
explicitly for both hosts, because the host's default CLI is not the right one. Cron's
`PATH` is typically only `/usr/bin:/bin`. `vendor/` is rsynced from a developer machine
and there is no `deploy:vendors`, so the CLI must match the PHP the dependencies were
installed against (8.4). Deployments run `composer check-platform-reqs` locally before
rsync; because `config.platform.php` is pinned in `composer.json`, this only verifies
that the locked dependencies resolve against the PHP and extension set we target — it
does not probe the staging or production runtime. Keeping both FPM and
`/usr/local/bin/php` on staging and production at PHP 8.4 is a manual precondition of
every deploy.
- **`--time-limit=300`** on the mailing worker. An 800-recipient mailing needs roughly
200–360 seconds of worker time — the fan-out renders 800 Twig bodies and inserts 800
rows, then the mails go out at about 3–5 per second over a reused SMTP connection. So a
mailing usually drains in one window and occasionally spills into the next; worst case
end to end is about half an hour. Raise it toward 840 for lower latency at the cost of an
effectively always-on process, lower it for a lighter footprint. Anything under 900
guarantees two runs cannot overlap.
- **Stopping mid-mailing is safe.** The time limit is only checked *between* messages
(`StopWorkerOnTimeLimitListener` listens on `WorkerRunningEvent`), so it never truncates
a send or a half-finished fan-out. Unhandled rows keep `delivered_at = NULL` and the next
run picks them up.
- **`--memory-limit` is a recycle trigger, not a safety net.** It too is only checked
between messages, so it cannot protect the fan-out, which is the memory peak. That is
down to the CLI `memory_limit` ini — keep it at 256M or more.
- **`--quiet` plus a redirect.** `messenger:consume` prints a banner on every run and
unredirected cron output is mailed to the crontab user, which would be 96 mails a day.
Nothing is lost: errors still reach `var/log/framework.prod.log` through the prod monolog
handler. Redirect into the **shared** `var/log`, which is in `shared_dirs` and survives
deploys — never anywhere under `var/cache`, which is per-release by design.
- **No `APP_ENV=prod` on the line.** `bin/console` loads Dotenv relative to the project
dir, and `APP_ENV` lives in the shared `.env.local`. A real environment variable takes
precedence over that file, so putting it in the crontab would create a second source of
truth and would need a different line for staging, which runs on the same host under the
same user.
## Deploys do not restart workers
`deploy.php` ends with `deploy:stop-workers`, which runs `messenger:stop-workers`. That
writes a restart timestamp into a cache pool under `%kernel.cache_dir%`, i.e. into the
**new release**. A worker that is already running was started from the previous release and
stays pinned to that release's `var/cache` for its whole life, so it never sees the flag.
**`--time-limit` is therefore what actually bounds a worker's life and what makes new code
take effect** — up to five minutes after a deploy for the mailing worker. This is not worth
"fixing" by moving `var/cache` into `shared_dirs`; that would break cache warming and
opcache invalidation far worse than the problem it solves.
Related: `keep_releases: 3`. A worker must not outlive three deploys, or it will be running
out of a directory that has been deleted. At a five-minute lifetime this cannot happen.
## When something goes wrong
```bash
bin/console messenger:stats # queue depths, including failed
bin/console messenger:failed:show # what failed and why
bin/console messenger:failed:show <id> -vv # the full exception
bin/console messenger:failed:retry # requeue, interactively
```
Nothing watches the `failed` transport, so a failed mailing stays silent until somebody
looks. Check it after any large mailing.
The scheduler's `email_on_failure` does not help here, and covers less than it appears to:
a task only counts as failed when its command exits non-zero, and `App\Command\CronCommand`
always returns `Command::SUCCESS` — even where it renders `$io->error()` for a failed log
flush. So none of the five services it orchestrates can currently raise an alert.
Three failure modes worth knowing because they are quiet rather than loud:
- **`.env.local` missing from the deploy path.** `APP_ENV` falls back to `dev` and
`MAILING_MAILER_DSN` to `null://null`. The worker then consumes every message and
delivers nothing, reporting success and recording no failure. The quietest failure in the
whole system.
- **No worker consuming `mailing`.** Mailings queue up indefinitely while the admin is told
they are on their way. `messenger:stats` shows it immediately.
- **Wrong PHP CLI.** A version mismatch against the rsynced `vendor/` fails at parse time,
inside a cron job nobody reads. This is why the log redirect exists.
## Scheduled tasks are a different mechanism
`config/packages/zenstruck_schedule.yaml` holds the daily jobs (`app:cron`,
`app:myep-import`, `app:teamer-status`, …), driven by a separate `schedule:run` cron entry.
**Do not put `messenger:consume` in there.** The bundle runs tasks in-process and
sequentially, so a consume task holds the entire schedule for its duration — every later
task waits, and the next `schedule:run` starts concurrently because no task declares
`withoutOverlapping`. This was tried and reverted (`dc796849`, "remove messenger from
scheduler config to be run separately"). Workers belong in their own crontab lines.
+358
View File
@@ -0,0 +1,358 @@
# Statistics: event collection and evaluation
Reference for how statistics are collected, what is stored, and how to read or extend it.
Describes the behaviour as implemented — not a plan. Evaluation screens for these metrics do
not exist yet; this is what they will be built on.
The governing rule, from which the rest follows:
> **Columns are for what you `GROUP BY`. The payload is for what you read back.**
>
> A value that will end up in a `GROUP BY` needs its own indexed column. Everything else —
> old and new values, reasons, scopes, flags — lives in the JSON `payload`. That split is
> what lets a new metric ship without a migration, and it is the only rule that has to hold
> as the table grows.
Two corollaries that surprise people:
- **Rows are never updated or deleted.** A statistics event is a record of something that
happened, not a view of current state. If the answer can be recomputed from live data at
any time, it does not belong here — see *What does not belong* below.
- **Nothing here is a foreign key.** `assignment_id` and friends are plain integers, so
statistics outlive the rows they describe. Applications are hard deleted; their history is
not.
---
## Why the table exists
Most of the metrics could not be answered from live data:
- `Assignment::$jobProfile` holds only the current value. Every edit destroyed the previous
one permanently.
- Applications are hard deleted (no soft delete), so any count over them silently shrank
over time. Dispositions are hard deleted too, and leave even less behind — no `createdAt`,
no trace of the teamer they were on.
- Call-offs persisted, but not *when* they happened — `updatedAt` is overwritten by any
later edit — nor at what scope.
The `log` table is not an alternative. It is a monolog dump (`App\Logging\DatabaseHandler`)
with free-form `context`/`extra`, excluded from migrations by
`schema_filter: ~^(?!log)~` in `config/packages/doctrine.yaml`. It is an audit trail for
humans to read, not a schema to aggregate over.
---
## The table
`statistics_event`, created by `migrations/Version20260826000000.php`, mapped by
`src/Entity/StatisticsEvent.php`.
| Column | Type | Purpose |
|--------|------|---------|
| `id` | int, PK | |
| `name` | varchar(64) | the metric, `enumType: StatisticsEventName` |
| `occurred_at` | datetime | when it happened |
| `actor_id` | int, null | `User::getId()` of whoever acted |
| `actor_label` | varchar(180), null | their `getUserIdentifier()` |
| `actor_role` | varchar(32), null | `enumType: StatisticsActorRole` |
| `assignment_id` | int, null | dimension |
| `disposition_id` | int, null | dimension |
| `application_id` | int, null | dimension |
| `teamer_id` | int, null | dimension |
| `destination_id` | int, null | dimension |
| `job_profile_id` | int, null | dimension |
| `hotel_code` | varchar(8), null | **normalized** code, frozen at write time |
| `payload` | json | everything metric-specific |
Indexed on `(name, occurred_at)`, `(name, hotel_code)`, `(assignment_id)`, `(teamer_id)`.
### Two traps in the columns
**`hotel_code` is normalized here and raw in `feedback`.** This table stores `ALB`;
`feedback.hotel_code` stores `SERALB` and normalizes at query time. Same column name,
opposite conventions — comparing or joining them directly returns nothing, silently. The
`SER`-stripping rule lives in `Destination::getHotelCodeNormalized()`
(`src/Entity/Destination.php`), and is reimplemented in DQL in `DispositionRepository` and
in raw SQL in `FeedbackRepository`. Reuse the entity method in PHP; do not add a fourth copy.
**Dimensions are frozen at write time, deliberately.** A later correction to a destination's
hotel code does not rewrite past periods. This is the same pattern `Feedback::fromAssignment()`
(`src/Entity/Feedback.php`) already uses.
---
## The five metrics
Defined in `src/Enum/StatisticsEventName.php`. Adding a case here plus one collector that
records it is the entire cost of a new metric.
| Name | Recorded when | Payload |
|------|---------------|---------|
| `assignment.job_profile_changed` | the job profile column changes on any write path | `from`, `to` (each `{id, name}` or null) |
| `assignment.called_off` | an assignment's status becomes `called_off` | `dispositions_affected`, `previous_status` |
| `disposition.called_off` | a disposition's status becomes `called_off` | `called_off_by`, `scope`, `reason`, `previous_status` |
| `disposition.deleted` | a disposition is hard deleted, on any delete path | `previous_status`, `remarks`, `called_off_by` |
| `application.created` | an application is persisted, by any path | — |
Which dimensions each one fills:
| Metric | assignment | destination | job_profile | hotel_code | teamer | disposition | application |
|--------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| `assignment.job_profile_changed` | ✓ | ✓ | ✓ | ✓ | — | — | — |
| `assignment.called_off` | ✓ | ✓ | ✓ | ✓ | — | — | — |
| `disposition.called_off` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| `disposition.deleted` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| `application.created` | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ |
`disposition.deleted` carries `remarks` because a hard delete destroys the entity outright:
the justification an admin types on the delete form (`DeleteController`) lives nowhere else
afterwards. `called_off_by` is set only when the placement had already been called off before
it was deleted.
### Call-offs: three different "who" and "how far"
This is the part most likely to be misread when building a chart.
- **`actor_role`** — who *clicked*, from the security token. Always `admin` for a call-off;
only administrative users can perform one.
- **`payload.called_off_by`** — whose *decision* it was, `teamer` or `office`, as declared by
an admin on the call-off form (`src/Form/DispositionCallOffType.php`). There is no
teamer-initiated call-off route; a teamer's decision is recorded on their behalf.
- **`payload.scope`** — how far it *reached*, `assignment` or `disposition`
(`src/Enum/CallOffScope.php`). The office can cancel one placement just as a
whole trip can fall away, so `called_off_by` does not imply this.
Cancelling a trip cascades onto every disposition on it in one flush, so **one office
decision produces one `assignment.called_off` row plus N `disposition.called_off` rows**.
Counting the disposition rows therefore counts *affected placements*, not decisions. To count
decisions:
```sql
-- decisions
SELECT COUNT(*) FROM statistics_event WHERE name = 'assignment.called_off';
SELECT COUNT(*) FROM statistics_event
WHERE name = 'disposition.called_off'
AND JSON_UNQUOTE(JSON_EXTRACT(payload,'$.scope')) = 'disposition';
-- affected placements
SELECT COUNT(*) FROM statistics_event WHERE name = 'disposition.called_off';
```
`assignment.called_off` carries `dispositions_affected` so a cancelled trip's cost reads off
a single row. It is frequently `0` — a trip cancelled before anyone was staffed is still a
decision worth counting.
---
## How events are recorded
One mechanism, writing through `App\Service\Statistics\StatisticsRecorder`.
**Doctrine changesets** — `src/EventListener/StatisticsChangeSetListener.php`, on `onFlush`
and `postFlush`. Used for state changes. `onFlush` is the only place the *previous* value
still exists, and it catches every write path — edit form, duplication, imports, console —
rather than the one route someone remembered to hook. Recording happens in `postFlush`, after
commit, so a rolled-back flush leaves no phantom statistics: the batch belongs to one flush,
is cleared at the start of the next, and goes away with the service reset that a worker does
between messages.
The listener itself knows nothing about any metric. It owns only the timing, and delegates
to **collectors** — implementations of
`App\Service\Statistics\Collector\StatisticsCollectorInterface`, autoconfigured by interface
so a new one needs no wiring and cannot be silently forgotten.
A collector receives the whole flush (`StatisticsFlush`), not one entity at a time. That is
deliberate: `CallOffCollector` has to see which *assignments* are becoming `called_off`
before it can tell a cascaded disposition from a teamer dropping out, and it counts the
cascade to fill `dispositions_affected`. Neither is expressible from a single entity's
changeset, and that distinction exists only inside the flush.
**Group collectors by concern, not by metric.** Both call-off metrics live in one collector
because they can only be told apart together; a job profile change shares nothing with them
and lives in its own. Order between collectors is irrelevant and nothing may depend on it.
Collectors see **insertions** as well as updates, so a metric about something being created
is a collector too. `ApplicationCreatedCollector` reads `insertionsOf(Application::class)`
rather than subscribing to `ApplicationCreatedEvent`, which is what an earlier version did.
Reading the flush counts every path that persists an application; subscribing counted only
the paths that remember to dispatch, and would have silently missed an admin applying on
someone's behalf, an import, or a fixture.
A newly inserted entity has no id during `onFlush`. That is fine — Doctrine assigns it during
the commit, and recording happens afterwards in `postFlush`.
Collectors see **deletions** too, through `deletionsOf()`. `DispositionDeletedCollector`
reads them for the same reason: every delete path is counted, and a hard delete leaves
nothing to reconstruct afterwards. Deletions carry one wrinkle the other two do not.
Recording still happens in `postFlush`, but by then Doctrine has removed the entity from the
identity map and **nulled its generated id** (`UnitOfWork::executeDeletions()`), so
`StatisticsRecorder` can no longer resolve the dimension columns from it. A deletion
collector therefore snapshots its dimensions itself, during `onFlush`, via
`StatisticsDimensions::forDisposition()`, and passes them on the `CollectedStatisticsEvent`;
the listener writes that array verbatim instead of resolving from the subject. Anything a
deletion metric needs in its payload — status, the form `remarks` — has to be read the same
way, off the still-attached entity, before the flush commits.
> **There is deliberately only one mechanism.** If a future metric is not a persisted state
> change at all — an email sent, a login, a document downloaded — call `StatisticsRecorder`
> directly from wherever that happens. Do not reintroduce a second listener layer for it.
**Writes go through raw DBAL**, not the ORM, so recording never touches the UnitOfWork — a
listener running inside a flush would otherwise trigger a nested flush. Failures are caught
and reported to the audit logger: statistics are a side effect and must never break the
business action that produced them.
---
## Reading the data
`src/Repository/StatisticsEventRepository.php`.
```php
public function countGroupedBy(
StatisticsEventName $name,
string $dimension, // whitelisted
?\DateTimeImmutable $dateFrom = null,
?\DateTimeImmutable $dateTo = null,
StatisticsDateBasis $dateBasis = StatisticsDateBasis::SEASON,
): array
```
`getCountGroupedByQuery()` returns the same query unexecuted, which is how its semantics are
pinned in tests without a database.
### A period means the season, not when the row was written
`StatisticsDateBasis::SEASON` (the default) filters on the destination's date range through
`src/Repository/Filter/SeasonPeriodFilter.php`, which is shared with
`DispositionRepository`. This matches every other statistics screen and
`StatisticsFilterType`. Change what a season means there, once, not per screen.
`StatisticsDateBasis::OCCURRENCE` filters `occurred_at` instead. Reach for it deliberately,
for genuine time series such as call-offs per month.
The two routinely disagree, because an application for the coming winter arrives months
before the season it belongs to. Picking the wrong one does not fail — it quietly answers a
different question from the screen next to it.
`SEASON` reaches the assignment through an arbitrary join and therefore excludes events with
no `assignment_id`. All five metrics set it.
### Crossing with live data
The plain-integer dimension columns cost nothing in queryability — Doctrine's arbitrary join
works:
```
SELECT destination.hotel AS hotel, COUNT(event.id) AS applications
FROM App\Entity\StatisticsEvent event
JOIN App\Entity\Assignment assignment WITH assignment.id = event.assignmentId
JOIN assignment.destination destination
WHERE event.name = :name
GROUP BY hotel
```
Group by `hotel_code`, though, not by the hotel *name*: they are not one-to-one. One
normalized code can span several destinations with different names.
---
## Backfilling
`bin/console app:statistics:backfill` seeds what current data still contains. Idempotent —
it skips subjects already recorded — and supports `--dry-run`. It is never run automatically:
nothing in `CronCommand`, the scheduler, or any deploy script invokes it.
It writes only to `statistics_event`, so it is safe to run against production at any time.
| Metric | Backfillable | `occurred_at` from |
|--------|--------------|--------------------|
| `application.created` | yes | `createdAt` — exact |
| `disposition.called_off` | yes | `updatedAt` — **approximate** |
| `assignment.called_off` | yes | `updatedAt` — **approximate** |
| `assignment.job_profile_changed` | **no** | history does not exist |
| `disposition.deleted` | **no** | the disposition is gone, nothing left to seed |
Every seeded row carries `"backfilled": true`; the approximate ones also carry
`"approximate_date": true`. So the choice is per chart, never table-wide:
```sql
WHERE JSON_EXTRACT(payload,'$.backfilled') IS NULL -- organically recorded only
```
Three limits on seeded rows, all of which matter when charting:
- **Dates from `updatedAt` are a proxy**, since any later edit overwrites it. Exclude those
rows from time series; keep them for totals.
- **`actor_role` is `system`.** The command runs on the CLI with no security token, so the
original actor is not recoverable.
- **Frozen dimensions are frozen at *backfill* time**, not at the time of the event, and
`scope` is left unset because it cannot be established retroactively.
---
## Extending it
The cost depends entirely on whether the new metric needs a new *dimension* or just a new
*name*. Almost all need only a name.
**No migration — a new kind of event.** Add a case to `StatisticsEventName`, then one
`StatisticsCollectorInterface` implementation that records it. Metric-specific detail goes in
`payload`. `StatisticsChangeSetListener` is never edited. This covers most requests —
application rejections, contract upload delays, feedback submitted, any status transition,
anything created or deleted.
Reuse an existing collector when the new metric is part of a concern already covered —
especially when it needs to see the same entities in the same flush. Write a new one when it
is genuinely independent.
**One migration — a new dimension to slice by.** Only when you need to group by something no
column carries (a season, a country, a fee band). Add the column and an index, fill it in
`StatisticsRecorder`, and accept `NULL` on older rows. Resist when the dimension is already
reachable through a join.
**Migration plus backfill — history for something not yet recorded.** Extend
`StatisticsBackfillCommand` if current data still holds the answer. If it has been
overwritten, as with job profiles, the metric simply starts the day it ships.
---
## What does not belong here
**Anything recomputable from live data that cannot be lost.** Feedback statistics
(`src/Controller/Administrative/Statistics/`) are the worked example: `Feedback` already
freezes its own dimensions at creation, is never deleted, and its aggregations average a
per-question `ratings` array. It is already an append-only record; copying it into
`statistics_event` would duplicate every row and lose the structure the charts need. This
table counts occurrences — it has no shape for averaging structured arrays.
**Current state.** "How many applications are open right now" is a question for the
`application` table. This table answers "how many were ever received", which is a different
and equally valid question — just not the same one.
**An audit trail for humans.** That is the `log` table and `/admin/log`.
---
## Where things live
| Concern | Path |
|---------|------|
| Entity | `src/Entity/StatisticsEvent.php` |
| Repository | `src/Repository/StatisticsEventRepository.php` |
| Enums | `src/Enum/Statistics*.php` |
| Write path | `src/Service/Statistics/StatisticsRecorder.php` |
| Dimension columns resolved from an entity | `src/Service/Statistics/StatisticsDimensions.php` |
| Flush orchestration | `src/EventListener/StatisticsChangeSetListener.php` |
| Collectors (one per concern) | `src/Service/Statistics/Collector/` |
| Flush context passed to collectors | `src/Service/Statistics/StatisticsFlush.php` |
| What a period means, shared with the disposition statistics | `src/Repository/Filter/SeasonPeriodFilter.php` |
| Backfill | `src/Command/StatisticsBackfillCommand.php` |
| Migration | `migrations/Version20260826000000.php` |
| Tests | `tests/EventListener/`, `tests/Repository/`, `tests/Service/Statistics/` |
Existing statistics screens, for the filter/handler pattern a future evaluation UI should
reuse: `src/Controller/Administrative/Statistics/`,
`src/Service/Common/StatisticsFilterHandler.php`, `src/Model/StatisticsFilterDto.php`,
`src/Form/StatisticsFilterType.php`.
+430
View File
@@ -0,0 +1,430 @@
# User Roles: Assignment, Approval and Revocation
Reference for how a user account comes into existence, how it gains and loses roles, and
how it gets blocked. Describes the behaviour as implemented — not a plan.
The governing rules, from which the rest follows:
> **BusPro is the source of truth for roles and hotel codes.** Both are synced on every
> login, so anything it no longer reports is withdrawn.
>
> **The CRM may nominate, but never grant, an administrative role.** Only a super admin
> turns a nomination into a privilege. The CRM's word alone is enough to take away access,
> never to hand it out - and an administrator's word alone is enough for neither.
Two corollaries that surprise people:
- **A role cannot be handed out by hand.** The edit form approves what BusPro claims; it
cannot add a role BusPro is silent about, nor remove one it reports. Same for hotel codes,
which are display-only there.
- **Every role stands on its own.** `ROLE_TEAMER` is not a base role others build on, and a
Reisemanager is not a superset of a Hausleitung - somebody claiming both is nominated for
both, and approved for each separately.
---
## Role catalogue
Defined in `User::ROLES` (`src/Entity/User.php`), these four are the only roles a human can
assign:
| Role | Label | Granted by | Revoked by | Hierarchy |
|------|-------|-----------|------------|-----------|
| `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_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 |
Markers **grant nothing**. They appear in `getRoles()` and therefore in the security token,
but no `access_control` rule, `role_hierarchy` entry or voter references them. Their only
effects are cosmetic (rendered as "Admin (nicht freigeschaltet)") and organisational (they
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_TEAM_SUPER_ADMIN` when the separate `superAdmin` boolean column is set. A
validation callback (`User::validateSuperAdmin()`) refuses `superAdmin` without
`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
Everything except `superAdmin` lives in the single `roles` JSON column. Three accessors
slice it, and picking the right one matters:
| Accessor | Returns |
|----------|---------|
| `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 |
---
## Where roles come from: the CRM mapping
`ResponseParser::createCrmAttributesResponse()` walks the `selektionsmerkmale` of the
`SelektionCRM` response and sets four booleans plus the hotel codes:
| CRM attribute | Recognised by | Sets |
|---------------|---------------|------|
| 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 |
`bpn_crm_house_manager_ids` (`config/services.yaml`) maps a BusPro selection id to a hotel
code. Entries whose code is not a house in the `houses` parameter are kept commented out:
such a house manager could log in but would see no assignments and no dispositions at all.
`UserDataHandler::collectClaimedRoles()` turns those four booleans into plain role names -
what the CRM says. Two things are made of that list: `toPendingRoles()` produces one marker
per claimed administrative role (the roles are independent, so a Reisemanager who is also a
Hausleitung gets both), and `collectRoles()` produces the markers plus a real `ROLE_TEAMER`,
which is what a brand new account starts with.
### The shape of a BusPro response (important)
**BusPro always returns the full attribute tree.** Membership is expressed by the `auswahl`
flag on each `<selektion>`, so a role somebody does not hold arrives as `auswahl="False"`,
never as a missing element, and a person who holds nothing at all still receives every
group. Sample payloads for both cases live in `tests/Resources/crm_attributes_granted.xml`
and `crm_attributes_revoked.xml`, and `ResponseParserTest` asserts against them.
This is what makes the demotion path below safe: an **empty group set cannot occur in a real
response**, so it is a reliable signal that the response is degraded rather than that the
roles were revoked.
> Note what that check does *not* cover. The three ids are compared with `===`, and a wrong
> or unset one produces a perfectly well-formed response in which nobody holds anything —
> every user logging in would be demoted, one at a time. The attribute ids are therefore
> deployment-critical configuration, not a detail:
>
> | Parameter | Attribute |
> |-----------|-----------|
> | `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
> login at a time, with re-approval manual per user. Entries whose hotel code is not a house
> in the `houses` parameter are deliberately commented out there — a person holding only such
> a Hausleitung claims nothing at all and is blocked (see 4) rather than left with a role that
> shows them no data.
>
> `APP_BPN_DEFAULT_HOTEL_CODE` is a testing affordance: when set, **every** admin also
> becomes a house manager for that hotel. It must stay empty outside local development.
---
## Lifecycle
### 1. First login
`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_TEAM_ADMIN_PENDING']` and no
privileges at all: they can authenticate, but `UserChecker` refuses the session until a
super admin approves them.
### 2. Every subsequent login — the sync
`UserDataHandler::updateLocalUser()` refreshes name, email and the teamer record, and hands
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_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.
It runs *after* the revocation, so a role just revoked is not immediately marked again.
4. **`grantTeamerRole()`** adds `ROLE_TEAMER` when the CRM claims it. Grant-only in itself;
withdrawing it is step 1's business.
**`syncHotelCodes()`** then replaces the hotel codes with the ones the CRM reports, so a
Hausleitung who moves house is not left seeing the old one.
Nothing on this path can raise a privilege: step 3 only ever produces markers.
The same two methods run on the MyE&P SSO path (`MyEpAuthenticator`), against the eligible
roles it reports — one policy, two identity sources.
### 3. Approval — turning a marker into a role
`/admin/system/user/edit/{id}`, `UserType`, gated by `UserVoter::EDIT`: super admin only,
never yourself, never while impersonating.
The page keeps three things apart, because they follow three different rules:
| Block | What it is |
|-------|-----------|
| **Aus BusPro** | information, not a form: granted roles, houses, last login. Synced on every login and editable nowhere in this application |
| **Freischaltung** | one action per nomination — a button, a confirmation dialog, its own route (`ApproveRoleController`). Only shown when the user carries a marker |
| **Account** | the actual form (`UserType`): super admin, block, block reasons |
None of this is a disabled form field. Roles and hotel codes are simply not fields, so there
is nothing to submit and nothing that looks editable but is not.
**Approval is its own act**, deliberately not a checkbox on the form: it grants a privilege,
so it is confirmed on its own, logged on its own, and cannot happen as a side effect of
saving an unrelated setting. `UserDataHandler::approveRole()` refuses any role the user has
no marker for, so a hand-crafted URL cannot grant one the CRM never claimed, and the check
runs again on submit to catch a sync that revoked the claim while the dialog was open.
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_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.
The user list marks nominations with their own badge — it is the only place an approver would
look for them.
### 4. Losing everything — the block
Revocation of an *individual* role is step 1 of the sync above. This section is the stronger
case: the CRM claims **nothing at all**, which is not a demotion but an exit.
Evaluated on every login, in `BpnAuthenticator::getOrCreateLocalUser()`, before the local
user is even loaded. If `collectClaimedRoles()` is empty, the CRM grants this person nothing
in this application, so they are not a user of it:
| Situation | Outcome |
|-----------|---------|
| no local account | login refused, **no account created** |
| existing account | `disableForRevokedCrmRoles()` blocks it, and the user is still returned so `UserChecker` can state the reason |
| account already blocked | left completely alone — an existing block may be disciplinary and must never be overwritten |
`disableForRevokedCrmRoles()` sets `disabledAt`, a public `disabledReason` ("Für deinen
Account liegt in BusPro keine Berechtigung mehr vor.") and an internal one, and drops all
pending markers — they no longer reflect the CRM. **Granted roles are kept**, so the account
stays reviewable.
Two preconditions guard this branch, because "no roles" is otherwise indistinguishable from
"the CRM told us nothing". They now protect the sync as well: a degraded response that got
past them would not merely block one account, it would strip the roles of every user logging
in.
1. the response must be a `CrmAttributesResponse` — BusPro answers with a notification
record on its own errors;
2. it must carry at least one attribute group. Since a real response always carries the
full tree (see above), an empty group set means an empty payload or a changed schema —
which would otherwise block every user who logs in. That case refuses the single login,
logs a warning, and leaves the account untouched.
A misconfigured attribute id defeats both checks, as noted above. There is no signal inside
the response that distinguishes it from a genuine revocation.
**Regaining a CRM role does not unblock anything.** The next login skips the demotion branch,
refreshes markers as usual, and `UserChecker` still refuses on `disabledAt`. Unblocking is
always a human decision.
### 5. Blocking and unblocking by hand
Two surfaces, with deliberately different authority — the super admin rule is about
administrative users, teamers are an admin's business:
| Surface | Who | Notes |
|---------|-----|-------|
| `/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
an unrelated edit never resets the block timestamp — and clears both reasons on unblocking.
`UserType` additionally clears them in a `POST_SUBMIT` listener, so unticking the box without
emptying the prefilled textareas cannot leave a stale reason behind.
---
## The login gate
`UserChecker::checkPreAuth()` runs after BusPro has accepted the credentials, in this order:
| Condition | Message |
|-----------|---------|
| `disabledAt` set | `Dein Account wurde gesperrt: «disabledReason»` |
| no role from `User::ROLES`, but markers present | `Dein Account wurde noch nicht freigeschaltet.` |
| no role from `User::ROLES` at all | `Keine gültige Rolle zugewiesen.` |
Only then does `onAuthenticationSuccess()` stamp `lastLoginAt` and redirect to
`User::getDefaultRoute()` — admin, manager, house manager or teamer area, in that order of
precedence.
Note that a refused login never updates `lastLoginAt`, which is what the teamer list's
`includeInactive` filter keys on.
---
## Who is listed where
| List | Contains |
|------|----------|
| `/admin/system/user` (`UserRepository::getAdministrativeUsers()`) | holders of an administrative role, holders of a marker, and blocked accounts **without** a teamer record |
| teamer list (`TeamerRepository`) | teamers; blocked ones are badged, and stale ones need the `includeInactive` filter |
The blocked-accounts clause exists because a CRM demotion can leave a user with no role that
would list them — a candidate awaiting approval loses their markers and would otherwise
become invisible with no page to be unblocked from. Blocked teamers are excluded because
they already have one.
---
## Accountzustände
An account is in exactly one of three states. **Block and deletion are independent flags**,
so both can be set at once — a disciplinary block has to survive a deletion and the restore
that follows it.
| Zustand | Spalte | Anmeldung | Listen, Formulare, Mailings | Gesetzt von |
|---------|--------|-----------|------------------------------|-------------|
| aktiv | – | ja | enthalten | – |
| gesperrt | `user.disabled_at` | abgelehnt, Grund wird angezeigt | **weiterhin enthalten** | Admin (`DisableUserController`), CRM (`disableForRevokedCrmRoles()`) |
| gelöscht | `teamer.deleted_at` + `user.deleted_at` | abgelehnt | ausgeschlossen | ausschließlich `AccountDeletionHandler` |
A blocked teamer stays in the list on purpose: that list is the only place the block can be
lifted from. A deleted one is removed from every forward-looking process instead, which is
what makes the deletion legally meaningful.
### Why two columns
Both `Teamer` and `User` carry their own `deleted_at`. Teamers without a user exist, users
without a teamer exist, and there is no Doctrine SQL filter — every query filters by hand on
the alias it already has (`teamer` on the teamer side, `user` on the login and mail side). A
one-sided flag would force a join into roughly ten queries, each an opportunity to drop rows
through the wrong join type.
`src/Service/Teamer/AccountDeletionHandler.php` is the **only** place that writes either
flag, which is what keeps the two from drifting apart. Do not call `setDeleted()` on a Teamer
or User anywhere else.
### What a deletion excludes
`TeamerRepository::getListQuery()` (behind `includeDeleted`) and `getAutocompletionData()`,
`UserRepository::getAdministrativeUsers()` / `getUsersByRoleAndHotelCode()` /
`getAutocompletionData()`, `AvailabilityRepository::getListQuery()` (custom branch) and
`getSelectableForTeamer()`, every teamer-facing handler in `EmailNotificationSubscriber`, the
cron reminders in `UploadReminderService` and `DispositionReminderService`, new applications
(`ApplicationValidator`) and impersonation (`ImpersonationVoter`).
### What stays visible
Everything already recorded: dispositions, applications, uploads, feedback, and the contracts
and invoices rendered from them. `AssignmentRepository`, `DispositionRepository`,
`ApplicationRepository`, `UploadRepository`, `FeedbackRepository`, the dashboards and all of
`src/Service/Pdf/` deliberately do **not** filter. Deleted teamers are marked in the UI with
`templates/_partials/_teamer_deleted_badge.html.twig`; PDFs are left untouched so that a
document regenerated after a deletion is identical to the one issued before it.
### Login and CRM
`UserChecker` refuses a deleted account before it checks the block, so the deletion message
wins. `BpnAuthenticator::getOrCreateLocalUser()` returns a deleted user untouched before any
sync branch runs: nothing is written back, no role is granted or revoked, `lastLoginAt` is not
bumped. `UserDataHandler::findLocalUser()` still matches a deleted account **by email** —
excluding it there would make the caller take the person for unknown and create a second
account, resurrecting them under a new row.
Symfony's `ContextListener` does not re-run the user checker when restoring a session from
its cookie, so `DeletedUserSubscriber` ends the session of anyone deleted while logged in.
---
## Deliberate decisions and known edges
- **Markers grant nothing but are stored in `roles`.** Convenient (one column, one query),
but it means `getRoles()` contains strings that are not roles in any meaningful sense.
Always intersect against `User::ROLES` when asking "may this user do anything at all" —
`UserChecker` does exactly that.
- **`ROLE_TEAMER` is granted but never withdrawn automatically.** See 2. A strict mirror
would strip manual grants on the next login.
- **A denied nomination reappears.** See 3. There is no "rejected" state.
- **Sole-super-admin lockout.** A super admin who is not a teamer and loses their CRM admin
attribute is auto-blocked, and only another super admin can unblock them. With no second
super admin there is no route back through the UI.
- **`getUsersByRoleAndHotelCode()` matches granted roles only**, so notification recipients
(e.g. feedback reminders) never include people who are merely nominated. That is intended.
---
## File map
| Concern | File |
|---------|------|
| role constants, accessors, `setDisabled()` | `src/Entity/User.php` |
| CRM → roles mapping | `src/BusProNet/ResponseParser.php`, `src/BusProNet/Model/CrmAttributesResponse.php` |
| create / update / demote | `src/BusProNet/UserDataHandler.php` |
| login flow and the demotion branch | `src/Security/BpnAuthenticator.php` |
| login gate | `src/Security/UserChecker.php` |
| who may edit a user | `src/Security/Voter/UserVoter.php` |
| who may impersonate | `src/Security/Voter/ImpersonationVoter.php` |
| approval / block form | `src/Form/UserType.php`, `templates/admin/system/user/_form.html.twig` |
| teamer block / unblock | `src/Controller/Admin/Teamer/DisableUserController.php`, `src/Form/DisableUserType.php` |
| soft delete / restore | `src/Service/Teamer/AccountDeletionHandler.php`, `src/Entity/Traits/SoftDeletableEntity.php` |
| deletion by an admin | `src/Controller/Admin/Teamer/DeleteAccountController.php` |
| deletion by the teamer | `src/Controller/Teamer/Profile/DeleteAccountController.php`, `src/Form/DeleteAccountType.php` |
| session of a deleted user | `src/EventListener/DeletedUserSubscriber.php` |
| listing | `src/Repository/UserRepository.php` |
| hierarchy, firewall, impersonation | `config/packages/security.yaml` |
| CRM attribute ids | `config/services.yaml` |
## Tests
| File | Covers |
|------|--------|
| `tests/BusProNet/UserDataHandlerTest.php` | CRM → marker mapping, marker refresh, teamer role grant, demotion incl. the untouched disciplinary block |
| `tests/Security/BpnAuthenticatorTest.php` | the three demotion outcomes, including the empty-payload safeguard |
| `tests/Entity/UserTest.php` | `setDisabled()` block / unblock / no-op, block and deletion as independent states, OneToOne inverse-side sync |
| `tests/Service/Teamer/AccountDeletionHandlerTest.php` | deletion from either side, missing counterpart, idempotence, roles and block left untouched, restore |
| `tests/Security/UserCheckerTest.php` | deleted refused, deletion message wins over the block message |
| `tests/EventListener/EmailNotificationSubscriberTest.php` | no mail to a deleted teamer, per-teamer exclusion on an assignment call-off |
| `tests/Entity/Traits/SoftDeletableEntityTest.php` | nullable `deletedAt`, `setDeleted()` / `setRestored()` |
| `tests/Form/UserTypeTest.php` | approval clears the marker, block with reasons, unblock clears them even when the fields are still filled |
+5
View File
@@ -0,0 +1,5 @@
{
"prod": {
"saas_base_url": "https://desktop.saas.de/rest"
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240110122028 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE feedback CHANGE destination_name destination_name VARCHAR(255) DEFAULT NULL, CHANGE assignment_date assignment_date DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', CHANGE job_profile_name job_profile_name VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE feedback CHANGE destination_name destination_name VARCHAR(255) NOT NULL, CHANGE job_profile_name job_profile_name VARCHAR(255) NOT NULL, CHANGE assignment_date assignment_date DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\'');
}
}
-33
View File
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240110164517 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE application ADD pickup INT DEFAULT NULL');
$this->addSql('ALTER TABLE disposition ADD pickup INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE disposition DROP pickup');
$this->addSql('ALTER TABLE application DROP pickup');
}
}
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240128125138 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE upload ADD global TINYINT(1) NOT NULL');
}
public function postUp(Schema $schema): void
{
$this->connection->executeQuery('UPDATE upload SET global=0');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE upload DROP global');
}
}
-37
View File
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240131173953 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE assignment_upload DROP FOREIGN KEY FK_2F9CC602D19302F8');
$this->addSql('ALTER TABLE assignment_upload DROP FOREIGN KEY FK_2F9CC602CCCFBA31');
$this->addSql('DROP TABLE assignment_upload');
$this->addSql('ALTER TABLE upload DROP global');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE assignment_upload (assignment_id INT NOT NULL, upload_id INT NOT NULL, INDEX IDX_2F9CC602CCCFBA31 (upload_id), INDEX IDX_2F9CC602D19302F8 (assignment_id), PRIMARY KEY(assignment_id, upload_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'\' ');
$this->addSql('ALTER TABLE assignment_upload ADD CONSTRAINT FK_2F9CC602D19302F8 FOREIGN KEY (assignment_id) REFERENCES assignment (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE assignment_upload ADD CONSTRAINT FK_2F9CC602CCCFBA31 FOREIGN KEY (upload_id) REFERENCES upload (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE upload ADD global TINYINT(1) NOT NULL');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240131185423 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE assignment ADD job_profile_info LONGTEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE assignment DROP job_profile_info');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240214134047 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE feedback ADD hotel VARCHAR(255) DEFAULT NULL, ADD hotel_code VARCHAR(32) DEFAULT NULL, ADD hotel_bus_pro_id INT DEFAULT NULL, ADD assignment_date_to DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', CHANGE assignment_date assignment_date_from DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE feedback ADD assignment_date DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', DROP hotel, DROP hotel_code, DROP hotel_bus_pro_id, DROP assignment_date_from, DROP assignment_date_to');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240404071550 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE contact ADD phone VARCHAR(255) NOT NULL, ADD category VARCHAR(64) NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE contact DROP phone, DROP category');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240404091244 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE disposition ADD special_agreements LONGTEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE disposition DROP special_agreements');
}
}
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240417160406 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user ADD super_admin TINYINT(1) NOT NULL');
}
public function postUp(Schema $schema): void
{
$this->connection->executeQuery('UPDATE user SET super_admin = 0');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user DROP super_admin');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240515131333 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE destination ADD created_by VARCHAR(255) DEFAULT NULL, ADD updated_by VARCHAR(255) DEFAULT NULL, CHANGE bus_pro_id bus_pro_id INT DEFAULT NULL, CHANGE code code VARCHAR(32) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE destination DROP created_by, DROP updated_by, CHANGE bus_pro_id bus_pro_id INT NOT NULL, CHANGE code code VARCHAR(32) NOT NULL');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240516075149 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user ADD mute_notifications TINYINT(1) NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user DROP mute_notifications');
}
}
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240623151419 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE teamer ADD viewed TINYINT(1) NOT NULL');
}
public function postUp(Schema $schema): void
{
$this->connection->executeQuery('UPDATE teamer SET viewed = 1');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE teamer DROP viewed');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240623161718 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE news (id INT AUTO_INCREMENT NOT NULL, status SMALLINT NOT NULL, message LONGTEXT NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE news');
}
}
-29
View File
@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240623164458 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$this->connection->executeQuery('update assignment set status=? where status=?', ['published', 'open']);
}
public function down(Schema $schema): void
{
$this->connection->executeQuery('update assignment set status=? where status=?', ['open', 'published']);
}
}
-61
View File
@@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Entity\Assignment;
use App\Repository\AssignmentRepository;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerAwareTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240716063200 extends AbstractMigration implements ContainerAwareInterface
{
private ContainerInterface $container;
public function getDescription(): string
{
return '';
}
public function setContainer(?ContainerInterface $container): void
{
$this->container = $container;
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE assignment ADD staffing_status VARCHAR(64) NOT NULL');
}
public function postUp(Schema $schema): void
{
$entityManager = $this->container->get('doctrine.orm.entity_manager');
$assignmentRepository = $entityManager->getRepository(Assignment::class);
$assignments = $assignmentRepository->findAll();
foreach ($assignments as $assignment) {
if (Assignment::STATUS_DRAFT === $assignment->getStatus() || null !== $assignment->getDeletedAt()) {
continue;
}
$assignment->updateStaffingStatus();
}
$entityManager->flush();
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE assignment DROP staffing_status');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Entity\Assignment;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240718105318 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$this->connection->executeQuery('UPDATE assignment SET status=? WHERE deleted_at is NOT NULL', [Assignment::STATUS_DELETED]);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20241028164059 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE teamer CHANGE date_of_birth date_of_birth DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE teamer CHANGE date_of_birth date_of_birth DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\'');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20241030091854 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE rememberme_token (series VARCHAR(88) NOT NULL, value VARCHAR(88) NOT NULL, lastUsed DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', class VARCHAR(100) NOT NULL, username VARCHAR(200) NOT NULL, PRIMARY KEY(series)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE rememberme_token');
}
}
-46
View File
@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250107163840 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user ADD hotel_codes JSON NOT NULL COMMENT \'(DC2Type:json)\'');
}
public function postUp(Schema $schema): void
{
$this->connection->executeQuery('UPDATE user SET hotel_codes="[]" WHERE hotel_code IS NULL');
$rows = $this->connection->fetchAllAssociative('SELECT id, hotel_code FROM user WHERE hotel_code IS NOT NULL');
foreach ($rows as $row) {
$this->connection->update('user', [
'hotel_codes' => json_encode([$row['hotel_code']]),
], [
'id' => $row['id'],
]);
}
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user DROP hotel_codes');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250110161832 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user DROP hotel_code');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user ADD hotel_code VARCHAR(32) DEFAULT NULL');
}
}
-33
View File
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250131164533 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE rememberme_token');
$this->addSql('ALTER TABLE upload ADD downloaded_at DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', ADD downloaded_by VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE rememberme_token (series VARCHAR(88) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, value VARCHAR(88) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, lastUsed DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', class VARCHAR(100) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, username VARCHAR(200) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, PRIMARY KEY(series)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'\' ');
$this->addSql('ALTER TABLE upload DROP downloaded_at, DROP downloaded_by');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250210162751 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE teamer ADD remarks_internal LONGTEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE teamer DROP remarks_internal');
}
}
-35
View File
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250228133252 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE assignment ADD cost_center VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE destination ADD cost_unit VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE upload ADD approved_at DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', ADD approved_by VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE upload DROP approved_at, DROP approved_by');
$this->addSql('ALTER TABLE assignment DROP cost_center');
$this->addSql('ALTER TABLE destination DROP cost_unit');
}
}
-33
View File
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250318105943 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE assignment DROP cost_center');
$this->addSql('ALTER TABLE job_profile ADD cost_unit VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE job_profile DROP cost_unit');
$this->addSql('ALTER TABLE assignment ADD cost_center VARCHAR(255) DEFAULT NULL');
}
}
-35
View File
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250430080623 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE job_profile ADD category VARCHAR(255) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE job_profile DROP category
SQL);
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250722125647 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE disposition ADD called_off_by VARCHAR(64) DEFAULT NULL, ADD called_off_reason LONGTEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE disposition DROP called_off_by, DROP called_off_reason');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250722142834 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user ADD disabled_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', ADD disabled_reason LONGTEXT DEFAULT NULL, ADD disabled_reason_internal LONGTEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user DROP disabled_at, DROP disabled_reason, DROP disabled_reason_internal');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250930161953 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE disposition ADD synced_with_bus_pro_net TINYINT(1) NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE disposition DROP synced_with_bus_pro_net');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251215135758 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add allow_overlapping_applications column to teamer table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE teamer ADD allow_overlapping_applications TINYINT(1) NOT NULL DEFAULT 0');
}
public function postUp(Schema $schema): void
{
$this->connection->executeStatement('UPDATE teamer SET allow_overlapping_applications = 0');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE teamer DROP allow_overlapping_applications');
}
}
-35
View File
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251216135542 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE feedback ADD assignment_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE feedback ADD CONSTRAINT FK_D2294458D19302F8 FOREIGN KEY (assignment_id) REFERENCES assignment (id)');
$this->addSql('CREATE INDEX IDX_D2294458D19302F8 ON feedback (assignment_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE feedback DROP FOREIGN KEY FK_D2294458D19302F8');
$this->addSql('DROP INDEX IDX_D2294458D19302F8 ON feedback');
$this->addSql('ALTER TABLE feedback DROP assignment_id');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251216162601 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE feedback ADD job_profile_category VARCHAR(32) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE feedback DROP job_profile_category');
}
}
-59
View File
@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251218100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Split course category into course_ski/course_board and add event_team category based on job profile names';
}
public function up(Schema $schema): void
{
// Split course category: profiles with "Ski" in name become course_ski
$this->addSql("
UPDATE job_profile
SET category = 'course_ski'
WHERE category = 'course'
AND name LIKE '%Ski%'
");
// Split course category: profiles with "Board" or "Snowboard" in name become course_board
$this->addSql("
UPDATE job_profile
SET category = 'course_board'
WHERE category = 'course'
AND (name LIKE '%Board%' OR name LIKE '%Snowboard%')
");
// New event_team category for profiles containing "Event" in name
$this->addSql("
UPDATE job_profile
SET category = 'event_team'
WHERE name LIKE '%Event%'
");
}
public function down(Schema $schema): void
{
// Revert course_ski and course_board back to course
$this->addSql("
UPDATE job_profile
SET category = 'course'
WHERE category IN ('course_ski', 'course_board')
");
// Note: Cannot reliably revert event_team as we don't know original category
$this->addSql("
UPDATE job_profile
SET category = 'service'
WHERE category = 'event_team'
");
}
}
-43
View File
@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251218100001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Backfill assignment_id and job_profile_category on feedback table from existing disposition data';
}
public function up(Schema $schema): void
{
// Backfill assignment_id from disposition's feedback relation
$this->addSql('
UPDATE feedback f
INNER JOIN disposition d ON d.feedback_id = f.id
SET f.assignment_id = d.assignment_id
WHERE f.assignment_id IS NULL
');
// Backfill job_profile_category from assignment's job profile
$this->addSql('
UPDATE feedback f
INNER JOIN assignment a ON a.id = f.assignment_id
INNER JOIN job_profile jp ON jp.id = a.job_profile_id
SET f.job_profile_category = jp.category
WHERE f.job_profile_category IS NULL
AND f.assignment_id IS NOT NULL
');
}
public function down(Schema $schema): void
{
// Data migration - setting to NULL on rollback
$this->addSql('UPDATE feedback SET assignment_id = NULL, job_profile_category = NULL');
}
}
-35
View File
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251223131716 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add feedback_set_id relation to feedback table';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE feedback ADD feedback_set_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE feedback ADD CONSTRAINT FK_D22944582DB73956 FOREIGN KEY (feedback_set_id) REFERENCES feedback_set (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX IDX_D22944582DB73956 ON feedback (feedback_set_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE feedback DROP FOREIGN KEY FK_D22944582DB73956');
$this->addSql('DROP INDEX IDX_D22944582DB73956 ON feedback');
$this->addSql('ALTER TABLE feedback DROP feedback_set_id');
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260209104255 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE teamer CHANGE size size VARCHAR(3) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE teamer CHANGE size size VARCHAR(2) DEFAULT NULL');
}
}
-26
View File
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260218173855 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE teamer ADD data_verified_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)'");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE teamer DROP data_verified_at');
}
}
-33
View File
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260227111223 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE teamer ADD driver_license_upload_id INT DEFAULT NULL, ADD driver_license_declaration TINYINT(1) DEFAULT NULL, ADD driver_license_verified TINYINT(1) NOT NULL DEFAULT 0, ADD driver_license_status VARCHAR(64) NOT NULL DEFAULT \'none\', ADD driver_license_review_comment LONGTEXT DEFAULT NULL, ADD driver_license_reviewed_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', ADD driver_license_reviewed_by VARCHAR(8) DEFAULT NULL');
$this->addSql('ALTER TABLE teamer ADD CONSTRAINT FK_15B751AE1C882DBD FOREIGN KEY (driver_license_upload_id) REFERENCES upload (id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_15B751AE1C882DBD ON teamer (driver_license_upload_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE teamer DROP FOREIGN KEY FK_15B751AE1C882DBD');
$this->addSql('DROP INDEX UNIQ_15B751AE1C882DBD ON teamer');
$this->addSql('ALTER TABLE teamer DROP driver_license_upload_id, DROP driver_license_declaration, DROP driver_license_verified, DROP driver_license_status, DROP driver_license_review_comment, DROP driver_license_reviewed_at, DROP driver_license_reviewed_by');
}
}
@@ -10,7 +10,7 @@ use Doctrine\Migrations\AbstractMigration;
/** /**
* Auto-generated Migration: Please modify to your needs! * Auto-generated Migration: Please modify to your needs!
*/ */
final class Version20231212155746 extends AbstractMigration final class Version20260824124850 extends AbstractMigration
{ {
public function getDescription(): string public function getDescription(): string
{ {
@@ -20,12 +20,12 @@ final class Version20231212155746 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
// this up() migration is auto-generated, please modify it to your needs // this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE feedback ADD author VARCHAR(255) NOT NULL'); $this->addSql('ALTER TABLE teamer ADD all_season TINYINT(1) NOT NULL');
} }
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
// this down() migration is auto-generated, please modify it to your needs // this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE feedback DROP author'); $this->addSql('ALTER TABLE teamer DROP all_season');
} }
} }
@@ -10,7 +10,7 @@ use Doctrine\Migrations\AbstractMigration;
/** /**
* Auto-generated Migration: Please modify to your needs! * Auto-generated Migration: Please modify to your needs!
*/ */
final class Version20231208094506 extends AbstractMigration final class Version20260901090029 extends AbstractMigration
{ {
public function getDescription(): string public function getDescription(): string
{ {
@@ -20,30 +20,32 @@ final class Version20231208094506 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
// this up() migration is auto-generated, please modify it to your needs // this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE application (id INT AUTO_INCREMENT NOT NULL, assignment_id INT DEFAULT NULL, teamer_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, requests LONGTEXT DEFAULT NULL, status VARCHAR(64) NOT NULL, comment LONGTEXT DEFAULT NULL, comment_visible TINYINT(1) NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_A45BDDC1D17F50A6 (uuid), INDEX IDX_A45BDDC1D19302F8 (assignment_id), INDEX IDX_A45BDDC14302FF75 (teamer_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE application (id INT AUTO_INCREMENT NOT NULL, assignment_id INT DEFAULT NULL, teamer_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, requests LONGTEXT DEFAULT NULL, pickup INT DEFAULT NULL, status VARCHAR(64) NOT NULL, comment LONGTEXT DEFAULT NULL, comment_visible TINYINT(1) NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_A45BDDC1D17F50A6 (uuid), INDEX IDX_A45BDDC1D19302F8 (assignment_id), INDEX IDX_A45BDDC14302FF75 (teamer_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE assignment (id INT AUTO_INCREMENT NOT NULL, destination_id INT DEFAULT NULL, job_profile_id INT DEFAULT NULL, contact_id INT DEFAULT NULL, owner_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, available_dispositions INT DEFAULT NULL, show_available_dispositions TINYINT(1) NOT NULL, date_from DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', pickup_date DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', pickup INT DEFAULT NULL, benefits LONGTEXT DEFAULT NULL, remarks LONGTEXT DEFAULT NULL, status VARCHAR(64) NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_30C544BAD17F50A6 (uuid), INDEX IDX_30C544BA816C6140 (destination_id), INDEX IDX_30C544BAA68D82A5 (job_profile_id), INDEX IDX_30C544BAE7A1254A (contact_id), INDEX IDX_30C544BA7E3C61F9 (owner_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE assignment (id INT AUTO_INCREMENT NOT NULL, destination_id INT DEFAULT NULL, job_profile_id INT DEFAULT NULL, contact_id INT DEFAULT NULL, owner_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, job_profile_info LONGTEXT DEFAULT NULL, available_dispositions INT DEFAULT NULL, show_available_dispositions TINYINT(1) NOT NULL, skip_formalities TINYINT(1) NOT NULL, pickup_date DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', pickup INT DEFAULT NULL, benefits LONGTEXT DEFAULT NULL, remarks LONGTEXT DEFAULT NULL, status VARCHAR(64) NOT NULL, staffing_status VARCHAR(64) NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_30C544BAD17F50A6 (uuid), INDEX IDX_30C544BA816C6140 (destination_id), INDEX IDX_30C544BAA68D82A5 (job_profile_id), INDEX IDX_30C544BAE7A1254A (contact_id), INDEX IDX_30C544BA7E3C61F9 (owner_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE assignment_fee (assignment_id INT NOT NULL, fee_id INT NOT NULL, INDEX IDX_4E2AE50AD19302F8 (assignment_id), INDEX IDX_4E2AE50AAB45AECA (fee_id), PRIMARY KEY(assignment_id, fee_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE assignment_fee (assignment_id INT NOT NULL, fee_id INT NOT NULL, INDEX IDX_4E2AE50AD19302F8 (assignment_id), INDEX IDX_4E2AE50AAB45AECA (fee_id), PRIMARY KEY(assignment_id, fee_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE assignment_upload (assignment_id INT NOT NULL, upload_id INT NOT NULL, INDEX IDX_2F9CC602D19302F8 (assignment_id), INDEX IDX_2F9CC602CCCFBA31 (upload_id), PRIMARY KEY(assignment_id, upload_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE availability (id INT AUTO_INCREMENT NOT NULL, owner_id INT DEFAULT NULL, date_from DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', description LONGTEXT DEFAULT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_3FB7A2BF7E3C61F9 (owner_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE availability (id INT AUTO_INCREMENT NOT NULL, owner_id INT DEFAULT NULL, date_from DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', description LONGTEXT DEFAULT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_3FB7A2BF7E3C61F9 (owner_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE contact (id INT AUTO_INCREMENT NOT NULL, photo_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, destination VARCHAR(255) DEFAULT NULL, UNIQUE INDEX UNIQ_4C62E6387E9E4C8C (photo_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE contact (id INT AUTO_INCREMENT NOT NULL, photo_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, phone VARCHAR(255) NOT NULL, destination VARCHAR(255) DEFAULT NULL, category VARCHAR(64) NOT NULL, UNIQUE INDEX UNIQ_4C62E6387E9E4C8C (photo_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE destination (id INT AUTO_INCREMENT NOT NULL, bus_pro_id INT NOT NULL, code VARCHAR(32) NOT NULL, date_from DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', product VARCHAR(255) NOT NULL, hotel VARCHAR(255) NOT NULL, hotel_code VARCHAR(32) NOT NULL, hotel_bus_pro_id INT NOT NULL, pickups JSON NOT NULL COMMENT \'(DC2Type:json)\', country VARCHAR(2) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE destination (id INT AUTO_INCREMENT NOT NULL, bus_pro_id INT DEFAULT NULL, code VARCHAR(32) DEFAULT NULL, date_from DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', product VARCHAR(255) NOT NULL, hotel VARCHAR(255) NOT NULL, hotel_code VARCHAR(32) NOT NULL, hotel_bus_pro_id INT NOT NULL, cost_unit VARCHAR(255) DEFAULT NULL, pickups JSON NOT NULL, country VARCHAR(2) DEFAULT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE disposition (id INT AUTO_INCREMENT NOT NULL, assignment_id INT DEFAULT NULL, teamer_id INT DEFAULT NULL, feedback_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, status VARCHAR(64) NOT NULL, remarks LONGTEXT DEFAULT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_4C58BF60D17F50A6 (uuid), INDEX IDX_4C58BF60D19302F8 (assignment_id), INDEX IDX_4C58BF604302FF75 (teamer_id), UNIQUE INDEX UNIQ_4C58BF60D249A887 (feedback_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE disposition (id INT AUTO_INCREMENT NOT NULL, assignment_id INT DEFAULT NULL, teamer_id INT DEFAULT NULL, feedback_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, status VARCHAR(64) NOT NULL, pickup INT DEFAULT NULL, remarks LONGTEXT DEFAULT NULL, special_agreements LONGTEXT DEFAULT NULL, called_off_by VARCHAR(64) DEFAULT NULL, called_off_reason LONGTEXT DEFAULT NULL, synced_with_bus_pro_net TINYINT(1) NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_4C58BF60D17F50A6 (uuid), INDEX IDX_4C58BF60D19302F8 (assignment_id), INDEX IDX_4C58BF604302FF75 (teamer_id), UNIQUE INDEX UNIQ_4C58BF60D249A887 (feedback_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE email_text (id INT AUTO_INCREMENT NOT NULL, template_key VARCHAR(64) NOT NULL, subject VARCHAR(255) NOT NULL, headline VARCHAR(255) DEFAULT NULL, body LONGTEXT NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX uniq_email_text_template_key (template_key), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE faq (id INT AUTO_INCREMENT NOT NULL, question LONGTEXT NOT NULL, answer LONGTEXT NOT NULL, sorting INT NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE faq (id INT AUTO_INCREMENT NOT NULL, question LONGTEXT NOT NULL, answer LONGTEXT NOT NULL, sorting INT NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE fee (id INT AUTO_INCREMENT NOT NULL, value INT NOT NULL, name VARCHAR(255) NOT NULL, name_internal VARCHAR(255) NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE fee (id INT AUTO_INCREMENT NOT NULL, value INT NOT NULL, name VARCHAR(255) NOT NULL, name_internal VARCHAR(255) NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE feedback (id INT AUTO_INCREMENT NOT NULL, teamer_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, status VARCHAR(32) NOT NULL, destination_name VARCHAR(255) NOT NULL, job_profile_name VARCHAR(255) NOT NULL, assignment_date DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', ratings JSON NOT NULL COMMENT \'(DC2Type:json)\', average_rating INT DEFAULT NULL, comment LONGTEXT DEFAULT NULL, comment_internal LONGTEXT DEFAULT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_D2294458D17F50A6 (uuid), INDEX IDX_D22944584302FF75 (teamer_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE feedback (id INT AUTO_INCREMENT NOT NULL, teamer_id INT DEFAULT NULL, assignment_id INT DEFAULT NULL, feedback_set_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, status VARCHAR(32) NOT NULL, destination_name VARCHAR(255) DEFAULT NULL, job_profile_name VARCHAR(255) DEFAULT NULL, job_profile_category VARCHAR(32) DEFAULT NULL, hotel VARCHAR(255) DEFAULT NULL, hotel_code VARCHAR(32) DEFAULT NULL, hotel_bus_pro_id INT DEFAULT NULL, assignment_date_from DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', assignment_date_to DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', ratings JSON NOT NULL, average_rating INT DEFAULT NULL, comment LONGTEXT DEFAULT NULL, comment_internal LONGTEXT DEFAULT NULL, author VARCHAR(255) NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_D2294458D17F50A6 (uuid), INDEX IDX_D22944584302FF75 (teamer_id), INDEX IDX_D2294458D19302F8 (assignment_id), INDEX IDX_D22944582DB73956 (feedback_set_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE feedback_set (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, ratings JSON NOT NULL COMMENT \'(DC2Type:json)\', created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE feedback_set (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, ratings JSON NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE job_profile (id INT AUTO_INCREMENT NOT NULL, feedback_set_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(255) NOT NULL, required_licenses JSON NOT NULL COMMENT \'(DC2Type:json)\', created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_1BB81D94D17F50A6 (uuid), INDEX IDX_1BB81D942DB73956 (feedback_set_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE job_profile (id INT AUTO_INCREMENT NOT NULL, feedback_set_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(255) NOT NULL, category VARCHAR(255) DEFAULT NULL, required_licenses JSON NOT NULL, cost_unit VARCHAR(255) DEFAULT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_1BB81D94D17F50A6 (uuid), INDEX IDX_1BB81D942DB73956 (feedback_set_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE job_profile_training (job_profile_id INT NOT NULL, training_id INT NOT NULL, INDEX IDX_F19E0BB8A68D82A5 (job_profile_id), INDEX IDX_F19E0BB8BEFD98D1 (training_id), PRIMARY KEY(job_profile_id, training_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE job_profile_training (job_profile_id INT NOT NULL, training_id INT NOT NULL, INDEX IDX_F19E0BB8A68D82A5 (job_profile_id), INDEX IDX_F19E0BB8BEFD98D1 (training_id), PRIMARY KEY(job_profile_id, training_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE license (id INT AUTO_INCREMENT NOT NULL, certificate_id INT DEFAULT NULL, teamer_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, type VARCHAR(64) NOT NULL, date DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_5768F419D17F50A6 (uuid), UNIQUE INDEX UNIQ_5768F41999223FFD (certificate_id), INDEX IDX_5768F4194302FF75 (teamer_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE license (id INT AUTO_INCREMENT NOT NULL, certificate_id INT DEFAULT NULL, teamer_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, type VARCHAR(64) NOT NULL, date DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_5768F419D17F50A6 (uuid), UNIQUE INDEX UNIQ_5768F41999223FFD (certificate_id), INDEX IDX_5768F4194302FF75 (teamer_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE teamer (id INT AUTO_INCREMENT NOT NULL, photo_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, gender VARCHAR(1) NOT NULL, date_of_birth DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', academic_title VARCHAR(255) DEFAULT NULL, salutation VARCHAR(255) DEFAULT NULL, nationality VARCHAR(255) DEFAULT NULL, tax_id VARCHAR(255) DEFAULT NULL, health_insurance_company VARCHAR(255) DEFAULT NULL, status VARCHAR(32) NOT NULL, remarks LONGTEXT DEFAULT NULL, pickups JSON NOT NULL COMMENT \'(DC2Type:json)\', language VARCHAR(255) DEFAULT NULL, size VARCHAR(2) DEFAULT NULL, crm_selections JSON DEFAULT NULL COMMENT \'(DC2Type:json)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', address_street VARCHAR(255) DEFAULT NULL, address_post_code VARCHAR(255) DEFAULT NULL, address_city VARCHAR(255) DEFAULT NULL, address_country VARCHAR(255) DEFAULT NULL, communication_phone VARCHAR(255) DEFAULT NULL, communication_mobile VARCHAR(255) DEFAULT NULL, communication_email VARCHAR(255) DEFAULT NULL, bank_account_iban VARCHAR(255) DEFAULT NULL, bank_account_bic VARCHAR(255) DEFAULT NULL, bank_account_bank VARCHAR(255) DEFAULT NULL, bank_account_holder VARCHAR(255) DEFAULT NULL, UNIQUE INDEX UNIQ_15B751AED17F50A6 (uuid), UNIQUE INDEX UNIQ_15B751AE7E9E4C8C (photo_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE news (id INT AUTO_INCREMENT NOT NULL, status SMALLINT NOT NULL, message LONGTEXT NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE statistics_event (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(64) NOT NULL, occurred_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', actor_id INT DEFAULT NULL, actor_label VARCHAR(180) DEFAULT NULL, actor_role VARCHAR(32) DEFAULT NULL, assignment_id INT DEFAULT NULL, disposition_id INT DEFAULT NULL, application_id INT DEFAULT NULL, teamer_id INT DEFAULT NULL, destination_id INT DEFAULT NULL, job_profile_id INT DEFAULT NULL, hotel_code VARCHAR(8) DEFAULT NULL, payload JSON NOT NULL, INDEX IDX_A343D13C5E237E0687C03D1B (name, occurred_at), INDEX IDX_A343D13C5E237E06C0D0E610 (name, hotel_code), INDEX IDX_A343D13CD19302F8 (assignment_id), INDEX IDX_A343D13C4302FF75 (teamer_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE teamer (id INT AUTO_INCREMENT NOT NULL, photo_id INT DEFAULT NULL, driver_license_upload_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, gender VARCHAR(1) NOT NULL, date_of_birth DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', academic_title VARCHAR(255) DEFAULT NULL, salutation VARCHAR(255) DEFAULT NULL, nationality VARCHAR(255) DEFAULT NULL, tax_id VARCHAR(255) DEFAULT NULL, health_insurance_company VARCHAR(255) DEFAULT NULL, status VARCHAR(32) NOT NULL, remarks LONGTEXT DEFAULT NULL, remarks_internal LONGTEXT DEFAULT NULL, pickups JSON NOT NULL, language VARCHAR(255) DEFAULT NULL, size VARCHAR(3) DEFAULT NULL, meal_preference VARCHAR(255) DEFAULT NULL, crm_selections JSON DEFAULT NULL, viewed TINYINT(1) NOT NULL, allow_overlapping_applications TINYINT(1) NOT NULL, data_verified_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', driver_license_declaration TINYINT(1) DEFAULT NULL, driver_license_verified TINYINT(1) NOT NULL, driver_license_status VARCHAR(64) NOT NULL, driver_license_review_comment LONGTEXT DEFAULT NULL, driver_license_reviewed_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', driver_license_reviewed_by VARCHAR(8) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', address_street VARCHAR(255) DEFAULT NULL, address_post_code VARCHAR(255) DEFAULT NULL, address_city VARCHAR(255) DEFAULT NULL, address_country VARCHAR(255) DEFAULT NULL, communication_phone VARCHAR(255) DEFAULT NULL, communication_mobile VARCHAR(255) DEFAULT NULL, communication_email VARCHAR(255) DEFAULT NULL, bank_account_iban VARCHAR(255) DEFAULT NULL, bank_account_bic VARCHAR(255) DEFAULT NULL, bank_account_bank VARCHAR(255) DEFAULT NULL, bank_account_holder VARCHAR(255) DEFAULT NULL, UNIQUE INDEX UNIQ_15B751AED17F50A6 (uuid), UNIQUE INDEX UNIQ_15B751AE7E9E4C8C (photo_id), UNIQUE INDEX UNIQ_15B751AE1C882DBD (driver_license_upload_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE teamer_availability (teamer_id INT NOT NULL, availability_id INT NOT NULL, INDEX IDX_9CF7958B4302FF75 (teamer_id), INDEX IDX_9CF7958B61778466 (availability_id), PRIMARY KEY(teamer_id, availability_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE teamer_availability (teamer_id INT NOT NULL, availability_id INT NOT NULL, INDEX IDX_9CF7958B4302FF75 (teamer_id), INDEX IDX_9CF7958B61778466 (availability_id), PRIMARY KEY(teamer_id, availability_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE teamer_job_profile (teamer_id INT NOT NULL, job_profile_id INT NOT NULL, INDEX IDX_E78F5DF64302FF75 (teamer_id), INDEX IDX_E78F5DF6A68D82A5 (job_profile_id), PRIMARY KEY(teamer_id, job_profile_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE teamer_job_profile (teamer_id INT NOT NULL, job_profile_id INT NOT NULL, INDEX IDX_E78F5DF64302FF75 (teamer_id), INDEX IDX_E78F5DF6A68D82A5 (job_profile_id), PRIMARY KEY(teamer_id, job_profile_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE teamer_assignment (teamer_id INT NOT NULL, assignment_id INT NOT NULL, INDEX IDX_BE0D5AE14302FF75 (teamer_id), INDEX IDX_BE0D5AE1D19302F8 (assignment_id), PRIMARY KEY(teamer_id, assignment_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE teamer_assignment (teamer_id INT NOT NULL, assignment_id INT NOT NULL, INDEX IDX_BE0D5AE14302FF75 (teamer_id), INDEX IDX_BE0D5AE1D19302F8 (assignment_id), PRIMARY KEY(teamer_id, assignment_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE training (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE training (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE training_attendance (id INT AUTO_INCREMENT NOT NULL, training_id INT DEFAULT NULL, certificate_id INT DEFAULT NULL, teamer_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, date DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_D75DB7F7D17F50A6 (uuid), INDEX IDX_D75DB7F7BEFD98D1 (training_id), UNIQUE INDEX UNIQ_D75DB7F799223FFD (certificate_id), INDEX IDX_D75DB7F74302FF75 (teamer_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE training_attendance (id INT AUTO_INCREMENT NOT NULL, training_id INT DEFAULT NULL, certificate_id INT DEFAULT NULL, teamer_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, date DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_D75DB7F7D17F50A6 (uuid), INDEX IDX_D75DB7F7BEFD98D1 (training_id), UNIQUE INDEX UNIQ_D75DB7F799223FFD (certificate_id), INDEX IDX_D75DB7F74302FF75 (teamer_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE upload (id INT AUTO_INCREMENT NOT NULL, owner_id INT DEFAULT NULL, disposition_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, type VARCHAR(64) NOT NULL, status VARCHAR(64) DEFAULT NULL, filename VARCHAR(255) NOT NULL, original_filename VARCHAR(255) NOT NULL, display_name VARCHAR(255) DEFAULT NULL, size INT NOT NULL, mime_type VARCHAR(255) NOT NULL, comment LONGTEXT DEFAULT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_17BDE61FD17F50A6 (uuid), INDEX IDX_17BDE61F7E3C61F9 (owner_id), INDEX IDX_17BDE61F287B65ED (disposition_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE upload (id INT AUTO_INCREMENT NOT NULL, owner_id INT DEFAULT NULL, disposition_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, type VARCHAR(64) NOT NULL, status VARCHAR(64) DEFAULT NULL, filename VARCHAR(255) NOT NULL, original_filename VARCHAR(255) NOT NULL, display_name VARCHAR(255) DEFAULT NULL, size INT NOT NULL, mime_type VARCHAR(255) NOT NULL, comment LONGTEXT DEFAULT NULL, downloaded_at DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', downloaded_by VARCHAR(255) DEFAULT NULL, approved_at DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', approved_by VARCHAR(255) DEFAULT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_17BDE61FD17F50A6 (uuid), INDEX IDX_17BDE61F7E3C61F9 (owner_id), INDEX IDX_17BDE61F287B65ED (disposition_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE user (id INT AUTO_INCREMENT NOT NULL, teamer_id INT DEFAULT NULL, contact_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, bus_pro_address_id INT NOT NULL, bus_pro_person_id INT NOT NULL, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, roles JSON NOT NULL COMMENT \'(DC2Type:json)\', last_login_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', hotel_code VARCHAR(32) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_8D93D649D17F50A6 (uuid), UNIQUE INDEX UNIQ_8D93D6494302FF75 (teamer_id), UNIQUE INDEX UNIQ_8D93D649E7A1254A (contact_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE user (id INT AUTO_INCREMENT NOT NULL, teamer_id INT DEFAULT NULL, contact_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, bus_pro_address_id INT NOT NULL, bus_pro_person_id INT NOT NULL, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, roles JSON NOT NULL, super_admin TINYINT(1) NOT NULL, last_login_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', hotel_codes JSON NOT NULL, mute_notifications TINYINT(1) NOT NULL, disabled_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', disabled_reason LONGTEXT DEFAULT NULL, disabled_reason_internal LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_8D93D649D17F50A6 (uuid), UNIQUE INDEX UNIQ_8D93D6494302FF75 (teamer_id), UNIQUE INDEX UNIQ_8D93D649E7A1254A (contact_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE messenger_messages (id BIGINT AUTO_INCREMENT NOT NULL, body LONGTEXT NOT NULL, headers LONGTEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', available_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', delivered_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_75EA56E0FB7336F0 (queue_name), INDEX IDX_75EA56E0E3BD61CE (available_at), INDEX IDX_75EA56E016BA31DB (delivered_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE messenger_messages (id BIGINT AUTO_INCREMENT NOT NULL, body LONGTEXT NOT NULL, headers LONGTEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', available_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', delivered_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 (queue_name, available_at, delivered_at, id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE application ADD CONSTRAINT FK_A45BDDC1D19302F8 FOREIGN KEY (assignment_id) REFERENCES assignment (id)'); $this->addSql('ALTER TABLE application ADD CONSTRAINT FK_A45BDDC1D19302F8 FOREIGN KEY (assignment_id) REFERENCES assignment (id)');
$this->addSql('ALTER TABLE application ADD CONSTRAINT FK_A45BDDC14302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)'); $this->addSql('ALTER TABLE application ADD CONSTRAINT FK_A45BDDC14302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)');
$this->addSql('ALTER TABLE assignment ADD CONSTRAINT FK_30C544BA816C6140 FOREIGN KEY (destination_id) REFERENCES destination (id)'); $this->addSql('ALTER TABLE assignment ADD CONSTRAINT FK_30C544BA816C6140 FOREIGN KEY (destination_id) REFERENCES destination (id)');
@@ -52,20 +54,21 @@ final class Version20231208094506 extends AbstractMigration
$this->addSql('ALTER TABLE assignment ADD CONSTRAINT FK_30C544BA7E3C61F9 FOREIGN KEY (owner_id) REFERENCES user (id)'); $this->addSql('ALTER TABLE assignment ADD CONSTRAINT FK_30C544BA7E3C61F9 FOREIGN KEY (owner_id) REFERENCES user (id)');
$this->addSql('ALTER TABLE assignment_fee ADD CONSTRAINT FK_4E2AE50AD19302F8 FOREIGN KEY (assignment_id) REFERENCES assignment (id) ON DELETE CASCADE'); $this->addSql('ALTER TABLE assignment_fee ADD CONSTRAINT FK_4E2AE50AD19302F8 FOREIGN KEY (assignment_id) REFERENCES assignment (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE assignment_fee ADD CONSTRAINT FK_4E2AE50AAB45AECA FOREIGN KEY (fee_id) REFERENCES fee (id) ON DELETE CASCADE'); $this->addSql('ALTER TABLE assignment_fee ADD CONSTRAINT FK_4E2AE50AAB45AECA FOREIGN KEY (fee_id) REFERENCES fee (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE assignment_upload ADD CONSTRAINT FK_2F9CC602D19302F8 FOREIGN KEY (assignment_id) REFERENCES assignment (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE assignment_upload ADD CONSTRAINT FK_2F9CC602CCCFBA31 FOREIGN KEY (upload_id) REFERENCES upload (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE availability ADD CONSTRAINT FK_3FB7A2BF7E3C61F9 FOREIGN KEY (owner_id) REFERENCES teamer (id)'); $this->addSql('ALTER TABLE availability ADD CONSTRAINT FK_3FB7A2BF7E3C61F9 FOREIGN KEY (owner_id) REFERENCES teamer (id)');
$this->addSql('ALTER TABLE contact ADD CONSTRAINT FK_4C62E6387E9E4C8C FOREIGN KEY (photo_id) REFERENCES upload (id)'); $this->addSql('ALTER TABLE contact ADD CONSTRAINT FK_4C62E6387E9E4C8C FOREIGN KEY (photo_id) REFERENCES upload (id)');
$this->addSql('ALTER TABLE disposition ADD CONSTRAINT FK_4C58BF60D19302F8 FOREIGN KEY (assignment_id) REFERENCES assignment (id)'); $this->addSql('ALTER TABLE disposition ADD CONSTRAINT FK_4C58BF60D19302F8 FOREIGN KEY (assignment_id) REFERENCES assignment (id)');
$this->addSql('ALTER TABLE disposition ADD CONSTRAINT FK_4C58BF604302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)'); $this->addSql('ALTER TABLE disposition ADD CONSTRAINT FK_4C58BF604302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)');
$this->addSql('ALTER TABLE disposition ADD CONSTRAINT FK_4C58BF60D249A887 FOREIGN KEY (feedback_id) REFERENCES feedback (id) ON DELETE SET NULL'); $this->addSql('ALTER TABLE disposition ADD CONSTRAINT FK_4C58BF60D249A887 FOREIGN KEY (feedback_id) REFERENCES feedback (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE feedback ADD CONSTRAINT FK_D22944584302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)'); $this->addSql('ALTER TABLE feedback ADD CONSTRAINT FK_D22944584302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)');
$this->addSql('ALTER TABLE feedback ADD CONSTRAINT FK_D2294458D19302F8 FOREIGN KEY (assignment_id) REFERENCES assignment (id)');
$this->addSql('ALTER TABLE feedback ADD CONSTRAINT FK_D22944582DB73956 FOREIGN KEY (feedback_set_id) REFERENCES feedback_set (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE job_profile ADD CONSTRAINT FK_1BB81D942DB73956 FOREIGN KEY (feedback_set_id) REFERENCES feedback_set (id) ON DELETE SET NULL'); $this->addSql('ALTER TABLE job_profile ADD CONSTRAINT FK_1BB81D942DB73956 FOREIGN KEY (feedback_set_id) REFERENCES feedback_set (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE job_profile_training ADD CONSTRAINT FK_F19E0BB8A68D82A5 FOREIGN KEY (job_profile_id) REFERENCES job_profile (id) ON DELETE CASCADE'); $this->addSql('ALTER TABLE job_profile_training ADD CONSTRAINT FK_F19E0BB8A68D82A5 FOREIGN KEY (job_profile_id) REFERENCES job_profile (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE job_profile_training ADD CONSTRAINT FK_F19E0BB8BEFD98D1 FOREIGN KEY (training_id) REFERENCES training (id) ON DELETE CASCADE'); $this->addSql('ALTER TABLE job_profile_training ADD CONSTRAINT FK_F19E0BB8BEFD98D1 FOREIGN KEY (training_id) REFERENCES training (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE license ADD CONSTRAINT FK_5768F41999223FFD FOREIGN KEY (certificate_id) REFERENCES upload (id)'); $this->addSql('ALTER TABLE license ADD CONSTRAINT FK_5768F41999223FFD FOREIGN KEY (certificate_id) REFERENCES upload (id)');
$this->addSql('ALTER TABLE license ADD CONSTRAINT FK_5768F4194302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)'); $this->addSql('ALTER TABLE license ADD CONSTRAINT FK_5768F4194302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)');
$this->addSql('ALTER TABLE teamer ADD CONSTRAINT FK_15B751AE7E9E4C8C FOREIGN KEY (photo_id) REFERENCES upload (id)'); $this->addSql('ALTER TABLE teamer ADD CONSTRAINT FK_15B751AE7E9E4C8C FOREIGN KEY (photo_id) REFERENCES upload (id)');
$this->addSql('ALTER TABLE teamer ADD CONSTRAINT FK_15B751AE1C882DBD FOREIGN KEY (driver_license_upload_id) REFERENCES upload (id)');
$this->addSql('ALTER TABLE teamer_availability ADD CONSTRAINT FK_9CF7958B4302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id) ON DELETE CASCADE'); $this->addSql('ALTER TABLE teamer_availability ADD CONSTRAINT FK_9CF7958B4302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE teamer_availability ADD CONSTRAINT FK_9CF7958B61778466 FOREIGN KEY (availability_id) REFERENCES availability (id) ON DELETE CASCADE'); $this->addSql('ALTER TABLE teamer_availability ADD CONSTRAINT FK_9CF7958B61778466 FOREIGN KEY (availability_id) REFERENCES availability (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE teamer_job_profile ADD CONSTRAINT FK_E78F5DF64302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id) ON DELETE CASCADE'); $this->addSql('ALTER TABLE teamer_job_profile ADD CONSTRAINT FK_E78F5DF64302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id) ON DELETE CASCADE');
@@ -92,20 +95,21 @@ final class Version20231208094506 extends AbstractMigration
$this->addSql('ALTER TABLE assignment DROP FOREIGN KEY FK_30C544BA7E3C61F9'); $this->addSql('ALTER TABLE assignment DROP FOREIGN KEY FK_30C544BA7E3C61F9');
$this->addSql('ALTER TABLE assignment_fee DROP FOREIGN KEY FK_4E2AE50AD19302F8'); $this->addSql('ALTER TABLE assignment_fee DROP FOREIGN KEY FK_4E2AE50AD19302F8');
$this->addSql('ALTER TABLE assignment_fee DROP FOREIGN KEY FK_4E2AE50AAB45AECA'); $this->addSql('ALTER TABLE assignment_fee DROP FOREIGN KEY FK_4E2AE50AAB45AECA');
$this->addSql('ALTER TABLE assignment_upload DROP FOREIGN KEY FK_2F9CC602D19302F8');
$this->addSql('ALTER TABLE assignment_upload DROP FOREIGN KEY FK_2F9CC602CCCFBA31');
$this->addSql('ALTER TABLE availability DROP FOREIGN KEY FK_3FB7A2BF7E3C61F9'); $this->addSql('ALTER TABLE availability DROP FOREIGN KEY FK_3FB7A2BF7E3C61F9');
$this->addSql('ALTER TABLE contact DROP FOREIGN KEY FK_4C62E6387E9E4C8C'); $this->addSql('ALTER TABLE contact DROP FOREIGN KEY FK_4C62E6387E9E4C8C');
$this->addSql('ALTER TABLE disposition DROP FOREIGN KEY FK_4C58BF60D19302F8'); $this->addSql('ALTER TABLE disposition DROP FOREIGN KEY FK_4C58BF60D19302F8');
$this->addSql('ALTER TABLE disposition DROP FOREIGN KEY FK_4C58BF604302FF75'); $this->addSql('ALTER TABLE disposition DROP FOREIGN KEY FK_4C58BF604302FF75');
$this->addSql('ALTER TABLE disposition DROP FOREIGN KEY FK_4C58BF60D249A887'); $this->addSql('ALTER TABLE disposition DROP FOREIGN KEY FK_4C58BF60D249A887');
$this->addSql('ALTER TABLE feedback DROP FOREIGN KEY FK_D22944584302FF75'); $this->addSql('ALTER TABLE feedback DROP FOREIGN KEY FK_D22944584302FF75');
$this->addSql('ALTER TABLE feedback DROP FOREIGN KEY FK_D2294458D19302F8');
$this->addSql('ALTER TABLE feedback DROP FOREIGN KEY FK_D22944582DB73956');
$this->addSql('ALTER TABLE job_profile DROP FOREIGN KEY FK_1BB81D942DB73956'); $this->addSql('ALTER TABLE job_profile DROP FOREIGN KEY FK_1BB81D942DB73956');
$this->addSql('ALTER TABLE job_profile_training DROP FOREIGN KEY FK_F19E0BB8A68D82A5'); $this->addSql('ALTER TABLE job_profile_training DROP FOREIGN KEY FK_F19E0BB8A68D82A5');
$this->addSql('ALTER TABLE job_profile_training DROP FOREIGN KEY FK_F19E0BB8BEFD98D1'); $this->addSql('ALTER TABLE job_profile_training DROP FOREIGN KEY FK_F19E0BB8BEFD98D1');
$this->addSql('ALTER TABLE license DROP FOREIGN KEY FK_5768F41999223FFD'); $this->addSql('ALTER TABLE license DROP FOREIGN KEY FK_5768F41999223FFD');
$this->addSql('ALTER TABLE license DROP FOREIGN KEY FK_5768F4194302FF75'); $this->addSql('ALTER TABLE license DROP FOREIGN KEY FK_5768F4194302FF75');
$this->addSql('ALTER TABLE teamer DROP FOREIGN KEY FK_15B751AE7E9E4C8C'); $this->addSql('ALTER TABLE teamer DROP FOREIGN KEY FK_15B751AE7E9E4C8C');
$this->addSql('ALTER TABLE teamer DROP FOREIGN KEY FK_15B751AE1C882DBD');
$this->addSql('ALTER TABLE teamer_availability DROP FOREIGN KEY FK_9CF7958B4302FF75'); $this->addSql('ALTER TABLE teamer_availability DROP FOREIGN KEY FK_9CF7958B4302FF75');
$this->addSql('ALTER TABLE teamer_availability DROP FOREIGN KEY FK_9CF7958B61778466'); $this->addSql('ALTER TABLE teamer_availability DROP FOREIGN KEY FK_9CF7958B61778466');
$this->addSql('ALTER TABLE teamer_job_profile DROP FOREIGN KEY FK_E78F5DF64302FF75'); $this->addSql('ALTER TABLE teamer_job_profile DROP FOREIGN KEY FK_E78F5DF64302FF75');
@@ -122,11 +126,11 @@ final class Version20231208094506 extends AbstractMigration
$this->addSql('DROP TABLE application'); $this->addSql('DROP TABLE application');
$this->addSql('DROP TABLE assignment'); $this->addSql('DROP TABLE assignment');
$this->addSql('DROP TABLE assignment_fee'); $this->addSql('DROP TABLE assignment_fee');
$this->addSql('DROP TABLE assignment_upload');
$this->addSql('DROP TABLE availability'); $this->addSql('DROP TABLE availability');
$this->addSql('DROP TABLE contact'); $this->addSql('DROP TABLE contact');
$this->addSql('DROP TABLE destination'); $this->addSql('DROP TABLE destination');
$this->addSql('DROP TABLE disposition'); $this->addSql('DROP TABLE disposition');
$this->addSql('DROP TABLE email_text');
$this->addSql('DROP TABLE faq'); $this->addSql('DROP TABLE faq');
$this->addSql('DROP TABLE fee'); $this->addSql('DROP TABLE fee');
$this->addSql('DROP TABLE feedback'); $this->addSql('DROP TABLE feedback');
@@ -134,6 +138,8 @@ final class Version20231208094506 extends AbstractMigration
$this->addSql('DROP TABLE job_profile'); $this->addSql('DROP TABLE job_profile');
$this->addSql('DROP TABLE job_profile_training'); $this->addSql('DROP TABLE job_profile_training');
$this->addSql('DROP TABLE license'); $this->addSql('DROP TABLE license');
$this->addSql('DROP TABLE news');
$this->addSql('DROP TABLE statistics_event');
$this->addSql('DROP TABLE teamer'); $this->addSql('DROP TABLE teamer');
$this->addSql('DROP TABLE teamer_availability'); $this->addSql('DROP TABLE teamer_availability');
$this->addSql('DROP TABLE teamer_job_profile'); $this->addSql('DROP TABLE teamer_job_profile');
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Adds the season_assignment table: the placement of an all-season teamer at a destination.
*
* Written by hand rather than taken from doctrine:migrations:diff - the diff against this
* database also carries a large amount of pre-existing drift (datetime and JSON column
* declarations across nearly every table) that has nothing to do with this feature.
*/
final class Version20260909124625 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add season_assignment table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE season_assignment (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, remarks LONGTEXT DEFAULT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, deleted_at DATETIME DEFAULT NULL, teamer_id INT DEFAULT NULL, destination_id INT DEFAULT NULL, job_profile_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_FB891DD17F50A6 (uuid), INDEX IDX_FB891D4302FF75 (teamer_id), INDEX IDX_FB891D816C6140 (destination_id), INDEX IDX_FB891DA68D82A5 (job_profile_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE season_assignment ADD CONSTRAINT FK_FB891D4302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)');
$this->addSql('ALTER TABLE season_assignment ADD CONSTRAINT FK_FB891D816C6140 FOREIGN KEY (destination_id) REFERENCES destination (id)');
$this->addSql('ALTER TABLE season_assignment ADD CONSTRAINT FK_FB891DA68D82A5 FOREIGN KEY (job_profile_id) REFERENCES job_profile (id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE season_assignment DROP FOREIGN KEY FK_FB891D4302FF75');
$this->addSql('ALTER TABLE season_assignment DROP FOREIGN KEY FK_FB891D816C6140');
$this->addSql('ALTER TABLE season_assignment DROP FOREIGN KEY FK_FB891DA68D82A5');
$this->addSql('DROP TABLE season_assignment');
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Adds the season assignment checklist: one row per checklist entry per assignment, the binding
* of an entry to a document from the global library, and the two links a season assignment needs
* to carry its own files.
*
* Written by hand rather than taken from doctrine:migrations:diff, like Version20260909124625 -
* the diff against this database also carries the pre-existing drift in datetime and JSON column
* declarations, which has nothing to do with this feature.
*/
final class Version20260915103000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add season assignment tasks, task document mapping and season uploads';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE season_assignment_task (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, task_key VARCHAR(64) NOT NULL, done TINYINT(1) NOT NULL, done_at DATETIME DEFAULT NULL, done_by VARCHAR(255) DEFAULT NULL, uploaded TINYINT(1) NOT NULL, deadline_override1 DATE DEFAULT NULL, deadline_override2 DATE DEFAULT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, season_assignment_id INT NOT NULL, UNIQUE INDEX UNIQ_BEEB9D8DD17F50A6 (uuid), UNIQUE INDEX season_assignment_task_unique (season_assignment_id, task_key), INDEX IDX_BEEB9D8DB291FD85 (season_assignment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE season_task_document (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, task_key VARCHAR(64) NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, document_id INT NOT NULL, UNIQUE INDEX UNIQ_FBAB9F47D17F50A6 (uuid), UNIQUE INDEX UNIQ_FBAB9F47E512532E (task_key), INDEX IDX_FBAB9F47C33F7837 (document_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE season_assignment_task ADD CONSTRAINT FK_BEEB9D8DB291FD85 FOREIGN KEY (season_assignment_id) REFERENCES season_assignment (id)');
$this->addSql('ALTER TABLE season_task_document ADD CONSTRAINT FK_FBAB9F47C33F7837 FOREIGN KEY (document_id) REFERENCES upload (id)');
$this->addSql('ALTER TABLE season_assignment ADD done_teamer TINYINT(1) NOT NULL');
$this->addSql('ALTER TABLE upload ADD season_assignment_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE upload ADD CONSTRAINT FK_17BDE61FB291FD85 FOREIGN KEY (season_assignment_id) REFERENCES season_assignment (id)');
$this->addSql('CREATE INDEX IDX_17BDE61FB291FD85 ON upload (season_assignment_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE upload DROP FOREIGN KEY FK_17BDE61FB291FD85');
$this->addSql('DROP INDEX IDX_17BDE61FB291FD85 ON upload');
$this->addSql('ALTER TABLE upload DROP season_assignment_id');
$this->addSql('ALTER TABLE season_assignment DROP done_teamer');
$this->addSql('ALTER TABLE season_assignment_task DROP FOREIGN KEY FK_BEEB9D8DB291FD85');
$this->addSql('ALTER TABLE season_task_document DROP FOREIGN KEY FK_FBAB9F47C33F7837');
$this->addSql('DROP TABLE season_assignment_task');
$this->addSql('DROP TABLE season_task_document');
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Replaces the season assignment's Destination relation with its own period and house.
*
* A Destination is one travel with one departure window; an all-season placement runs a whole
* season at one house, so the relation only ever meant "borrow three scalars from an arbitrary
* travel row". The house is now a key of the configured "houses" map, which is keyed by the
* normalized hotel code - hence the SER stripping in the backfill, mirroring
* App\Entity\Destination::getHotelCodeNormalized().
*
* Written by hand rather than taken from doctrine:migrations:diff, like Version20260909124625 -
* the diff against this database also carries the pre-existing drift in datetime and JSON column
* declarations, which has nothing to do with this feature.
*/
final class Version20260916160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Move season assignment period and house onto the assignment itself';
}
public function up(Schema $schema): void
{
// nullable first, so the existing rows can be backfilled before the columns are tightened
$this->addSql('ALTER TABLE season_assignment ADD date_from DATE DEFAULT NULL, ADD date_to DATE DEFAULT NULL, ADD hotel_code VARCHAR(32) DEFAULT NULL');
$this->addSql('UPDATE season_assignment sa JOIN destination d ON d.id = sa.destination_id SET sa.date_from = d.date_from, sa.date_to = d.date_to, sa.hotel_code = CASE WHEN d.hotel_code LIKE \'SER%\' THEN SUBSTRING(d.hotel_code, 4, 3) ELSE SUBSTRING(d.hotel_code, 1, 3) END');
$this->addSql('ALTER TABLE season_assignment CHANGE date_from date_from DATE NOT NULL, CHANGE date_to date_to DATE NOT NULL, CHANGE hotel_code hotel_code VARCHAR(32) NOT NULL');
$this->addSql('ALTER TABLE season_assignment DROP FOREIGN KEY FK_FB891D816C6140');
$this->addSql('DROP INDEX IDX_FB891D816C6140 ON season_assignment');
$this->addSql('ALTER TABLE season_assignment DROP destination_id');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE season_assignment ADD destination_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE season_assignment ADD CONSTRAINT FK_FB891D816C6140 FOREIGN KEY (destination_id) REFERENCES destination (id)');
$this->addSql('CREATE INDEX IDX_FB891D816C6140 ON season_assignment (destination_id)');
// The destination a placement was cut from cannot be recovered from a period and a house
// code, so the relation comes back empty.
$this->addSql('ALTER TABLE season_assignment DROP date_from, DROP date_to, DROP hotel_code');
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Drops the season assignment's done_teamer flag.
*
* It was a note the office set by hand and it gated nothing - neither the weekly teamer digest
* nor the office deadline summary ever read it. Both stages it was meant to express follow from
* the checklist rows, so App\Service\Season\SeasonChecklistBuilder derives them instead.
*
* Written by hand rather than taken from doctrine:migrations:diff, like Version20260916160000:
* the diff against this database also carries the pre-existing drift in datetime and JSON column
* declarations, which has nothing to do with this change.
*/
final class Version20260917120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Drop the informative done_teamer flag from season assignments';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE season_assignment DROP done_teamer');
}
public function down(Schema $schema): void
{
// The hand-set notes themselves cannot be recovered, so the column comes back unset.
$this->addSql('ALTER TABLE season_assignment ADD done_teamer TINYINT(1) NOT NULL');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Renames the checklist entry's uploaded flag to office_done.
*
* The flag is the office's own step on an entry, and that step differs per entry - filing a
* returned contract is not the same act as registering someone with the municipality. Named
* after a file arriving, it read as a second kind of "done" everywhere else, next to the done
* flag that actually closes the entry.
*
* A rename, not a new column: the ticks themselves stay.
*/
final class Version20260917123000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Rename season_assignment_task.uploaded to office_done';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE season_assignment_task CHANGE uploaded office_done TINYINT(1) NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE season_assignment_task CHANGE office_done uploaded TINYINT(1) NOT NULL');
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Copies the library document onto the checklist row.
*
* The library holds what the next assignments get and is replaced from season to season, so
* resolving it on read rewrote what an assignment that already exists - a finished one included -
* hands out. The row carries its own reference from now on.
*
* The backfill maps every existing row to what it resolves to today, so nothing changes for the
* rows that are already out there. ON DELETE SET NULL keeps a row readable when the library file
* itself is deleted: "kein Dokument hinterlegt" is a state every view already handles.
*/
final class Version20260917140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Give each season checklist row its own document reference';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE season_assignment_task ADD document_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE season_assignment_task ADD CONSTRAINT FK_BEEB9D8DC33F7837 FOREIGN KEY (document_id) REFERENCES upload (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX IDX_BEEB9D8DC33F7837 ON season_assignment_task (document_id)');
$this->addSql('UPDATE season_assignment_task t JOIN season_task_document d ON d.task_key = t.task_key SET t.document_id = d.document_id');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE season_assignment_task DROP FOREIGN KEY FK_BEEB9D8DC33F7837');
$this->addSql('DROP INDEX IDX_BEEB9D8DC33F7837 ON season_assignment_task');
$this->addSql('ALTER TABLE season_assignment_task DROP document_id');
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Work months imported from the SaaS time tracking, and the signed Arbeitszeitnachweis that
* belongs to each of them.
*
* Hand-trimmed: doctrine:migrations:diff also produced a long tail of datetime, JSON and
* TINYINT redeclarations across almost every table. That is the long-standing drift between
* the dev database and the mapping, not anything this feature changed, so none of it is here.
*/
final class Version20260921122743 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds teamer_work_month, the SaaS employee id on teamer and the work month relation on upload';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE teamer_work_month (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, year SMALLINT NOT NULL, month SMALLINT NOT NULL, days JSON NOT NULL, total_minutes INT NOT NULL, worked_days INT NOT NULL, absence_days INT NOT NULL, source_ist_minutes INT NOT NULL, status VARCHAR(255) NOT NULL, submitted_at DATETIME DEFAULT NULL, reviewed_at DATETIME DEFAULT NULL, reviewed_by VARCHAR(8) DEFAULT NULL, review_comment LONGTEXT DEFAULT NULL, synced_at DATETIME NOT NULL, warnings JSON DEFAULT NULL, mismatch_detected_at DATETIME DEFAULT NULL, mismatch_total_minutes INT DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, teamer_id INT NOT NULL, UNIQUE INDEX UNIQ_28857572D17F50A6 (uuid), UNIQUE INDEX teamer_work_month_unique (teamer_id, year, month), INDEX IDX_288575724302FF75 (teamer_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE teamer_work_month ADD CONSTRAINT FK_288575724302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)');
$this->addSql('ALTER TABLE teamer ADD saas_employee_uuid VARCHAR(36) DEFAULT NULL');
$this->addSql('ALTER TABLE upload ADD teamer_work_month_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE upload ADD CONSTRAINT FK_17BDE61FFEAE833A FOREIGN KEY (teamer_work_month_id) REFERENCES teamer_work_month (id)');
$this->addSql('CREATE INDEX IDX_17BDE61FFEAE833A ON upload (teamer_work_month_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE upload DROP FOREIGN KEY FK_17BDE61FFEAE833A');
$this->addSql('DROP INDEX IDX_17BDE61FFEAE833A ON upload');
$this->addSql('ALTER TABLE upload DROP teamer_work_month_id');
$this->addSql('ALTER TABLE teamer DROP saas_employee_uuid');
$this->addSql('ALTER TABLE teamer_work_month DROP FOREIGN KEY FK_288575724302FF75');
$this->addSql('DROP TABLE teamer_work_month');
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The monthly target and the month's total break time, both shown in the head of the
* Arbeitszeitnachweis next to the hours actually worked.
*
* Hand-trimmed, like Version20260921122743: doctrine:migrations:diff sweeps in the
* long-standing datetime and JSON drift of the dev database, none of which belongs here.
*
* The per-day start, end and break times that go with these live in the existing "days" JSON
* column, so they need no schema change. Rows imported before this migration simply carry
* neither, and are refreshed by the next import of a month that is not yet signed.
*/
final class Version20260921160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds the monthly target and total break time to teamer_work_month';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE teamer_work_month ADD target_minutes INT DEFAULT 0 NOT NULL, ADD break_minutes INT DEFAULT 0 NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE teamer_work_month DROP target_minutes, DROP break_minutes');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The employee's department, shown in the head of the Arbeitszeitnachweis.
*
* Hand-trimmed like its two predecessors: doctrine:migrations:diff sweeps in the long-standing
* datetime and JSON drift of the dev database, none of which belongs here.
*
* Nullable, and not only because existing rows have none: a month made up entirely of holiday
* carries no booking to read a department from.
*/
final class Version20260921170000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds the department to teamer_work_month';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE teamer_work_month ADD department VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE teamer_work_month DROP department');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Form submissions posted by the sibling websites (TYPO3 ApplicationApiFinisher).
*
* Generated with doctrine:migrations:diff and reduced to these two statements: the diff also
* swept in the long-standing dev drift in the datetime and json column declarations, plus
* tables that only exist on another branch.
*/
final class Version20260923062331 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add application_form_submission for form submissions posted by the websites';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE application_form_submission (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, form VARCHAR(64) NOT NULL, form_label VARCHAR(255) NOT NULL, submitted_at DATETIME NOT NULL, page_uid INT DEFAULT NULL, page_url VARCHAR(2048) DEFAULT NULL, first_name VARCHAR(255) DEFAULT NULL, last_name VARCHAR(255) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, phone VARCHAR(64) DEFAULT NULL, mobile VARCHAR(64) DEFAULT NULL, birth_date VARCHAR(64) DEFAULT NULL, street VARCHAR(255) DEFAULT NULL, zip_code VARCHAR(32) DEFAULT NULL, city VARCHAR(255) DEFAULT NULL, details JSON NOT NULL, payload JSON NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_F47CE995D17F50A6 (uuid), INDEX IDX_F47CE995B5265E72 (form, submitted_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE application_form_submission');
}
}
Loaded 100 of 904 files, more files were not shown because too many files have changed in this diff. Show more