Compare commits
18
Commits
3d4d92b5c4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
518f3a46d0 | ||
|
|
a05143bfbe | ||
|
|
29cedd5916 | ||
|
|
5c57dc823f | ||
|
|
d4908b6ab9 | ||
|
|
39a343911a | ||
|
|
66d560f98d | ||
|
|
86817731f4 | ||
|
|
9d639c8db1 | ||
|
|
8e599a5dd7 | ||
|
|
9072e764c0 | ||
|
|
24451d0f36 | ||
|
|
d86eb1fd2e | ||
|
|
3cdaee1df3 | ||
|
|
4b20aad98e | ||
|
|
52926ec6cc | ||
|
|
16d847137d | ||
|
|
66527515ef |
@@ -15,6 +15,7 @@ web_environment: []
|
||||
nodejs_version: "18"
|
||||
corepack_enable: false
|
||||
disable_upload_dirs_warning: true
|
||||
omit_containers: [ddev-ssh-agent]
|
||||
|
||||
xhgui_https_port: "8142"
|
||||
xhgui_http_port: "8143"
|
||||
|
||||
@@ -55,7 +55,7 @@ APP_BPN_DEBUG=false
|
||||
# This hotel code will be assigned to admin users together with ROLE_HOTEL_MANAGER
|
||||
# in dev and staging environments for testing purposes
|
||||
APP_BPN_DEFAULT_HOTEL_CODE=
|
||||
APP_BPN_CRM_ID_ADMIN=1292
|
||||
APP_BPN_CRM_ID_TEAM_ADMIN=1484
|
||||
APP_BPN_CRM_ID_MANAGER=1293
|
||||
APP_BPN_CRM_ID_TEAMER=1070
|
||||
|
||||
|
||||
Generated
+350
-312
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@ texts:
|
||||
|
||||
Zusätzliche Absprachen: {specialAgreements}
|
||||
|
||||
Schau einmal in das My E&P-Team Portal, um die Einsatzdetails einzusehen und deinen Honorarvertrag zu unterschreiben.
|
||||
Schau einmal in das E&P-Team Portal, um die Einsatzdetails einzusehen und deinen Honorarvertrag zu unterschreiben.
|
||||
|
||||
Mit dem Erhalt dieser E-Mail hast du {contractUploadDeadlineDays} Tage Zeit deinen Einsatz zu bestätigen, indem du den unterschriebenen Vertrag hochlädst. Ist diese Frist vergangen, wird der Einsatz für deine Teamkolleg:innen freigeschaltet.
|
||||
|
||||
@@ -45,7 +45,7 @@ texts:
|
||||
|
||||
Damit ist für dich alles erledigt - für diesen Einsatz brauchen wir weder einen Honorarvertrag noch eine Honorarnote von dir.
|
||||
|
||||
Schau einmal in das My E&P-Team Portal, um die Einsatzdetails einzusehen.
|
||||
Schau einmal in das E&P-Team Portal, um die Einsatzdetails einzusehen.
|
||||
|
||||
Schön, dass du dabei bist und ganz viel Spaß in den Bergen! 😊
|
||||
|
||||
@@ -139,7 +139,7 @@ texts:
|
||||
|
||||
Wir hoffen, dass du ich schon freust und wünschen dir viel Spaß und Erfolg! Finale Infos erhältst du, falls noch nicht geschehen, ein paar Tage vor deinem Einsatz von deinen zuständigen Haus- oder Reisemanager:innen.
|
||||
|
||||
Bitte denk daran, deine **ausgefüllte Honorarnote bis zu {invoiceUploadDeadlineDays} Tage nach deinem Einsatz** in My E&P Team hochzuladen.
|
||||
Bitte denk daran, deine **ausgefüllte Honorarnote bis zu {invoiceUploadDeadlineDays} Tage nach deinem Einsatz** in E&P Team hochzuladen.
|
||||
|
||||
Liebe Grüße,
|
||||
dein Team Personalabteilung
|
||||
|
||||
@@ -36,6 +36,15 @@ oneup_uploader:
|
||||
namer: app.upload_namer
|
||||
storage:
|
||||
directory: '%kernel.project_dir%/uploads/driver_license'
|
||||
# Belege backing a Honorarnote. The directory has to be named exactly after
|
||||
# Upload::TYPE_RECEIPT: UploadHandler::getUploadFilepath() interpolates the type into
|
||||
# uploads/<type>/<shard>/<filename> rather than looking the mapping up.
|
||||
receipt:
|
||||
frontend: dropzone
|
||||
use_orphanage: true
|
||||
namer: app.upload_namer
|
||||
storage:
|
||||
directory: '%kernel.project_dir%/uploads/receipt'
|
||||
chunks:
|
||||
maxage: 86400
|
||||
storage:
|
||||
|
||||
@@ -9,7 +9,7 @@ security:
|
||||
property: email
|
||||
|
||||
role_hierarchy:
|
||||
ROLE_ADMIN: [ ROLE_ADMINISTRATIVE ]
|
||||
ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ]
|
||||
ROLE_MANAGER: [ ROLE_ADMINISTRATIVE ]
|
||||
|
||||
firewalls:
|
||||
|
||||
@@ -4,11 +4,12 @@ zenstruck_schedule:
|
||||
mailer:
|
||||
service: mailer
|
||||
default_to: [email protected]
|
||||
default_from: team@ep-reisen.de
|
||||
subject_prefix: "[MyE&P-Team]"
|
||||
default_from: noreply@ep-reisen.de
|
||||
subject_prefix: "[E&P-Team]"
|
||||
|
||||
schedule_extensions:
|
||||
email_on_failure:
|
||||
enabled: true
|
||||
to: [email protected]
|
||||
|
||||
tasks:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||
parameters:
|
||||
bpn_crm_id_admin: '%env(int:APP_BPN_CRM_ID_ADMIN)%'
|
||||
bpn_crm_id_team_admin: '%env(int:APP_BPN_CRM_ID_TEAM_ADMIN)%'
|
||||
bpn_crm_id_manager: '%env(int:APP_BPN_CRM_ID_MANAGER)%'
|
||||
bpn_crm_id_teamer: '%env(int:APP_BPN_CRM_ID_TEAMER)%'
|
||||
bpn_default_hotel_code: '%env(default::APP_BPN_DEFAULT_HOTEL_CODE)%'
|
||||
@@ -162,7 +162,7 @@ services:
|
||||
App\BusProNet\ResponseParser:
|
||||
arguments:
|
||||
$options:
|
||||
bpn_crm_id_admin: '%bpn_crm_id_admin%'
|
||||
bpn_crm_id_team_admin: '%bpn_crm_id_team_admin%'
|
||||
bpn_crm_id_manager: '%bpn_crm_id_manager%'
|
||||
bpn_crm_id_teamer: '%bpn_crm_id_teamer%'
|
||||
bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%'
|
||||
|
||||
+20
-36
@@ -22,7 +22,7 @@ set('keep_releases', 3);
|
||||
// Rsync options, mainly files/dirs to exclude
|
||||
$rsyncOptions = [
|
||||
'exclude' => [
|
||||
'.cursor',
|
||||
'.claude',
|
||||
'.idea',
|
||||
'.vscode',
|
||||
'.DS_Store',
|
||||
@@ -70,44 +70,33 @@ $rsyncOptions = [
|
||||
];
|
||||
|
||||
host('prod')
|
||||
->setHostname('185.243.135.29')
|
||||
->setRemoteUser('p704161')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/home/www/p704161/html/myep-team')
|
||||
->set('writable_mode', 'chmod')
|
||||
->set('http_user', 'p704161')
|
||||
->set('bin/php', '/usr/local/bin/php')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://myep-team.ep-reisen.de')
|
||||
;
|
||||
|
||||
host('staging')
|
||||
->setHostname('185.243.135.29')
|
||||
->setRemoteUser('p704161')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/home/www/p704161/html/myep-team-staging')
|
||||
->set('writable_mode', 'chmod')
|
||||
->set('http_user', 'p704161')
|
||||
->set('bin/php', '/usr/local/bin/php')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://myep-team.ep-reisen.net')
|
||||
;
|
||||
|
||||
host('hetzner')
|
||||
->setHostname('dedi10193.your-server.de')
|
||||
->setRemoteUser('myepteam')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/usr/home/myepteam/public_html/myep-team')
|
||||
->setDeployPath('/usr/www/users/myepteam/prod')
|
||||
->set('bin/php', '/usr/bin/php')
|
||||
->set('http_user', 'myepteam')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://myep-team.ep-reisen.net')
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://team.ep-reisen.de')
|
||||
;
|
||||
|
||||
host('staging')
|
||||
->setHostname('dedi10193.your-server.de')
|
||||
->setRemoteUser('myepteam')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/usr/www/users/myepteam/staging')
|
||||
->set('bin/php', '/usr/bin/php')
|
||||
->set('http_user', 'myepteam')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://team.ep-reisen.net --web-basic-auth=team:staging')
|
||||
->add('shared_files', [
|
||||
'public/.htaccess',
|
||||
'public/.htpasswd',
|
||||
])
|
||||
;
|
||||
|
||||
task('deploy', [
|
||||
@@ -116,7 +105,6 @@ task('deploy', [
|
||||
'deploy:lock',
|
||||
'deploy:release',
|
||||
'deploy:assets',
|
||||
'deploy:check-platform',
|
||||
'rsync',
|
||||
'deploy:shared',
|
||||
'deploy:writable',
|
||||
@@ -127,10 +115,6 @@ task('deploy', [
|
||||
'deploy:stop-workers',
|
||||
]);
|
||||
|
||||
task('deploy:check-platform', function () {
|
||||
runLocally('composer check-platform-reqs');
|
||||
});
|
||||
|
||||
task('deploy:stop-workers', function () {
|
||||
run('{{bin/console}} messenger:stop-workers');
|
||||
});
|
||||
|
||||
+44
-12
@@ -30,17 +30,35 @@ assign:
|
||||
|
||||
| Role | Label | Granted by | Revoked by | Hierarchy |
|
||||
|------|-------|-----------|------------|-----------|
|
||||
| `ROLE_ADMIN` | Admin | super admin, approving a CRM claim | the CRM, automatically | ⇒ `ROLE_ADMINISTRATIVE` |
|
||||
| `ROLE_TEAM_ADMIN` | Admin | super admin, approving a CRM claim | the CRM, automatically | ⇒ `ROLE_ADMINISTRATIVE` |
|
||||
| `ROLE_MANAGER` | Reisemanager | super admin, approving a CRM claim | the CRM, automatically | ⇒ `ROLE_ADMINISTRATIVE` |
|
||||
| `ROLE_HOUSE_MANAGER` | Hausleitung | super admin, approving a CRM claim | the CRM, automatically | — |
|
||||
| `ROLE_TEAMER` | Teamer | the CRM, automatically | the CRM, automatically | — |
|
||||
|
||||
### A note on the `TEAM_` prefix
|
||||
|
||||
`ROLE_TEAM_ADMIN`, `ROLE_TEAM_ADMIN_PENDING` and `ROLE_TEAM_SUPER_ADMIN` were renamed from
|
||||
`ROLE_ADMIN`, `ROLE_ADMIN_PENDING` and `ROLE_SUPER_ADMIN`. The MyE&P identity provider is
|
||||
shared with a sibling portal that uses `ROLE_ADMIN` for a different privilege, so the plain
|
||||
name was ambiguous across the estate; the prefix makes it unambiguously *this* application's
|
||||
admin. The German label is unchanged — it still reads "Admin" everywhere in the UI.
|
||||
|
||||
Two consequences worth remembering:
|
||||
|
||||
- **`ROLE_ADMINISTRATIVE` is a different role and was not renamed.** It is granted only by
|
||||
the hierarchy, never stored, and it shares the old `ROLE_ADMIN` prefix — so any
|
||||
search-and-replace over role names must match on a word boundary
|
||||
(`ROLE_TEAM_ADMIN(?![A-Z_])`) or it will corrupt ~100 call sites silently.
|
||||
- **`ELIGIBLE_ROLES` in `MyEpAuthenticator` is a wire contract**, not an internal name: it is
|
||||
compared directly against the IdP's `roles` claim. It only works while MyE&P emits
|
||||
`ROLE_TEAM_ADMIN`, so the two sides have to move together.
|
||||
|
||||
`User::PENDING_ROLES` holds a marker for each of the three administrative roles, keyed by
|
||||
the role it stands for:
|
||||
|
||||
| Marker | Meaning |
|
||||
|--------|---------|
|
||||
| `ROLE_ADMIN_PENDING` | the CRM claims this person is an admin, nobody has confirmed it |
|
||||
| `ROLE_TEAM_ADMIN_PENDING` | the CRM claims this person is an admin, nobody has confirmed it |
|
||||
| `ROLE_MANAGER_PENDING` | likewise for Reisemanager |
|
||||
| `ROLE_HOUSE_MANAGER_PENDING` | likewise for Hausleitung |
|
||||
|
||||
@@ -50,10 +68,10 @@ effects are cosmetic (rendered as "Admin (nicht freigeschaltet)") and organisati
|
||||
put the user on the approval list). `ROLE_TEAMER` has no marker: it needs no approval.
|
||||
|
||||
Two further roles are synthesized by `User::getRoles()` and never stored: `ROLE_USER` for
|
||||
everybody, and `ROLE_SUPER_ADMIN` when the separate `superAdmin` boolean column is set. A
|
||||
everybody, and `ROLE_TEAM_SUPER_ADMIN` when the separate `superAdmin` boolean column is set. A
|
||||
validation callback (`User::validateSuperAdmin()`) refuses `superAdmin` without
|
||||
`ROLE_ADMIN` alongside it — super admin is an elevation, never a standalone grant. The sync
|
||||
enforces the same rule from the other side: revoking `ROLE_ADMIN` clears the flag, or the one
|
||||
`ROLE_TEAM_ADMIN` alongside it — super admin is an elevation, never a standalone grant. The sync
|
||||
enforces the same rule from the other side: revoking `ROLE_TEAM_ADMIN` clears the flag, or the one
|
||||
role that outranks every check in the application would outlive the role it depends on.
|
||||
|
||||
### Storage and accessors
|
||||
@@ -63,7 +81,7 @@ slice it, and picking the right one matters:
|
||||
|
||||
| Accessor | Returns |
|
||||
|----------|---------|
|
||||
| `getRoles()` | the column **plus** synthesized `ROLE_USER` / `ROLE_SUPER_ADMIN` — what Symfony authorises against |
|
||||
| `getRoles()` | the column **plus** synthesized `ROLE_USER` / `ROLE_TEAM_SUPER_ADMIN` — what Symfony authorises against |
|
||||
| `getAssignedRoles()` | only the four real roles from the column — what the sync works on |
|
||||
| `getPendingRoles()` | only the markers |
|
||||
| `getNominatedRoles()` | the roles behind those markers, as `role => label` — what an approver acts on |
|
||||
@@ -77,7 +95,7 @@ slice it, and picking the right one matters:
|
||||
|
||||
| CRM attribute | Recognised by | Sets |
|
||||
|---------------|---------------|------|
|
||||
| admin | attribute id `%bpn_crm_id_admin%`, selected | `isAdmin` |
|
||||
| admin | attribute id `%bpn_crm_id_team_admin%`, selected | `isAdmin` |
|
||||
| Reisemanager | attribute id `%bpn_crm_id_manager%`, selected | `isManager` |
|
||||
| teamer | attribute id `%bpn_crm_id_teamer%`, selected | `isTeamer` |
|
||||
| Hausleitung | attribute id listed in `%bpn_crm_house_manager_ids%`, selected | `isHouseManager` + the hotel code that id maps to |
|
||||
@@ -111,12 +129,26 @@ roles were revoked.
|
||||
>
|
||||
> | Parameter | Attribute |
|
||||
> |-----------|-----------|
|
||||
> | `APP_BPN_CRM_ID_ADMIN` | `Admin` |
|
||||
> | `APP_BPN_CRM_ID_TEAM_ADMIN` | the team-admin selection — **not** the old portal-wide `Admin` (1292) |
|
||||
> | `APP_BPN_CRM_ID_MANAGER` | `Manager` |
|
||||
> | `APP_BPN_CRM_ID_TEAMER` | `E&P Teamer - allg. Merkmal` |
|
||||
>
|
||||
> Matching is by id and never by label, so `Preisrechner Admin` does not trip the admin flag.
|
||||
>
|
||||
> `APP_BPN_CRM_ID_TEAM_ADMIN` was renamed from `APP_BPN_CRM_ID_ADMIN` with the
|
||||
> `ROLE_TEAM_ADMIN` rename, and its **value has to change too**. The old value 1292 is the
|
||||
> portal-wide admin selection, which still means `ROLE_ADMIN` in the sibling portal — keeping
|
||||
> it would have left this app granting its admin off the very selection the rename was meant
|
||||
> to stop sharing. It ships as `0` — a valid int that matches no attribute, so the container
|
||||
> boots but nobody is granted the role — and must be set to the new selection's id.
|
||||
>
|
||||
> **This is a hard cutover.** `revokeUnclaimedRoles()` withdraws any granted role the CRM no
|
||||
> longer claims, and `revokeSuperAdminWithoutRoleAdmin()` takes the super admin flag down with
|
||||
> `ROLE_TEAM_ADMIN`. So the new selection must exist **and already be assigned to every admin**
|
||||
> in BusPro before this is deployed; otherwise each of them is demoted on their next login and
|
||||
> needs a super admin to re-approve. Admins who also hold `ROLE_TEAMER` degrade to teamer
|
||||
> access; an admin without it is blocked outright by `disableForRevokedCrmRoles()`.
|
||||
>
|
||||
> `bpn_crm_house_manager_ids` (`config/services.yaml`) is deployment-critical for the same
|
||||
> reason, and more sharply so: since roles are synced, an id missing from that map does not
|
||||
> merely fail to nominate a Hausleitung, it **revokes** the role from everyone holding it, one
|
||||
@@ -137,7 +169,7 @@ roles were revoked.
|
||||
`BpnAuthenticator::getOrCreateLocalUser()` → `UserDataHandler::createLocalUser()` writes
|
||||
`collectRoles()` verbatim, together with the hotel codes from the Hausleitung attributes.
|
||||
|
||||
A CRM admin who is not also a teamer therefore starts with `['ROLE_ADMIN_PENDING']` and no
|
||||
A CRM admin who is not also a teamer therefore starts with `['ROLE_TEAM_ADMIN_PENDING']` and no
|
||||
privileges at all: they can authenticate, but `UserChecker` refuses the session until a
|
||||
super admin approves them.
|
||||
|
||||
@@ -148,7 +180,7 @@ the roles to **`syncRoles()`**, which is the whole policy in four steps:
|
||||
|
||||
1. **revoke** every granted role the CRM no longer claims. This is what makes BusPro the
|
||||
source of truth, and it applies to `ROLE_TEAMER` as much as to the administrative roles.
|
||||
2. **clear the super admin flag** when `ROLE_ADMIN` was among them — `ROLE_SUPER_ADMIN` is
|
||||
2. **clear the super admin flag** when `ROLE_TEAM_ADMIN` was among them — `ROLE_TEAM_SUPER_ADMIN` is
|
||||
synthesized from a separate column and would otherwise survive its own precondition.
|
||||
3. **`refreshPendingRoles()`** recomputes the marker set from the current claims. A marker
|
||||
whose real role is already granted is dropped — an approved role is never marked again.
|
||||
@@ -189,7 +221,7 @@ runs again on submit to catch a sync that revoked the claim while the dialog was
|
||||
A denial is not recorded anywhere: as long as the CRM keeps claiming the role, the
|
||||
nomination is back on the next login.
|
||||
|
||||
**Super admin** is only offered to somebody who already holds `ROLE_ADMIN` — approve first,
|
||||
**Super admin** is only offered to somebody who already holds `ROLE_TEAM_ADMIN` — approve first,
|
||||
elevate afterwards. The one exception is a flag that outlived its role, which stays editable
|
||||
so the account can be saved at all while `User::validateSuperAdmin()` is violated; the sync
|
||||
clears it (see 2), so it should never occur in practice.
|
||||
@@ -243,7 +275,7 @@ administrative users, teamers are an admin's business:
|
||||
|
||||
| Surface | Who | Notes |
|
||||
|---------|-----|-------|
|
||||
| `/admin/teamer/disable-user/{uuid}` and `/administrative/teamer/enable-user/{uuid}` | `ROLE_ADMIN` | teamers; public reason mandatory, internal optional |
|
||||
| `/admin/teamer/disable-user/{uuid}` and `/administrative/teamer/enable-user/{uuid}` | `ROLE_TEAM_ADMIN` | teamers; public reason mandatory, internal optional |
|
||||
| "Account gesperrt" checkbox on the user edit form | super admin (`UserVoter`) | everyone else; both reasons optional |
|
||||
|
||||
Both go through `User::setDisabled()`, which is a no-op when the state is unchanged — saving
|
||||
|
||||
@@ -162,17 +162,36 @@ class ApiClient
|
||||
$this->config['max_retries']
|
||||
);
|
||||
$this->send($socket, $body);
|
||||
$response = $this->receive($socket);
|
||||
$this->disconnect($socket);
|
||||
|
||||
// message length (10 bytes) is prepended to actual message
|
||||
$xml = substr($response, 10);
|
||||
try {
|
||||
// the length header is consumed by receive(), this is the payload
|
||||
$xml = $this->receive($socket);
|
||||
} finally {
|
||||
$this->disconnect($socket);
|
||||
}
|
||||
|
||||
if (true === $this->config['debug']) {
|
||||
$this->dumpXmlToFile('response', $requestId, $xml);
|
||||
}
|
||||
|
||||
return $this->responseParser->parseXmlString($type, $xml);
|
||||
try {
|
||||
return $this->responseParser->parseXmlString($type, $xml);
|
||||
} catch (ResponseParserException $e) {
|
||||
// Keep the evidence even outside debug mode: without the raw response a parse
|
||||
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
|
||||
if (true !== $this->config['debug']) {
|
||||
$this->dumpXmlToFile('response', $requestId, $xml);
|
||||
}
|
||||
|
||||
$this->logger->error('Unable to parse BusProNet response', [
|
||||
'request_id' => $requestId,
|
||||
'type' => $type,
|
||||
'response_length' => strlen($xml),
|
||||
'error_message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function dumpXmlToFile(string $type, string $requestId, string $body): void
|
||||
|
||||
@@ -58,15 +58,58 @@ trait ApiClientTrait
|
||||
fwrite($socket, $send);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a single response message. The protocol prepends the payload length as a
|
||||
* 10 byte header, so read exactly that many bytes rather than guessing at EOF:
|
||||
* a peer that closes mid-stream would otherwise yield a silently truncated body.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
private function receive($socket): string
|
||||
{
|
||||
$response = '';
|
||||
$header = $this->readBytes($socket, 10);
|
||||
|
||||
while (false === feof($socket)) {
|
||||
$response .= fread($socket, 4096);
|
||||
if (10 !== strlen($header)) {
|
||||
throw new ApiClientException(sprintf('Incomplete response header, got %d of 10 bytes', strlen($header)));
|
||||
}
|
||||
|
||||
return $response;
|
||||
$expectedLength = (int) trim($header);
|
||||
|
||||
if (1 > $expectedLength) {
|
||||
throw new ApiClientException(sprintf('Response announced an empty body (header "%s")', trim($header)));
|
||||
}
|
||||
|
||||
$body = $this->readBytes($socket, $expectedLength);
|
||||
|
||||
if (strlen($body) !== $expectedLength) {
|
||||
throw new ApiClientException(sprintf('Truncated response, got %d of %d announced bytes', strlen($body), $expectedLength));
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
private function readBytes($socket, int $length): string
|
||||
{
|
||||
$buffer = '';
|
||||
|
||||
while (strlen($buffer) < $length && false === feof($socket)) {
|
||||
$chunk = fread($socket, min(4096, $length - strlen($buffer)));
|
||||
|
||||
if (false === $chunk || '' === $chunk) {
|
||||
if (true === (stream_get_meta_data($socket)['timed_out'] ?? false)) {
|
||||
throw new ApiClientException(sprintf('Timed out reading response after %d of %d bytes', strlen($buffer), $length));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$buffer .= $chunk;
|
||||
}
|
||||
|
||||
return $buffer;
|
||||
}
|
||||
|
||||
private function disconnect($socket): void
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\ApiClientException;
|
||||
use App\BusProNet\Model\Country;
|
||||
use App\BusProNet\Model\NotificationResponse;
|
||||
use App\BusProNet\ResponseParserException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
@@ -35,7 +36,8 @@ class CountryDataProvider
|
||||
|
||||
return $response->getItems();
|
||||
});
|
||||
} catch (ApiClientException $e) {
|
||||
} catch (ApiClientException|ResponseParserException $e) {
|
||||
$this->logger->error('Unable to fetch country base data from BusProNet: '.$e->getMessage());
|
||||
$countries = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\ApiClientException;
|
||||
use App\BusProNet\Model\Hotel;
|
||||
use App\BusProNet\Model\NotificationResponse;
|
||||
use App\BusProNet\ResponseParserException;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
@@ -36,7 +37,8 @@ class HotelDataProvider
|
||||
|
||||
return $response->getItems();
|
||||
});
|
||||
} catch (ApiClientException|InvalidArgumentException $e) {
|
||||
} catch (ApiClientException|InvalidArgumentException|ResponseParserException $e) {
|
||||
$this->logger->error('Unable to fetch hotel base data from BusProNet: '.$e->getMessage());
|
||||
$hotels = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\ApiClientException;
|
||||
use App\BusProNet\Model\NotificationResponse;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\ResponseParserException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
@@ -35,7 +36,8 @@ class PickupDataProvider
|
||||
|
||||
return $response->getItems();
|
||||
});
|
||||
} catch (ApiClientException $e) {
|
||||
} catch (ApiClientException|ResponseParserException $e) {
|
||||
$this->logger->error('Unable to fetch pickup base data from BusProNet: '.$e->getMessage());
|
||||
$pickups = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,7 @@ class ResponseParser
|
||||
*/
|
||||
public function parseXmlString(string $type, string $content): mixed
|
||||
{
|
||||
$xml = simplexml_load_string($content);
|
||||
if (false === $xml) {
|
||||
throw new ResponseParserException('Unable to parse XML response');
|
||||
}
|
||||
$xml = $this->loadXml($content);
|
||||
|
||||
// Override type when present in XML to catch error responses
|
||||
$responseType = $type;
|
||||
@@ -65,7 +62,7 @@ class ResponseParser
|
||||
return $this->createHotelsResponse($xml);
|
||||
}
|
||||
|
||||
throw new ResponseParserException('Unable to parse XML response');
|
||||
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType));
|
||||
}
|
||||
|
||||
public function createNotificationResponse(\SimpleXMLElement $xml): NotificationResponse
|
||||
@@ -200,7 +197,7 @@ class ResponseParser
|
||||
$isHouseManager = true;
|
||||
$hotelCodes[] = $houseManagerCode;
|
||||
}
|
||||
if ($this->config['bpn_crm_id_admin'] === $attribute->getId() && true === $attribute->isSelected()) {
|
||||
if ($this->config['bpn_crm_id_team_admin'] === $attribute->getId() && true === $attribute->isSelected()) {
|
||||
$isAdmin = true;
|
||||
}
|
||||
if ($this->config['bpn_crm_id_manager'] === $attribute->getId() && true === $attribute->isSelected()) {
|
||||
@@ -302,14 +299,58 @@ class ResponseParser
|
||||
return new BaseDataResponse($hotels);
|
||||
}
|
||||
|
||||
/**
|
||||
* The BusProNet endpoint can answer with an empty or truncated body. Keep the libxml
|
||||
* reason instead of collapsing every shape of broken response into one message.
|
||||
*
|
||||
* @throws ResponseParserException
|
||||
*/
|
||||
private function loadXml(string $content): \SimpleXMLElement
|
||||
{
|
||||
if ('' === trim($content)) {
|
||||
throw new ResponseParserException(sprintf('Unable to parse XML response (%d bytes): empty response', strlen($content)));
|
||||
}
|
||||
|
||||
$previousUseErrors = libxml_use_internal_errors(true);
|
||||
libxml_clear_errors();
|
||||
|
||||
try {
|
||||
$xml = simplexml_load_string($content);
|
||||
|
||||
if (false === $xml) {
|
||||
throw new ResponseParserException(sprintf('Unable to parse XML response (%d bytes): %s', strlen($content), $this->describeLibxmlErrors()));
|
||||
}
|
||||
|
||||
return $xml;
|
||||
} finally {
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previousUseErrors);
|
||||
}
|
||||
}
|
||||
|
||||
private function describeLibxmlErrors(): string
|
||||
{
|
||||
$messages = [];
|
||||
|
||||
foreach (libxml_get_errors() as $error) {
|
||||
$messages[] = sprintf('%s (line %d, column %d)', trim($error->message), $error->line, $error->column);
|
||||
}
|
||||
|
||||
if ([] === $messages) {
|
||||
return 'unknown XML error';
|
||||
}
|
||||
|
||||
return implode('; ', array_unique($messages));
|
||||
}
|
||||
|
||||
private function resolveOptions(array $options): array
|
||||
{
|
||||
$optionsResolver = new OptionsResolver();
|
||||
$optionsResolver->setRequired(['bpn_crm_id_admin', 'bpn_crm_id_manager', 'bpn_crm_id_teamer', 'bpn_crm_house_manager_ids']);
|
||||
$optionsResolver->setRequired(['bpn_crm_id_team_admin', 'bpn_crm_id_manager', 'bpn_crm_id_teamer', 'bpn_crm_house_manager_ids']);
|
||||
$optionsResolver->setDefaults([
|
||||
'bpn_default_hotel_code' => null,
|
||||
]);
|
||||
$optionsResolver->setAllowedTypes('bpn_crm_id_admin', 'int');
|
||||
$optionsResolver->setAllowedTypes('bpn_crm_id_team_admin', 'int');
|
||||
$optionsResolver->setAllowedTypes('bpn_crm_id_manager', 'int');
|
||||
$optionsResolver->setAllowedTypes('bpn_crm_id_teamer', 'int');
|
||||
$optionsResolver->setAllowedTypes('bpn_crm_house_manager_ids', 'array');
|
||||
|
||||
@@ -69,7 +69,7 @@ class UserDataHandler
|
||||
$claimedRoles = [];
|
||||
|
||||
if ($crmAttributes->isAdmin()) {
|
||||
$claimedRoles[] = 'ROLE_ADMIN';
|
||||
$claimedRoles[] = 'ROLE_TEAM_ADMIN';
|
||||
}
|
||||
|
||||
if ($crmAttributes->isManager()) {
|
||||
@@ -370,7 +370,7 @@ class UserDataHandler
|
||||
* The whole policy, in the order it has to run:
|
||||
*
|
||||
* 1. revoke what is no longer claimed - the identity source leads;
|
||||
* 2. drop the super admin flag along with ROLE_ADMIN, or the highest privilege in the
|
||||
* 2. drop the super admin flag along with ROLE_TEAM_ADMIN, or the highest privilege in the
|
||||
* application would outlive the role it depends on;
|
||||
* 3. refresh the pending markers, after the revocation so that a role just revoked is
|
||||
* not immediately marked again - it is unclaimed in both steps;
|
||||
@@ -424,7 +424,7 @@ class UserDataHandler
|
||||
*
|
||||
* Only the roles of User::ROLES are touched: getAssignedRoles() excludes the pending
|
||||
* markers as well as the implicit ROLE_USER, and the markers are dealt with by
|
||||
* refreshPendingRoles(). ROLE_SUPER_ADMIN is not a stored role at all but a flag, so
|
||||
* refreshPendingRoles(). ROLE_TEAM_SUPER_ADMIN is not a stored role at all but a flag, so
|
||||
* it is handled separately below.
|
||||
*
|
||||
* @param string[] $claimedRoles
|
||||
@@ -450,22 +450,22 @@ class UserDataHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the super admin flag down with ROLE_ADMIN.
|
||||
* Takes the super admin flag down with ROLE_TEAM_ADMIN.
|
||||
*
|
||||
* The flag is stored on its own and getRoles() turns it into ROLE_SUPER_ADMIN whatever
|
||||
* The flag is stored on its own and getRoles() turns it into ROLE_TEAM_SUPER_ADMIN whatever
|
||||
* else the user holds, so without this a person the CRM no longer calls an admin would
|
||||
* keep the one role that outranks every check in the application. User::validateSuperAdmin()
|
||||
* enforces the same rule on the edit form, but only there.
|
||||
*/
|
||||
private function revokeSuperAdminWithoutRoleAdmin(User $user): void
|
||||
{
|
||||
if (false === $user->isSuperAdmin() || true === $user->hasRole('ROLE_ADMIN')) {
|
||||
if (false === $user->isSuperAdmin() || true === $user->hasRole('ROLE_TEAM_ADMIN')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user->setSuperAdmin(false);
|
||||
|
||||
$this->logger->info('Revoke super admin flag along with ROLE_ADMIN', [
|
||||
$this->logger->info('Revoke super admin flag along with ROLE_TEAM_ADMIN', [
|
||||
'user_id' => $user->getId(),
|
||||
'user_email' => $user->getEmail(),
|
||||
]);
|
||||
|
||||
@@ -56,11 +56,30 @@ class BpnImportCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Resolve the BusProNet base data once, before touching a single row. A destination
|
||||
// cannot be written without its hotel, so an unavailable hotel list would otherwise
|
||||
// skip every record and still report success. Holding both lists locally also keeps
|
||||
// the loop off the providers: on a failed fetch nothing is cached, and a per-lookup
|
||||
// ->get() would re-open the socket for every pickup of all 174 files.
|
||||
$hotels = $this->hotelDataProvider->getAll();
|
||||
$pickups = $this->pickupDataProvider->getAll();
|
||||
|
||||
if ([] === $hotels) {
|
||||
$io->error('Hotel-Stammdaten konnten nicht von BusProNet geladen werden – Import abgebrochen.');
|
||||
$this->logger->error('BPN import aborted: hotel base data unavailable');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->info('Found '.$totalCount.' XML files');
|
||||
$addedCount = 0;
|
||||
$updatedCount = 0;
|
||||
$warnings = [];
|
||||
|
||||
if ([] === $pickups) {
|
||||
$warnings[] = 'Zustiegs-Stammdaten konnten nicht von BusProNet geladen werden';
|
||||
}
|
||||
|
||||
$progressBar = $io->createProgressBar($totalCount);
|
||||
$progressBar->setFormat(" %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%\n %message%");
|
||||
$progressBar->setMessage('Starting');
|
||||
@@ -92,7 +111,7 @@ class BpnImportCommand extends Command
|
||||
foreach ($destinationXml->xpath('zustiege/zustieg') as $pickupXml) {
|
||||
$pickupBusProId = (int) $pickupXml->attributes()['idbuspro'];
|
||||
if ($pickupBusProId) {
|
||||
if (null === $pickup = $this->pickupDataProvider->get($pickupBusProId)) {
|
||||
if (null === $pickup = $pickups[$pickupBusProId] ?? null) {
|
||||
$warnings[] = 'Pickup with busProId '.$pickupBusProId.' not found';
|
||||
continue;
|
||||
}
|
||||
@@ -109,7 +128,7 @@ class BpnImportCommand extends Command
|
||||
// Iterate over all hotel entries
|
||||
foreach ($destinationXml->xpath('hotel') as $hotelXml) {
|
||||
$hotelBusProId = (int) $hotelXml->attributes()['idbuspro'];
|
||||
if (null === $hotel = $this->hotelDataProvider->get($hotelBusProId)) {
|
||||
if (null === $hotel = $hotels[$hotelBusProId] ?? null) {
|
||||
$warnings[] = 'Hotel with busProId '.$hotelBusProId.' not found';
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -91,10 +91,8 @@ class TeamerStatusCommand extends Command
|
||||
$qb->expr()->eq('disposition.status', ':disposition_status')
|
||||
))
|
||||
->groupBy('teamer.id')
|
||||
->setParameters([
|
||||
'teamer_status' => Teamer::STATUS_NEW,
|
||||
'disposition_status' => Disposition::STATUS_COMPLETED,
|
||||
])
|
||||
->setParameter('teamer_status', Teamer::STATUS_NEW)
|
||||
->setParameter('disposition_status', Disposition::STATUS_COMPLETED)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
@@ -16,7 +16,7 @@ class UserController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/autocomplete/user', name: 'app_admin_autocomplete_user')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -22,7 +22,7 @@ class ApproveController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/feedback/approve/{uuid}', name: 'app_admin_feedback_approve')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Feedback $feedback, Request $request): Response
|
||||
{
|
||||
$form = $this->createForm(FeedbackApproveType::class, $feedback);
|
||||
|
||||
@@ -24,7 +24,7 @@ class ProvideController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/feedback/provide', name: 'app_admin_feedback_provide')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$form = $this->getFeedbackForm();
|
||||
@@ -58,7 +58,7 @@ class ProvideController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/feedback/provide/form', name: 'app_admin_feedback_provide_form')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function form(Request $request): Response
|
||||
{
|
||||
$form = $this->getFeedbackForm();
|
||||
|
||||
@@ -21,7 +21,7 @@ class IndexController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin', name: 'app_admin_index')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(): Response
|
||||
{
|
||||
$applicationRepository = $this->entityManager->getRepository(Application::class);
|
||||
|
||||
@@ -20,7 +20,7 @@ class IndexController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/log', name: 'app_admin_log_index')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$qb = $this
|
||||
|
||||
@@ -22,7 +22,7 @@ class CreateController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/availability/create', name: 'app_admin_system_availability_create')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$availability = new Availability();
|
||||
|
||||
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/availability/delete/{id}', name: 'app_admin_system_availability_delete', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Availability $availability, Request $request): Response
|
||||
{
|
||||
if (true === $request->isMethod('POST')) {
|
||||
|
||||
@@ -22,7 +22,7 @@ class DuplicateController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/availability/duplicate/{id}', name: 'app_admin_system_availability_duplicate')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Availability $availability, Request $request): Response
|
||||
{
|
||||
$copy = Availability::duplicate($availability);
|
||||
|
||||
@@ -22,7 +22,7 @@ class EditController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/availability/edit/{id}', name: 'app_admin_system_availability_edit')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Availability $availability, Request $request): Response
|
||||
{
|
||||
$form = $this->createForm(AvailabilityType::class, $availability);
|
||||
|
||||
@@ -19,7 +19,7 @@ class IndexController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/availability', name: 'app_admin_system_availability_index')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$query = $this
|
||||
|
||||
@@ -37,13 +37,13 @@ class CreateController extends AbstractController
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$photo = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_PHOTO);
|
||||
$photo = Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_PHOTO)->first(), $user, Upload::TYPE_PHOTO);
|
||||
$contact->setPhoto($photo);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_PHOTO, $uploadSession);
|
||||
$this->uploadHandler->destroyUploadSession();
|
||||
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_PHOTO);
|
||||
}
|
||||
|
||||
$form = $this->createForm(ContactType::class, $contact, ['upload_session' => $uploadSession]);
|
||||
|
||||
@@ -34,14 +34,14 @@ class EditController extends AbstractController
|
||||
if (true === $request->isMethod('POST') && 0 < $uploadSession->getCount()) {
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$photo = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_PHOTO);
|
||||
$photo = Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_PHOTO)->first(), $user, Upload::TYPE_PHOTO);
|
||||
if (null !== $existingPhoto = $contact->getPhoto()) {
|
||||
$this->entityManager->remove($existingPhoto);
|
||||
}
|
||||
$contact->setPhoto($photo);
|
||||
$this->entityManager->flush();
|
||||
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_PHOTO, $uploadSession);
|
||||
$this->uploadHandler->destroyUploadSession();
|
||||
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_PHOTO);
|
||||
}
|
||||
|
||||
$form = $this->createForm(ContactType::class, $contact, ['upload_session' => $uploadSession]);
|
||||
|
||||
@@ -28,7 +28,7 @@ class EditController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/email-text/edit/{key}', name: 'app_admin_system_email_text_edit')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(EmailTextKey $key, Request $request): Response
|
||||
{
|
||||
$definition = $this->catalog->get($key);
|
||||
|
||||
@@ -18,7 +18,7 @@ class IndexController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/email-text', name: 'app_admin_system_email_text_index')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(): Response
|
||||
{
|
||||
// The list is driven by the catalogue, not by the table: a mail that has never
|
||||
|
||||
@@ -25,7 +25,7 @@ class PreviewController extends AbstractController
|
||||
* a real mail is being assembled.
|
||||
*/
|
||||
#[Route('/admin/system/email-text/preview/{key}', name: 'app_admin_system_email_text_preview')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(EmailTextKey $key): Response
|
||||
{
|
||||
$definition = $this->catalog->get($key);
|
||||
|
||||
@@ -27,7 +27,7 @@ class PreviewDraftController extends AbstractController
|
||||
* the submitted values instead of the stored ones.
|
||||
*/
|
||||
#[Route('/admin/system/email-text/preview-draft/{key}', name: 'app_admin_system_email_text_preview_draft', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(EmailTextKey $key, Request $request): Response
|
||||
{
|
||||
$definition = $this->catalog->get($key);
|
||||
|
||||
@@ -25,7 +25,7 @@ class ResetController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/email-text/reset/{key}', name: 'app_admin_system_email_text_reset')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(EmailTextKey $key, Request $request): Response
|
||||
{
|
||||
$emailText = $this->emailTextRepository->findByKey($key);
|
||||
|
||||
@@ -22,7 +22,7 @@ class CreateController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/fee/create', name: 'app_admin_system_fee_create')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$fee = new Fee();
|
||||
|
||||
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/fee/delete/{id}', name: 'app_admin_system_fee_delete')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Fee $fee, Request $request): Response
|
||||
{
|
||||
if (true === $request->isMethod('POST')) {
|
||||
|
||||
@@ -22,7 +22,7 @@ class DuplicateController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/fee/duplicate/{id}', name: 'app_admin_system_fee_duplicate')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Fee $fee, Request $request): Response
|
||||
{
|
||||
$copy = Fee::duplicate($fee);
|
||||
|
||||
@@ -22,7 +22,7 @@ class EditController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/fee/edit/{id}', name: 'app_admin_system_fee_edit')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Fee $fee, Request $request): Response
|
||||
{
|
||||
$form = $this->createForm(FeeType::class, $fee);
|
||||
|
||||
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/fee', name: 'app_admin_system_fee_index')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(): Response
|
||||
{
|
||||
$fees = $this->feeRepository->getList();
|
||||
|
||||
@@ -21,7 +21,7 @@ class CreateController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/feedback-set/create', name: 'app_admin_system_feedback_set_create')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$feedbackSet = new FeedbackSet();
|
||||
|
||||
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/feedback-set/delete/{id}', name: 'app_admin_system_feedback_set_delete')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
#[IsGranted('DELETE', subject: 'feedbackSet')]
|
||||
public function index(FeedbackSet $feedbackSet, Request $request): Response
|
||||
{
|
||||
|
||||
@@ -21,7 +21,7 @@ class EditController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/feedback-set/edit/{id}', name: 'app_admin_system_feedback_set_edit')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(FeedbackSet $feedbackSet, Request $request): Response
|
||||
{
|
||||
$form = $this->createForm(FeedbackSetType::class, $feedbackSet);
|
||||
|
||||
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/feedback-set', name: 'app_admin_system_feedback_set_index')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(): Response
|
||||
{
|
||||
$feedbackSets = $this
|
||||
|
||||
@@ -21,7 +21,7 @@ class CreateController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/job-profile/create', name: 'app_admin_system_job_profile_create')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$jobProfile = new JobProfile();
|
||||
|
||||
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/job-profile/delete/{id}', name: 'app_admin_system_job_profile_delete')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(JobProfile $jobProfile, Request $request): Response
|
||||
{
|
||||
if (true === $request->isMethod('POST')) {
|
||||
|
||||
@@ -21,7 +21,7 @@ class EditController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/job-profile/edit/{id}', name: 'app_admin_system_job_profile_edit')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(JobProfile $jobProfile, Request $request): Response
|
||||
{
|
||||
$form = $this->createForm(JobProfileType::class, $jobProfile);
|
||||
|
||||
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/job-profile', name: 'app_admin_system_job_profile_index')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(): Response
|
||||
{
|
||||
$jobProfiles = $this
|
||||
|
||||
@@ -22,7 +22,7 @@ class CreateController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/training/create', name: 'app_admin_system_training_create')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$training = new Training();
|
||||
|
||||
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/training/delete/{id}', name: 'app_admin_system_training_delete')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Training $training, Request $request): Response
|
||||
{
|
||||
if (true === $request->isMethod('POST')) {
|
||||
|
||||
@@ -22,7 +22,7 @@ class EditController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/training/edit/{id}', name: 'app_admin_system_training_edit')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Training $training, Request $request): Response
|
||||
{
|
||||
$form = $this->createForm(TrainingType::class, $training);
|
||||
|
||||
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/training', name: 'app_admin_system_training_index')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(): Response
|
||||
{
|
||||
$trainings = $this
|
||||
|
||||
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/system/user', name: 'app_admin_system_user_index')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(): Response
|
||||
{
|
||||
$users = $this->userRepository->getAdministrativeUsers();
|
||||
|
||||
@@ -11,7 +11,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
class CrmSelectionsController extends AbstractController
|
||||
{
|
||||
#[Route('/admin/teamer/crm-selections/{uuid}', name: 'app_admin_teamer_crm_selections')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Teamer $teamer): Response
|
||||
{
|
||||
return $this->render('admin/teamer/crm_selections.html.twig', [
|
||||
|
||||
@@ -23,7 +23,7 @@ class DeleteAccountController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/teamer/delete-account/{uuid}', name: 'app_admin_teamer_delete_account')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Teamer $teamer, Request $request): Response
|
||||
{
|
||||
if (true === $request->isMethod(Request::METHOD_POST)) {
|
||||
@@ -44,7 +44,7 @@ class DeleteAccountController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/teamer/restore-account/{uuid}', name: 'app_admin_teamer_restore_account')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function restore(Teamer $teamer, Request $request): Response
|
||||
{
|
||||
if (true === $request->isMethod(Request::METHOD_POST)) {
|
||||
|
||||
@@ -22,7 +22,7 @@ class DisableUserController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/teamer/disable-user/{uuid}', name: 'app_admin_teamer_disable_user')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Teamer $teamer, Request $request): Response
|
||||
{
|
||||
$user = $teamer->getUser();
|
||||
@@ -50,7 +50,7 @@ class DisableUserController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/administrative/teamer/enable-user/{uuid}', name: 'app_admin_teamer_enable_user')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function enable(Teamer $teamer, Request $request): Response
|
||||
{
|
||||
$user = $teamer->getUser();
|
||||
|
||||
@@ -39,7 +39,7 @@ class MailingController extends AbstractController
|
||||
* the real send goes through the confirmation modal below.
|
||||
*/
|
||||
#[Route('/admin/teamer/mailing', name: 'app_admin_teamer_mailing')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$form = $this->createMailingForm($request);
|
||||
@@ -82,7 +82,7 @@ class MailingController extends AbstractController
|
||||
* one that was parked.
|
||||
*/
|
||||
#[Route('/admin/teamer/mailing/draft', name: 'app_admin_teamer_mailing_draft', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function draft(Request $request): Response
|
||||
{
|
||||
$mailingDto = $this->createMailingForm($request)->getData();
|
||||
@@ -93,7 +93,7 @@ class MailingController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/teamer/mailing/discard', name: 'app_admin_teamer_mailing_discard')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function discard(): Response
|
||||
{
|
||||
$this->draftHandler->resetDraft();
|
||||
@@ -102,7 +102,7 @@ class MailingController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/teamer/mailing/confirm', name: 'app_admin_teamer_mailing_confirm', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function confirm(Request $request): Response
|
||||
{
|
||||
$form = $this->createMailingForm($request);
|
||||
@@ -119,7 +119,7 @@ class MailingController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/teamer/mailing/send', name: 'app_admin_teamer_mailing_send', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function send(Request $request): Response
|
||||
{
|
||||
$form = $this->createMailingForm($request);
|
||||
|
||||
@@ -21,7 +21,7 @@ class RemarksController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/teamer/remarks/{uuid}', name: 'app_admin_teamer_remarks_internal')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Teamer $teamer, Request $request): Response
|
||||
{
|
||||
$returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index');
|
||||
|
||||
@@ -25,7 +25,7 @@ class SkillsController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/teamer/skills/{uuid}', name: 'app_admin_teamer_skills')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Teamer $teamer, Request $request): Response
|
||||
{
|
||||
$trainings = $this->trainingRepository->getList();
|
||||
|
||||
@@ -34,7 +34,7 @@ class CreateController extends AbstractController
|
||||
path: '/admin/teamer/skills/training-attendance/create/{training_id}/{teamer_id}',
|
||||
name: 'app_admin_teamer_skills_training_attendance_create'
|
||||
)]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(
|
||||
#[MapEntity(mapping: ['training_id' => 'id'])]
|
||||
Training $training,
|
||||
|
||||
@@ -30,7 +30,7 @@ class DeleteController extends AbstractController
|
||||
path: '/admin/teamer/skills/training-attendance/delete/{uuid}',
|
||||
name: 'app_admin_teamer_skills_training_attendance_delete'
|
||||
)]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(TrainingAttendance $attendance, Request $request): Response
|
||||
{
|
||||
if (true === $request->isMethod('POST')) {
|
||||
|
||||
@@ -25,7 +25,7 @@ class CallOffController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/administrative/assignment/call-off/{uuid}', name: 'app_administrative_assignment_call_off')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
#[IsGranted('CALL_OFF', subject: 'assignment')]
|
||||
public function index(Assignment $assignment, Request $request): Response
|
||||
{
|
||||
if (true === $request->isMethod('POST')) {
|
||||
|
||||
@@ -11,6 +11,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class PublishController extends AbstractController
|
||||
{
|
||||
@@ -23,6 +24,7 @@ class PublishController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/administrative/assignment/publish/{uuid}', name: 'app_administrative_assignment_publish')]
|
||||
#[IsGranted('PUBLISH', subject: 'assignment')]
|
||||
public function index(Assignment $assignment, Request $request): Response
|
||||
{
|
||||
$returnUrl = $this->getReturnUrl($request, 'app_administrative_assignment_index');
|
||||
|
||||
@@ -89,7 +89,7 @@ class DocumentUploadController extends AbstractController
|
||||
$uploadSession = $this->uploadHandler->getUploadSession();
|
||||
|
||||
$upload = Upload::fromUploadDto(
|
||||
$uploadSession->getUploads()->first(),
|
||||
$uploadSession->getUploadsByType($type)->first(),
|
||||
$teamer->getUser(),
|
||||
$type
|
||||
);
|
||||
@@ -113,7 +113,7 @@ class DocumentUploadController extends AbstractController
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->uploadHandler->moveUploadSessionFilesFromOrphanage($type, $uploadSession);
|
||||
$this->uploadHandler->destroyUploadSession();
|
||||
$this->uploadHandler->removeUploadSessionUploadsByType($type);
|
||||
|
||||
if (true === $this->featureManager->isActive('sanitize_uploads')) {
|
||||
$this->uploadHandler->ensurePdf($upload);
|
||||
|
||||
@@ -94,6 +94,9 @@ class CheckController extends AbstractController
|
||||
return $this->render('administrative/document/modal_check.html.twig', [
|
||||
'document' => $document,
|
||||
'form' => $form->createView(),
|
||||
'receipts' => Upload::TYPE_INVOICE === $document->getType()
|
||||
? $document->getDisposition()?->getDocumentsByType(Upload::TYPE_RECEIPT)
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -117,6 +120,25 @@ class CheckController extends AbstractController
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
private function removeReceipts(Upload $document): void
|
||||
{
|
||||
$receipts = $document->getDisposition()?->getDocumentsByType(Upload::TYPE_RECEIPT);
|
||||
|
||||
if (null === $receipts || 0 === $receipts->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($receipts as $receipt) {
|
||||
$this->entityManager->remove($receipt);
|
||||
}
|
||||
|
||||
$this->logger->info('Delete receipts of accepted invoice', [
|
||||
'document_id' => $document->getId(),
|
||||
'disposition_id' => $document->getDisposition()->getId(),
|
||||
'count' => $receipts->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function confirmDocument(Upload $document, string $transition, string $status, DocumentCheckDto $formData): void
|
||||
{
|
||||
/** @var User $user */
|
||||
@@ -147,6 +169,14 @@ class CheckController extends AbstractController
|
||||
|
||||
if (true === $this->workflow->can($document->getDisposition(), $transition)) {
|
||||
$this->workflow->apply($document->getDisposition(), $transition);
|
||||
|
||||
// Belege only have to survive until the Honorarnote is accepted. Removing them here
|
||||
// and not before the guard means a blocked confirmation - which still persists the
|
||||
// new document status below - cannot destroy the evidence for an invoice that was in
|
||||
// fact not approved. DeleteUploadListener removes the files from disk on preRemove.
|
||||
if (Upload::TYPE_INVOICE === $document->getType() && Upload::STATUS_PAID === $status) {
|
||||
$this->removeReceipts($document);
|
||||
}
|
||||
} else {
|
||||
$blockers = $this->workflow->buildTransitionBlockerList($document->getDisposition(), $transition);
|
||||
foreach ($blockers as $blocker) {
|
||||
|
||||
@@ -13,6 +13,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class EditController extends AbstractController
|
||||
{
|
||||
@@ -27,6 +28,7 @@ class EditController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/administrative/destination/edit/{id}', name: 'app_administrative_system_destination_edit')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(Destination $destination, Request $request): Response
|
||||
{
|
||||
$destinationDto = DestinationDto::fromEntity($destination);
|
||||
|
||||
@@ -32,10 +32,10 @@ class ReplaceController extends AbstractController
|
||||
if (true === $request->isMethod('POST') && 0 < $uploadSession->getCount()) {
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_DOCUMENT, $upload);
|
||||
Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_DOCUMENT)->first(), $user, Upload::TYPE_DOCUMENT, $upload);
|
||||
$this->entityManager->flush();
|
||||
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_DOCUMENT, $uploadSession);
|
||||
$this->uploadHandler->destroyUploadSession();
|
||||
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_DOCUMENT);
|
||||
}
|
||||
|
||||
$filenameBefore = $upload->getOriginalFilename();
|
||||
|
||||
@@ -39,14 +39,14 @@ class UploadController extends AbstractController
|
||||
if (true === $request->isMethod('POST') && 0 < $uploadSession->getCount()) {
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
foreach ($uploadSession->getUploads() as $upload) {
|
||||
foreach ($uploadSession->getUploadsByType(Upload::TYPE_DOCUMENT) as $upload) {
|
||||
$document = Upload::fromUploadDto($upload, $user, Upload::TYPE_DOCUMENT);
|
||||
$uploadedDocuments[] = $document;
|
||||
$this->entityManager->persist($document);
|
||||
}
|
||||
$this->entityManager->flush();
|
||||
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_DOCUMENT, $uploadSession);
|
||||
$this->uploadHandler->destroyUploadSession();
|
||||
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_DOCUMENT);
|
||||
}
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
|
||||
@@ -32,6 +32,7 @@ class IndexController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/administrative/system/faq/sort', name: 'app_administrative_system_faq_sort', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function sort(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
@@ -55,6 +55,7 @@ class ApplicationFilterController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/common/application/filter/reset', name: 'app_common_application_filter_reset')]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function reset(Request $request): Response
|
||||
{
|
||||
$this->filterHandler->resetFilterSettings();
|
||||
|
||||
@@ -53,6 +53,7 @@ class AssignmentFilterController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/common/assignment/filter/reset', name: 'app_common_assignment_filter_reset')]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function reset(Request $request): Response
|
||||
{
|
||||
$this->filterHandler->resetFilterSettings();
|
||||
|
||||
@@ -47,6 +47,7 @@ class DocumentFilterController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/common/document/filter/reset', name: 'app_common_document_filter_reset')]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function reset(Request $request): Response
|
||||
{
|
||||
$this->filterHandler->resetFilterSettings();
|
||||
|
||||
@@ -39,6 +39,7 @@ class FeedbackFilterController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/common/feedback/filter/reset', name: 'app_common_feedback_filter_reset')]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function reset(Request $request): Response
|
||||
{
|
||||
$this->filterHandler->resetFilterSettings();
|
||||
|
||||
@@ -42,6 +42,7 @@ class TeamerFilterController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/common/teamer/filter/reset', name: 'app_common_teamer_filter_reset')]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function reset(Request $request): Response
|
||||
{
|
||||
$this->filterHandler->resetFilterSettings();
|
||||
|
||||
@@ -52,6 +52,7 @@ class TimelineFilterController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/common/timeline/filter/reset', name: 'app_common_timeline_filter_reset')]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function reset(Request $request): Response
|
||||
{
|
||||
$this->filterHandler->resetFilterSettings();
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Repository\UploadRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class IndexController extends AbstractController
|
||||
{
|
||||
@@ -18,6 +19,7 @@ class IndexController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/management', name: 'app_manager_index')]
|
||||
#[IsGranted('ROLE_MANAGER')]
|
||||
public function index(): Response
|
||||
{
|
||||
$dispositions = $this->dispositionRepository->getNew();
|
||||
|
||||
@@ -133,13 +133,13 @@ class DriverLicenseController extends AbstractController
|
||||
$this->entityManager->remove($existingUpload);
|
||||
}
|
||||
|
||||
$upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_DRIVER_LICENSE);
|
||||
$upload = Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_DRIVER_LICENSE)->first(), $user, Upload::TYPE_DRIVER_LICENSE);
|
||||
$upload->setStatus(Upload::STATUS_NEW);
|
||||
|
||||
$teamer->setDriverLicenseUpload($upload);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_DRIVER_LICENSE, $uploadSession);
|
||||
$this->uploadHandler->destroyUploadSession();
|
||||
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_DRIVER_LICENSE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Repository\ContactRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class ContactController extends AbstractController
|
||||
{
|
||||
@@ -15,6 +16,7 @@ class ContactController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/teamer/contact', name: 'app_teamer_contact')]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function index(): Response
|
||||
{
|
||||
$allContacts = $this
|
||||
|
||||
@@ -86,14 +86,18 @@ class DetailController extends AbstractController
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_CONTRACT);
|
||||
$upload = Upload::fromUploadDto(
|
||||
$uploadSession->getUploadsByType(Upload::TYPE_CONTRACT)->first(),
|
||||
$user,
|
||||
Upload::TYPE_CONTRACT
|
||||
);
|
||||
$upload->setStatus(Upload::STATUS_NEW);
|
||||
|
||||
$disposition->addDocument($upload);
|
||||
$this->workflow->apply($disposition, 'upload_contract');
|
||||
|
||||
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_CONTRACT, $uploadSession);
|
||||
$this->uploadHandler->destroyUploadSession();
|
||||
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_CONTRACT);
|
||||
|
||||
if (true === $this->featureManager->isActive('sanitize_uploads')) {
|
||||
$this->uploadHandler->ensurePdf($upload);
|
||||
@@ -119,14 +123,18 @@ class DetailController extends AbstractController
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_INVOICE);
|
||||
$upload = Upload::fromUploadDto(
|
||||
$uploadSession->getUploadsByType(Upload::TYPE_INVOICE)->first(),
|
||||
$user,
|
||||
Upload::TYPE_INVOICE
|
||||
);
|
||||
$upload->setStatus(Upload::STATUS_NEW);
|
||||
|
||||
$disposition->addDocument($upload);
|
||||
$this->workflow->apply($disposition, 'upload_invoice');
|
||||
|
||||
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_INVOICE, $uploadSession);
|
||||
$this->uploadHandler->destroyUploadSession();
|
||||
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_INVOICE);
|
||||
|
||||
if (true === $this->featureManager->isActive('sanitize_uploads')) {
|
||||
$this->uploadHandler->ensurePdf($upload);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Teamer\Disposition;
|
||||
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\Upload;
|
||||
use App\Entity\User;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Model\UploadDto;
|
||||
use App\Service\Upload\UploadHandler;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Flagception\Manager\FeatureManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Belege backing a Honorarnote.
|
||||
*
|
||||
* Deliberately separate from DetailController and from the disposition workflow: a receipt is
|
||||
* evidence for the invoice, never a document in its own right. It is not approved or rejected, it
|
||||
* carries no status, and it applies no transition - which is what lets a teamer keep managing
|
||||
* receipts while the invoice is being checked, a phase in which the state machine offers no
|
||||
* transition at all. The window is guarded by the MANAGE_RECEIPTS attribute on the disposition.
|
||||
*/
|
||||
class ReceiptController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UploadHandler $uploadHandler,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly FeatureManagerInterface $featureManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/teamer/disposition/receipt/{uuid}', name: 'app_teamer_disposition_receipt_upload')]
|
||||
#[IsGranted('ROLE_TEAMER')]
|
||||
#[IsGranted('MANAGE_RECEIPTS', subject: 'disposition')]
|
||||
public function upload(Disposition $disposition, Request $request): Response
|
||||
{
|
||||
// Dummy form without fields, as elsewhere: the files travel in the upload session, not in
|
||||
// the form itself.
|
||||
$form = $this->createFormBuilder()->getForm();
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$count = $this->processUpload($disposition);
|
||||
|
||||
$this->addFlash(
|
||||
'success',
|
||||
1 === $count ? 'Der Beleg wurde hochgeladen' : 'Die Belege wurden hochgeladen'
|
||||
);
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_teamer_disposition_detail', [
|
||||
'uuid' => $disposition->getUuid(),
|
||||
'r' => $request->query->get('r'),
|
||||
]));
|
||||
}
|
||||
|
||||
return $this->render('teamer/disposition/modal_receipt_upload.html.twig', [
|
||||
'disposition' => $disposition,
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/teamer/disposition/receipt/delete/{uuid}', name: 'app_teamer_disposition_receipt_delete')]
|
||||
#[IsGranted('ROLE_TEAMER')]
|
||||
#[IsGranted('DELETE', subject: 'receipt')]
|
||||
public function delete(Upload $receipt, Request $request): Response
|
||||
{
|
||||
if (Upload::TYPE_RECEIPT !== $receipt->getType()) {
|
||||
throw $this->createNotFoundException('Not a receipt');
|
||||
}
|
||||
|
||||
// UploadVoter::DELETE only asserts ownership, which would still hold once the invoice has
|
||||
// been accepted. The window itself is the disposition's to answer.
|
||||
$this->denyAccessUnlessGranted('MANAGE_RECEIPTS', $receipt->getDisposition());
|
||||
|
||||
if (true === $request->isMethod('POST')) {
|
||||
$disposition = $receipt->getDisposition();
|
||||
|
||||
// Doctrine's DeleteUploadListener removes the file from disk on preRemove.
|
||||
$this->entityManager->remove($receipt);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Der Beleg wurde gelöscht');
|
||||
$this->logger->info('Delete receipt', [
|
||||
'disposition_id' => $disposition->getId(),
|
||||
'receipt_filename' => $receipt->getOriginalFilename(),
|
||||
'owner' => $receipt->getOwner()->getFullName(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_teamer_disposition_detail', [
|
||||
'uuid' => $disposition->getUuid(),
|
||||
'r' => $request->query->get('r'),
|
||||
]));
|
||||
}
|
||||
|
||||
return $this->render('teamer/disposition/modal_receipt_delete.html.twig', [
|
||||
'receipt' => $receipt,
|
||||
]);
|
||||
}
|
||||
|
||||
private function processUpload(Disposition $disposition): int
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$uploadSession = $this->uploadHandler->getUploadSession();
|
||||
|
||||
// Only this dropzone's files: on the detail page in place "ended" the invoice dropzone is
|
||||
// live at the same time and shares the one session.
|
||||
$uploadDtos = $uploadSession->getUploadsByType(Upload::TYPE_RECEIPT);
|
||||
|
||||
$uploads = [];
|
||||
|
||||
/** @var UploadDto $uploadDto */
|
||||
foreach ($uploadDtos as $uploadDto) {
|
||||
$upload = Upload::fromUploadDto($uploadDto, $user, Upload::TYPE_RECEIPT);
|
||||
|
||||
// No status on purpose. UploadVoter grants CHECK only for "new" and "pending", so a
|
||||
// null status is what keeps a receipt out of the checking process by construction.
|
||||
$upload->setStatus(null);
|
||||
|
||||
$disposition->addDocument($upload);
|
||||
$this->entityManager->persist($upload);
|
||||
|
||||
$uploads[] = $upload;
|
||||
}
|
||||
|
||||
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_RECEIPT, $uploadSession);
|
||||
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_RECEIPT);
|
||||
|
||||
if (true === $this->featureManager->isActive('sanitize_uploads')) {
|
||||
foreach ($uploads as $upload) {
|
||||
$this->uploadHandler->ensurePdf($upload);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->info('Upload receipts', [
|
||||
'user' => $user->getUserIdentifier(),
|
||||
'disposition_id' => $disposition->getId(),
|
||||
'count' => count($uploads),
|
||||
]);
|
||||
|
||||
// No DocumentUploadedEvent: its listener mails the managers about a document waiting to be
|
||||
// checked, which is exactly what a receipt is not.
|
||||
|
||||
return count($uploads);
|
||||
}
|
||||
}
|
||||
@@ -96,11 +96,11 @@ class IndexController extends AbstractController
|
||||
$this->entityManager->remove($existingUpload);
|
||||
}
|
||||
|
||||
$upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_PHOTO);
|
||||
$upload = Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_PHOTO)->first(), $user, Upload::TYPE_PHOTO);
|
||||
$teamer->setPhoto($upload);
|
||||
$this->entityManager->flush();
|
||||
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_PHOTO, $uploadSession);
|
||||
$this->uploadHandler->destroyUploadSession();
|
||||
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_PHOTO);
|
||||
|
||||
$this->addFlash('success', 'Dein Profilbild wurde aktualisiert');
|
||||
$this->logger->info('Update teamer photo', [
|
||||
|
||||
@@ -73,11 +73,11 @@ class AddController extends AbstractController
|
||||
|
||||
private function updateCertificate(User $user, License $license, UploadSessionDto $uploadSession): void
|
||||
{
|
||||
$upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_CERTIFICATE);
|
||||
$upload = Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_CERTIFICATE)->first(), $user, Upload::TYPE_CERTIFICATE);
|
||||
$license->setCertificate($upload);
|
||||
$this->entityManager->flush();
|
||||
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_CERTIFICATE, $uploadSession);
|
||||
$this->uploadHandler->destroyUploadSession();
|
||||
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_CERTIFICATE);
|
||||
|
||||
$this->addFlash('success', 'Der Nachweis wurde hochgeladen');
|
||||
}
|
||||
|
||||
@@ -188,6 +188,20 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlike getDocumentByType() this returns every match, for the types a disposition can hold
|
||||
* more than one of. Belege are the only such type today: there is exactly one Honorarvertrag
|
||||
* and one Honorarnote, but any number of receipts backing the latter.
|
||||
*
|
||||
* @return Collection<int, Upload>
|
||||
*/
|
||||
public function getDocumentsByType(string $type): Collection
|
||||
{
|
||||
return $this->documents->filter(function (Upload $upload) use ($type) {
|
||||
return $type === $upload->getType();
|
||||
});
|
||||
}
|
||||
|
||||
public function addDocument(Upload $document): static
|
||||
{
|
||||
if (!$this->documents->contains($document)) {
|
||||
|
||||
@@ -22,6 +22,7 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
public const TYPE_INVOICE = 'invoice';
|
||||
public const TYPE_DOCUMENT = 'document';
|
||||
public const TYPE_DRIVER_LICENSE = 'driver_license';
|
||||
public const TYPE_RECEIPT = 'receipt';
|
||||
|
||||
public const STATUS_NEW = 'new';
|
||||
public const STATUS_PENDING = 'pending';
|
||||
@@ -135,6 +136,7 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
self::TYPE_INVOICE => 'Honorarnote',
|
||||
self::TYPE_DOCUMENT => 'Info',
|
||||
self::TYPE_DRIVER_LICENSE => 'Führerschein',
|
||||
self::TYPE_RECEIPT => 'Beleg',
|
||||
default => 'Dokument',
|
||||
};
|
||||
}
|
||||
|
||||
+7
-7
@@ -22,7 +22,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
|
||||
* Assignable roles and their labels.
|
||||
*/
|
||||
public const ROLES = [
|
||||
'ROLE_ADMIN' => 'Admin',
|
||||
'ROLE_TEAM_ADMIN' => 'Team Admin',
|
||||
'ROLE_MANAGER' => 'Reisemanager',
|
||||
'ROLE_HOUSE_MANAGER' => 'Hausleitung',
|
||||
'ROLE_TEAMER' => 'Teamer',
|
||||
@@ -35,7 +35,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
|
||||
* a super admin.
|
||||
*/
|
||||
public const PENDING_ROLES = [
|
||||
'ROLE_ADMIN' => 'ROLE_ADMIN_PENDING',
|
||||
'ROLE_TEAM_ADMIN' => 'ROLE_TEAM_ADMIN_PENDING',
|
||||
'ROLE_MANAGER' => 'ROLE_MANAGER_PENDING',
|
||||
'ROLE_HOUSE_MANAGER' => 'ROLE_HOUSE_MANAGER_PENDING',
|
||||
];
|
||||
@@ -194,7 +194,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
|
||||
$roles = ['ROLE_USER', ...$this->roles];
|
||||
|
||||
if (true === $this->isSuperAdmin()) {
|
||||
$roles[] = 'ROLE_SUPER_ADMIN';
|
||||
$roles[] = 'ROLE_TEAM_SUPER_ADMIN';
|
||||
}
|
||||
|
||||
return array_unique($roles);
|
||||
@@ -223,7 +223,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
|
||||
|
||||
/**
|
||||
* The manually assignable roles held by the user, i.e. without the implicit ROLE_USER
|
||||
* and ROLE_SUPER_ADMIN added by getRoles() and without any pending marker. Used to
|
||||
* and ROLE_TEAM_SUPER_ADMIN added by getRoles() and without any pending marker. Used to
|
||||
* edit role assignments: saving them resolves the pending approvals.
|
||||
*/
|
||||
public function getAssignedRoles(): array
|
||||
@@ -283,12 +283,12 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
|
||||
}
|
||||
|
||||
/**
|
||||
* Super admin is an elevation of ROLE_ADMIN, never a standalone grant.
|
||||
* Super admin is an elevation of ROLE_TEAM_ADMIN, never a standalone grant.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateSuperAdmin(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (true === $this->superAdmin && false === in_array('ROLE_ADMIN', $this->roles, true)) {
|
||||
if (true === $this->superAdmin && false === in_array('ROLE_TEAM_ADMIN', $this->roles, true)) {
|
||||
$context
|
||||
->buildViolation('Nur Admins können zu Superadmins ernannt werden.')
|
||||
->atPath('superAdmin')
|
||||
@@ -311,7 +311,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
|
||||
|
||||
public function getDefaultRoute(): string
|
||||
{
|
||||
if ($this->hasRole('ROLE_ADMIN')) {
|
||||
if ($this->hasRole('ROLE_TEAM_ADMIN')) {
|
||||
return 'app_admin_index';
|
||||
} elseif ($this->hasRole('ROLE_MANAGER')) {
|
||||
return 'app_manager_index';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* What became of an application that was hard deleted.
|
||||
*
|
||||
* Applications are removed on the way to a placement just as they are removed when they come
|
||||
* to nothing, and the row is gone either way. Without this, staffing somebody would read as
|
||||
* an application being thrown away, and "how many applications did we lose" would count every
|
||||
* success along with every loss.
|
||||
*/
|
||||
enum ApplicationDeletionOutcome: string
|
||||
{
|
||||
/** It became a placement: a disposition was created from it in the same flush. */
|
||||
case DISPOSED = 'disposed';
|
||||
|
||||
/** Nobody was staffed from it - withdrawn by the teamer, deleted by the office, or purged. */
|
||||
case REMOVED = 'removed';
|
||||
}
|
||||
@@ -19,6 +19,7 @@ enum StatisticsEventName: string
|
||||
case DISPOSITION_CALLED_OFF = 'disposition.called_off';
|
||||
case DISPOSITION_DELETED = 'disposition.deleted';
|
||||
case APPLICATION_CREATED = 'application.created';
|
||||
case APPLICATION_DELETED = 'application.deleted';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
@@ -28,6 +29,7 @@ enum StatisticsEventName: string
|
||||
self::DISPOSITION_CALLED_OFF => 'Einsatz abgesagt',
|
||||
self::DISPOSITION_DELETED => 'Einteilung gelöscht',
|
||||
self::APPLICATION_CREATED => 'Bewerbung eingegangen',
|
||||
self::APPLICATION_DELETED => 'Bewerbung gelöscht',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,15 @@ class UploadSessionListener
|
||||
// Get original filename from Dropzone request
|
||||
$originalFileName = $request->request->get('originalFilename');
|
||||
|
||||
// The mapping name, which is 1:1 with the Upload::TYPE_* values. Without it the session
|
||||
// is a flat list and a page with two dropzones cannot tell its own files apart.
|
||||
$upload = new UploadDto(
|
||||
$uuid,
|
||||
$uploadedFile->getFilename(),
|
||||
$originalFileName,
|
||||
$uploadedFile->getMimeType(),
|
||||
$uploadedFile->getSize()
|
||||
$uploadedFile->getSize(),
|
||||
$event->getType()
|
||||
);
|
||||
|
||||
$uploadSession = $this->uploadHandler->addUploadToSession($upload);
|
||||
|
||||
@@ -118,7 +118,7 @@ class AssignmentType extends AbstractType
|
||||
return $qb
|
||||
->where($qb->expr()->like('user.roles', ':role'))
|
||||
->setParameter('role', '%ROLE_MANAGER%')
|
||||
->orderBy('user.firstName', 'ASC')
|
||||
->orderBy('user.firstName', \SortDirection::Ascending)
|
||||
;
|
||||
},
|
||||
])
|
||||
|
||||
@@ -37,7 +37,7 @@ class JobProfileType extends AbstractType
|
||||
'expanded' => true,
|
||||
'query_builder' => function (EntityRepository $repository) {
|
||||
return $repository->createQueryBuilder('job_profile')
|
||||
->orderBy('job_profile.name', 'ASC')
|
||||
->orderBy('job_profile.name', \SortDirection::Ascending)
|
||||
;
|
||||
},
|
||||
])
|
||||
|
||||
@@ -48,7 +48,7 @@ class TeamerFilterType extends AbstractType
|
||||
'empty_label' => 'nicht filtern',
|
||||
'query_builder' => function (EntityRepository $repository) {
|
||||
return $repository->createQueryBuilder('job_profile')
|
||||
->orderBy('job_profile.name', 'ASC');
|
||||
->orderBy('job_profile.name', \SortDirection::Ascending);
|
||||
},
|
||||
])
|
||||
->add('noTrainings', CheckboxType::class, [
|
||||
|
||||
@@ -67,7 +67,7 @@ class UserType extends AbstractType
|
||||
});
|
||||
|
||||
// Super admin is an elevation of an existing role, never a grant of its own, so the
|
||||
// field exists only for somebody who already holds ROLE_ADMIN - offering it to
|
||||
// field exists only for somebody who already holds ROLE_TEAM_ADMIN - offering it to
|
||||
// anyone else would only produce the violation from User::validateSuperAdmin().
|
||||
// A flag already set without the role is the exception: it has to stay editable, or
|
||||
// that user could not be saved at all until BusPro claims them an admin again.
|
||||
@@ -78,7 +78,7 @@ class UserType extends AbstractType
|
||||
return;
|
||||
}
|
||||
|
||||
if (false === in_array('ROLE_ADMIN', $user->getAssignedRoles(), true) && false === $user->isSuperAdmin()) {
|
||||
if (false === in_array('ROLE_TEAM_ADMIN', $user->getAssignedRoles(), true) && false === $user->isSuperAdmin()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ abstract class AbstractMenuBuilder
|
||||
* Note that "app_admin_" does not match the shared "app_administrative_" routes.
|
||||
*/
|
||||
protected const AREA_ROUTE_PREFIXES = [
|
||||
'app_admin_' => 'ROLE_ADMIN',
|
||||
'app_admin_' => 'ROLE_TEAM_ADMIN',
|
||||
'app_manager_' => 'ROLE_MANAGER',
|
||||
'app_house_manager_' => 'ROLE_HOUSE_MANAGER',
|
||||
'app_teamer_' => 'ROLE_TEAMER',
|
||||
@@ -29,7 +29,7 @@ abstract class AbstractMenuBuilder
|
||||
* Priority order of the area roles, must stay in sync with User::getDefaultRoute().
|
||||
*/
|
||||
protected const ROLE_PRIORITY = [
|
||||
'ROLE_ADMIN',
|
||||
'ROLE_TEAM_ADMIN',
|
||||
'ROLE_MANAGER',
|
||||
'ROLE_HOUSE_MANAGER',
|
||||
'ROLE_TEAMER',
|
||||
@@ -140,7 +140,7 @@ abstract class AbstractMenuBuilder
|
||||
|
||||
protected function addAdminItem(ItemInterface $menu): void
|
||||
{
|
||||
if ($this->security->isGranted('ROLE_ADMIN')) {
|
||||
if ($this->security->isGranted('ROLE_TEAM_ADMIN')) {
|
||||
$this->addDivider($menu);
|
||||
$menu->addChild('zum Adminbereich', [
|
||||
'route' => 'app_admin_index',
|
||||
|
||||
@@ -32,7 +32,7 @@ class MenuBuilder extends AbstractMenuBuilder
|
||||
public function createMainMenu(array $options): ItemInterface
|
||||
{
|
||||
return match ($this->resolveArea()) {
|
||||
'ROLE_ADMIN' => $this->adminMenuBuilder->createMainMenu($options),
|
||||
'ROLE_TEAM_ADMIN' => $this->adminMenuBuilder->createMainMenu($options),
|
||||
'ROLE_MANAGER' => $this->managerMenuBuilder->createMainMenu($options),
|
||||
'ROLE_HOUSE_MANAGER' => $this->houseManagerMenuBuilder->createMainMenu($options),
|
||||
'ROLE_TEAMER' => $this->teamerMenuBuilder->createMainMenu($options),
|
||||
@@ -43,7 +43,7 @@ class MenuBuilder extends AbstractMenuBuilder
|
||||
public function createTeamerMenu(array $options): ItemInterface
|
||||
{
|
||||
return match ($this->resolveArea()) {
|
||||
'ROLE_ADMIN' => $this->adminMenuBuilder->createTeamerMenu($options),
|
||||
'ROLE_TEAM_ADMIN' => $this->adminMenuBuilder->createTeamerMenu($options),
|
||||
'ROLE_MANAGER' => $this->managerMenuBuilder->createTeamerMenu($options),
|
||||
default => $this->createRootElement(),
|
||||
};
|
||||
|
||||
@@ -52,5 +52,4 @@ class DriverLicenseDeclarationDto
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,13 +4,25 @@ namespace App\Model;
|
||||
|
||||
class UploadDto
|
||||
{
|
||||
/**
|
||||
* The oneup_uploader mapping the file was uploaded through, which is 1:1 with the
|
||||
* Upload::TYPE_* values. Deliberately not a promoted constructor property: the enclosing
|
||||
* UploadSessionDto is serialized into the PHP session, and a promoted property carries no
|
||||
* class-level default, so entries written before this field existed would deserialize
|
||||
* uninitialized and throw on access. Nullable with a real default lets them come back as
|
||||
* "unknown type", which matches no type and is therefore ignored.
|
||||
*/
|
||||
private ?string $type = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $uuid,
|
||||
private readonly string $filename,
|
||||
private readonly string $originalFilename,
|
||||
private readonly string $mimeType,
|
||||
private readonly int $size,
|
||||
?string $type = null,
|
||||
) {
|
||||
$this->type = $type;
|
||||
}
|
||||
|
||||
public function getUuid(): string
|
||||
@@ -37,4 +49,9 @@ class UploadDto
|
||||
{
|
||||
return $this->size;
|
||||
}
|
||||
|
||||
public function getType(): ?string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,29 @@ class UploadSessionDto
|
||||
return $this->uploads;
|
||||
}
|
||||
|
||||
/**
|
||||
* The uploads that came in through one oneup_uploader mapping. Two dropzones can be live on
|
||||
* the same page (the Honorarnote and its Belege), and the session is one flat list, so a
|
||||
* consumer must never take getUploads()->first() and hope.
|
||||
*
|
||||
* @return Collection<int, UploadDto>
|
||||
*/
|
||||
public function getUploadsByType(string $type): Collection
|
||||
{
|
||||
return $this->uploads->filter(function (UploadDto $upload) use ($type) {
|
||||
return $type === $upload->getType();
|
||||
});
|
||||
}
|
||||
|
||||
public function removeUploadsByType(string $type): static
|
||||
{
|
||||
foreach ($this->getUploadsByType($type) as $upload) {
|
||||
$this->uploads->removeElement($upload);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function flushUploads(): static
|
||||
{
|
||||
$this->uploads->clear();
|
||||
|
||||
@@ -231,7 +231,7 @@ class ApplicationRepository extends ServiceEntityRepository
|
||||
->innerJoin('application.teamer', 'teamer')
|
||||
->innerJoin('teamer.user', 'user')
|
||||
->where($qb->expr()->neq('application.status', ':status'))
|
||||
->orderBy('application.createdAt', 'DESC')
|
||||
->orderBy('application.createdAt', \SortDirection::Descending)
|
||||
->setParameter('status', Application::STATUS_REJECTED)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
@@ -280,8 +280,8 @@ class ApplicationRepository extends ServiceEntityRepository
|
||||
->innerJoin('application.assignment', 'assignment')
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->groupBy('hotelCode')
|
||||
->orderBy('totalCount', 'DESC')
|
||||
->addOrderBy('hotelCode', 'ASC')
|
||||
->orderBy('totalCount', \SortDirection::Descending)
|
||||
->addOrderBy('hotelCode', \SortDirection::Ascending)
|
||||
;
|
||||
|
||||
SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
|
||||
|
||||
@@ -152,7 +152,7 @@ class AssignmentRepository extends ServiceEntityRepository
|
||||
;
|
||||
}
|
||||
|
||||
if (0 < count((array)$filterDto->getHotels())) {
|
||||
if (0 < count((array) $filterDto->getHotels())) {
|
||||
$constraints = [];
|
||||
foreach ($filterDto->getHotels() as $index => $hotel) {
|
||||
$constraints[] = $qb->expr()->like('destination.hotel', ':hotel'.$index);
|
||||
@@ -293,7 +293,7 @@ class AssignmentRepository extends ServiceEntityRepository
|
||||
$result = $qb
|
||||
->select('assignment', 'job_profile')
|
||||
->innerJoin('assignment.jobProfile', 'job_profile')
|
||||
->orderBy('job_profile.name', 'ASC')
|
||||
->orderBy('job_profile.name', \SortDirection::Ascending)
|
||||
->where($qb->expr()->isNull('assignment.deletedAt'))
|
||||
->getQuery()
|
||||
->getResult()
|
||||
@@ -314,7 +314,7 @@ class AssignmentRepository extends ServiceEntityRepository
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->innerJoin('assignment.jobProfile', 'job_profile')
|
||||
->innerJoin('assignment.owner', 'owner')
|
||||
->orderBy('assignment.createdAt', 'DESC')
|
||||
->orderBy('assignment.createdAt', \SortDirection::Descending)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
@@ -358,7 +358,7 @@ class AssignmentRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
return $qb
|
||||
->orderBy('destination.dateFrom', 'ASC')
|
||||
->orderBy('destination.dateFrom', \SortDirection::Ascending)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user