Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.02% covered (warning)
86.02%
320 / 372
27.78% covered (danger)
27.78%
5 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
MailTemplatesCopy
86.02% covered (warning)
86.02%
320 / 372
27.78% covered (danger)
27.78%
5 / 18
88.96
0.00% covered (danger)
0.00%
0 / 1
 readResponse
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
1 / 1
3
 readAuthorizedWorkstation
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 readQueryParameters
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 readScopeList
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 withScopesThatHaveProvider
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 readProviderId
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 resolveSourceScopeId
53.33% covered (warning)
53.33%
8 / 15
0.00% covered (danger)
0.00%
0 / 1
9.66
 resolveSourceScope
42.86% covered (danger)
42.86%
6 / 14
0.00% covered (danger)
0.00%
0 / 1
6.99
 readCustomTemplates
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
4.01
 processPostRequest
83.33% covered (warning)
83.33%
45 / 54
0.00% covered (danger)
0.00%
0 / 1
10.46
 validateTargetScopes
74.07% covered (warning)
74.07%
20 / 27
0.00% covered (danger)
0.00%
0 / 1
5.44
 copyTemplate
83.33% covered (warning)
83.33%
30 / 36
0.00% covered (danger)
0.00%
0 / 1
4.07
 readPositiveInt
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
6.22
 readTargetScopeIds
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 countUniqueProviders
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 buildTargetScopeOptions
97.44% covered (success)
97.44%
38 / 39
0.00% covered (danger)
0.00%
0 / 1
7
 isEligibleCopyTarget
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 formatScopeName
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
2.69
1<?php
2
3namespace BO\Zmsadmin;
4
5use BO\Slim\Render;
6use BO\Zmsclient\Exception;
7use BO\Zmsentities\Collection\MailtemplateList;
8use BO\Zmsentities\Collection\ScopeList;
9use BO\Zmsentities\Exception\ScopeMissingProvider;
10use BO\Zmsentities\Exception\UserAccountMissingRights;
11use BO\Zmsentities\Scope;
12use BO\Zmsentities\Workstation;
13use Psr\Http\Message\RequestInterface;
14use Psr\Http\Message\ResponseInterface;
15
16class MailTemplatesCopy extends BaseController
17{
18    /**
19     * @return ResponseInterface
20     */
21    #[\Override]
22    public function readResponse(
23        RequestInterface $request,
24        ResponseInterface $response,
25        array $args
26    ): ResponseInterface {
27        $workstation = $this->readAuthorizedWorkstation();
28        $queryParameters = $this->readQueryParameters($request);
29        $scopeList = $this->readScopeList($workstation);
30
31        $input = (array) ($request->getParsedBody() ?? []);
32        $isPostRequest = strtoupper($request->getMethod()) === 'POST';
33
34        $sourceScopeId = $this->resolveSourceScopeId(
35            $input,
36            $queryParameters['sourceScopeId'],
37            $isPostRequest,
38            $workstation,
39            $scopeList
40        );
41
42        [
43            $selectedSourceScope,
44            $formError,
45        ] = $this->resolveSourceScope(
46            $sourceScopeId,
47            $isPostRequest,
48            $scopeList
49        );
50
51        $customTemplates = $this->readCustomTemplates(
52            $selectedSourceScope
53        );
54
55        $sourceTemplateId = null;
56        $selectedTargetScopeIds = [];
57
58        if ($isPostRequest) {
59            $postResult = $this->processPostRequest(
60                $input,
61                $sourceScopeId,
62                $selectedSourceScope,
63                $scopeList,
64                $customTemplates,
65                $formError
66            );
67
68            if ($postResult['response'] instanceof ResponseInterface) {
69                return $postResult['response'];
70            }
71
72            $sourceTemplateId =
73                $postResult['sourceTemplateId'];
74
75            $selectedTargetScopeIds =
76                $postResult['selectedTargetScopeIds'];
77
78            $formError = $postResult['formError'];
79        }
80
81        return Render::withHtml(
82            $response,
83            'page/mailtemplatesCopy.twig',
84            [
85                'title' => 'E-Mail-Template kopieren',
86                'pageTitle' => 'E-Mail-Template kopieren',
87                'menuActive' => 'mailtemplatesCopy',
88                'workstation' => $workstation,
89                'scopeList' => $scopeList,
90                'sourceScopeId' => $sourceScopeId,
91                'selectedSourceScope' => $selectedSourceScope,
92                'customTemplates' => $customTemplates,
93                'selectedSourceTemplateId' => $sourceTemplateId,
94                'selectedTargetScopeIds' =>
95                    $selectedTargetScopeIds,
96                'formError' => $formError,
97                'success' => $queryParameters['success'],
98                'copiedCount' =>
99                    $queryParameters['copiedCount'],
100                'targetScopeOptions' =>
101                    $this->buildTargetScopeOptions(
102                        $workstation,
103                        $scopeList,
104                        $selectedSourceScope
105                    ),
106            ]
107        );
108    }
109
110    private function readAuthorizedWorkstation(): Workstation
111    {
112        $workstation = \App::$http
113            ->readGetResult(
114                '/workstation/',
115                ['resolveReferences' => 3]
116            )
117            ->getEntity();
118
119        if (
120            !$workstation
121                ->getUseraccount()
122                ->hasPermissions(['mailtemplates'])
123        ) {
124            throw new UserAccountMissingRights();
125        }
126
127        return $workstation;
128    }
129
130    private function readQueryParameters(
131        RequestInterface $request
132    ): array {
133        $validator = $request->getAttribute('validator');
134
135        return [
136            'success' => $validator
137                ->getParameter('success')
138                ->isString()
139                ->getValue(),
140            'copiedCount' => (int) $validator
141                ->getParameter('copiedCount')
142                ->isNumber()
143                ->setDefault(0)
144                ->getValue(),
145            'sourceScopeId' => $validator
146                ->getParameter('sourceScopeId')
147                ->isNumber()
148                ->getValue(),
149        ];
150    }
151
152    private function readScopeList(Workstation $workstation): ScopeList
153    {
154        /*
155         * Dieselbe Menge wie backend EntityAccess/hasScope():
156         * zugewiesene Departments inklusive Cluster-Standorte.
157         */
158        $scopeList = $workstation
159            ->getUseraccount()
160            ->getDepartmentList()
161            ->getUniqueScopeList()
162            ->sortByContactName();
163
164        return $this->withScopesThatHaveProvider($scopeList);
165    }
166
167    private function withScopesThatHaveProvider(
168        ScopeList $scopeList
169    ): ScopeList {
170        $authorizedScopeList = new ScopeList();
171
172        foreach ($scopeList as $scope) {
173            if ($this->readProviderId($scope) === '') {
174                continue;
175            }
176
177            $authorizedScopeList->addEntity($scope);
178        }
179
180        return $authorizedScopeList;
181    }
182
183    private function readProviderId(Scope $scope): string
184    {
185        try {
186            $providerId = (string) $scope->getProviderId();
187        } catch (ScopeMissingProvider) {
188            return '';
189        }
190
191        if ($providerId === '' || $providerId === '0') {
192            return '';
193        }
194
195        return $providerId;
196    }
197
198    private function resolveSourceScopeId(
199        array $input,
200        mixed $requestedSourceScopeId,
201        bool $isPostRequest,
202        Workstation $workstation,
203        ScopeList $scopeList
204    ): ?int {
205        $sourceScopeValue = $isPostRequest
206            ? ($input['sourceScopeId'] ?? null)
207            : $requestedSourceScopeId;
208
209        $sourceScopeId = $this->readPositiveInt(
210            $sourceScopeValue
211        );
212
213        if ($sourceScopeId !== null || $isPostRequest) {
214            return $sourceScopeId;
215        }
216
217        /*
218         * Beim ersten Seitenaufruf wird der aktuell ausgewählte
219         * Arbeitsplatzstandort als Quelle vorausgewählt.
220         */
221        $currentScopeId = $this->readPositiveInt(
222            $workstation->getScope()->getId()
223        );
224
225        if (
226            $currentScopeId !== null
227            && $scopeList->hasEntity($currentScopeId)
228        ) {
229            return $currentScopeId;
230        }
231
232        return null;
233    }
234
235    private function resolveSourceScope(
236        ?int $sourceScopeId,
237        bool $isPostRequest,
238        ScopeList $scopeList
239    ): array {
240        if ($sourceScopeId === null) {
241            $formError = $isPostRequest
242                ? 'Bitte wählen Sie einen gĂĽltigen '
243                    . 'Quellstandort aus.'
244                : null;
245
246            return [null, $formError];
247        }
248
249        $selectedSourceScope = $scopeList->getEntity(
250            $sourceScopeId
251        );
252
253        if ($selectedSourceScope === null) {
254            return [
255                null,
256                'Der ausgewählte Quellstandort wurde nicht '
257                    . 'gefunden oder darf nicht verwendet werden.',
258            ];
259        }
260
261        return [$selectedSourceScope, null];
262    }
263
264    private function readCustomTemplates(
265        ?Scope $selectedSourceScope
266    ): MailtemplateList {
267        $customTemplates = new MailtemplateList();
268
269        if ($selectedSourceScope === null) {
270            return $customTemplates;
271        }
272
273        $providerId = $this->readProviderId(
274            $selectedSourceScope
275        );
276
277        if ($providerId === '') {
278            return $customTemplates;
279        }
280
281        $loadedTemplates = \App::$http
282            ->readGetResult(
283                '/custom-mailtemplates/' . $providerId . '/'
284            )
285            ->getCollection();
286
287        if ($loadedTemplates === null) {
288            return $customTemplates;
289        }
290
291        $loadedTemplates->prioritizeByName([
292            'mail_preconfirmed.twig',
293            'mail_confirmation.twig',
294            'mail_reminder.twig',
295            'mail_delete.twig',
296        ]);
297
298        return $loadedTemplates;
299    }
300
301    private function processPostRequest(
302        array $input,
303        ?int $sourceScopeId,
304        ?Scope $selectedSourceScope,
305        ScopeList $scopeList,
306        MailtemplateList $customTemplates,
307        ?string $formError
308    ): array {
309        $sourceTemplateId = $this->readPositiveInt(
310            $input['sourceTemplateId'] ?? null
311        );
312
313        $normalizedTargetScopeIds =
314            $this->readTargetScopeIds(
315                $input['targetScopeIds'] ?? null
316            );
317
318        $result = [
319            'sourceTemplateId' => $sourceTemplateId,
320            'selectedTargetScopeIds' =>
321                $normalizedTargetScopeIds ?? [],
322            'formError' => $formError,
323            'response' => null,
324        ];
325
326        if ($result['formError'] !== null) {
327            return $result;
328        }
329
330        if (
331            $sourceScopeId === null
332            || $selectedSourceScope === null
333        ) {
334            $result['formError'] =
335                'Bitte wählen Sie einen gĂĽltigen '
336                . 'Quellstandort aus.';
337
338            return $result;
339        }
340
341        if (
342            $sourceTemplateId === null
343            || !$customTemplates->hasEntity(
344                $sourceTemplateId
345            )
346        ) {
347            $result['formError'] =
348                'Bitte wählen Sie ein angepasstes '
349                . 'E-Mail-Template des Quellstandorts aus.';
350
351            return $result;
352        }
353
354        if (
355            $normalizedTargetScopeIds === null
356            || $normalizedTargetScopeIds === []
357        ) {
358            $result['formError'] =
359                'Bitte wählen Sie mindestens einen '
360                . 'Zielstandort aus.';
361
362            return $result;
363        }
364
365        $targetScopeError = $this->validateTargetScopes(
366            $normalizedTargetScopeIds,
367            $selectedSourceScope,
368            $scopeList
369        );
370
371        if ($targetScopeError !== null) {
372            $result['formError'] = $targetScopeError;
373
374            return $result;
375        }
376
377        $copyResult = $this->copyTemplate(
378            $sourceScopeId,
379            $sourceTemplateId,
380            $normalizedTargetScopeIds,
381            $scopeList
382        );
383
384        if ($copyResult instanceof ResponseInterface) {
385            $result['response'] = $copyResult;
386        } else {
387            $result['formError'] = $copyResult;
388        }
389
390        return $result;
391    }
392
393    private function validateTargetScopes(
394        array $targetScopeIds,
395        Scope $selectedSourceScope,
396        ScopeList $scopeList
397    ): ?string {
398        foreach ($targetScopeIds as $targetScopeId) {
399            $targetScope = $scopeList->getEntity(
400                $targetScopeId
401            );
402
403            if ($targetScope === null) {
404                return
405                    'Mindestens ein ausgewählter Zielstandort '
406                    . 'wurde nicht gefunden oder darf nicht '
407                    . 'verwendet werden.';
408            }
409
410            $isSourceScope =
411                $targetScopeId
412                === $selectedSourceScope->getId();
413
414            if ($isSourceScope) {
415                return
416                    'Der Quellstandort darf nicht gleichzeitig '
417                    . 'als Zielstandort ausgewählt werden.';
418            }
419
420            $hasSameProvider =
421                $this->readProviderId($targetScope)
422                ===
423                $this->readProviderId($selectedSourceScope);
424
425            if ($hasSameProvider) {
426                return
427                    'Der ausgewählte Zielstandort verwendet '
428                    . 'denselben Provider wie der Quellstandort '
429                    . 'und kann daher nicht als Zielstandort '
430                    . 'verwendet werden.';
431            }
432        }
433
434        return null;
435    }
436
437    private function copyTemplate(
438        int $sourceScopeId,
439        int $sourceTemplateId,
440        array $targetScopeIds,
441        ScopeList $scopeList
442    ): ResponseInterface|string {
443        try {
444            $copiedTemplate = \App::$http
445                ->readPostResult(
446                    '/mailtemplates/copy/',
447                    [
448                        'sourceScopeId' => $sourceScopeId,
449                        'sourceTemplateId' =>
450                            $sourceTemplateId,
451                        'targetScopeIds' => $targetScopeIds,
452                    ]
453                )
454                ->getEntity();
455        } catch (Exception $exception) {
456            $backendMessage = trim(
457                (string) $exception->originalMessage
458            );
459
460            if ($backendMessage !== '') {
461                return $backendMessage;
462            }
463
464            return
465                'Beim Kopieren des E-Mail-Templates ist ein '
466                . 'Fehler aufgetreten.';
467        }
468
469        if ($copiedTemplate === null) {
470            return
471                'Das E-Mail-Template konnte nicht kopiert '
472                . 'werden.';
473        }
474
475        return Render::redirect(
476            'mailtemplatesCopy',
477            [],
478            [
479                'sourceScopeId' => $sourceScopeId,
480                'success' => 'mailtemplates_copied',
481                'copiedCount' => $this->countUniqueProviders(
482                    $targetScopeIds,
483                    $scopeList
484                ),
485            ]
486        );
487    }
488
489    private function readPositiveInt(mixed $value): ?int
490    {
491        if (
492            (!is_int($value) && !is_string($value))
493            || is_bool($value)
494        ) {
495            return null;
496        }
497
498        $validatedValue = filter_var(
499            $value,
500            FILTER_VALIDATE_INT
501        );
502
503        if (
504            $validatedValue === false
505            || $validatedValue < 1
506        ) {
507            return null;
508        }
509
510        return (int) $validatedValue;
511    }
512
513    private function readTargetScopeIds(
514        mixed $value
515    ): ?array {
516        if (!is_array($value)) {
517            return null;
518        }
519
520        $scopeIds = [];
521
522        foreach ($value as $scopeId) {
523            $validatedScopeId = $this->readPositiveInt(
524                $scopeId
525            );
526
527            if ($validatedScopeId === null) {
528                return null;
529            }
530
531            $scopeIds[] = $validatedScopeId;
532        }
533
534        return array_values(array_unique($scopeIds));
535    }
536
537    private function countUniqueProviders(
538        array $targetScopeIds,
539        ScopeList $scopeList
540    ): int {
541        $providerIds = [];
542
543        foreach ($targetScopeIds as $targetScopeId) {
544            $targetScope = $scopeList->getEntity(
545                $targetScopeId
546            );
547
548            if ($targetScope === null) {
549                continue;
550            }
551
552            $providerId = $this->readProviderId($targetScope);
553
554            if ($providerId !== '') {
555                $providerIds[] = $providerId;
556            }
557        }
558
559        return count(array_unique($providerIds));
560    }
561
562    /**
563     * @return array<int, array{name: string, options: array<int, array{value: int, name: string}>}>
564     */
565    private function buildTargetScopeOptions(
566        Workstation $workstation,
567        ScopeList $scopeList,
568        ?Scope $selectedSourceScope
569    ): array {
570        if ($selectedSourceScope === null) {
571            return [];
572        }
573
574        $departments = $workstation
575            ->getUseraccount()
576            ->getDepartmentList()
577            ->withMatchingScopes($scopeList);
578
579        $seenProviderIds = [];
580        $targetScopeOptions = [];
581
582        foreach ($departments as $department) {
583            $departmentScopeOptions = [];
584
585            foreach ($department->scopes as $scope) {
586                $providerId = $this->readProviderId($scope);
587
588                if (
589                    !$this->isEligibleCopyTarget(
590                        $scope,
591                        $selectedSourceScope
592                    )
593                    || isset($seenProviderIds[$providerId])
594                ) {
595                    continue;
596                }
597
598                $seenProviderIds[$providerId] = true;
599                $departmentScopeOptions[] = [
600                    'value' => (int) $scope->getId(),
601                    'name' => $this->formatScopeName($scope),
602                ];
603            }
604
605            if ($departmentScopeOptions === []) {
606                continue;
607            }
608
609            usort(
610                $departmentScopeOptions,
611                static function (array $left, array $right): int {
612                    return strnatcasecmp(
613                        (string) $left['name'],
614                        (string) $right['name']
615                    );
616                }
617            );
618
619            $targetScopeOptions[] = [
620                'name' => (string) $department->name,
621                'options' => $departmentScopeOptions,
622            ];
623        }
624
625        return $targetScopeOptions;
626    }
627
628    private function isEligibleCopyTarget(
629        Scope $scope,
630        Scope $selectedSourceScope
631    ): bool {
632        if (
633            (int) $scope->getId()
634            === (int) $selectedSourceScope->getId()
635        ) {
636            return false;
637        }
638
639        $scopeProviderId = $this->readProviderId($scope);
640        $sourceProviderId = $this->readProviderId(
641            $selectedSourceScope
642        );
643
644        if ($scopeProviderId === '' || $sourceProviderId === '') {
645            return false;
646        }
647
648        return $scopeProviderId !== $sourceProviderId;
649    }
650
651    private function formatScopeName(Scope $scope): string
652    {
653        $contactName = trim((string) $scope->getName());
654        $shortName = trim((string) $scope->getShortName());
655
656        if ($contactName !== '') {
657            return trim($contactName . ' ' . $shortName);
658        }
659
660        return sprintf(
661            '(Standort gelöscht, ehemals: %s-%s)',
662            (string) ($scope->provider['source'] ?? ''),
663            $this->readProviderId($scope)
664        );
665    }
666}