Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.56% covered (success)
80.56%
373 / 463
45.45% covered (danger)
45.45%
5 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
FaqController
80.56% covered (success)
80.56%
373 / 463
45.45% covered (danger)
45.45%
5 / 11
124.84
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create
68.63% covered (warning)
68.63%
105 / 153
0.00% covered (danger)
0.00%
0 / 1
28.00
 update
78.57% covered (warning)
78.57%
110 / 140
0.00% covered (danger)
0.00%
0 / 1
28.21
 listPermissions
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 listByCategory
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 activate
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
7
 sticky
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
7
 delete
88.24% covered (success)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
3.01
 search
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
3
 saveOrderOfStickyFaqs
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
4.00
 import
81.40% covered (success)
81.40%
35 / 43
0.00% covered (danger)
0.00%
0 / 1
11.78
1<?php
2
3/**
4 * The Admin FAQ Controller
5 * This Source Code Form is subject to the terms of the Mozilla Public License,
6 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
7 * obtain one at https://mozilla.org/MPL/2.0/.
8 *
9 * @package   phpMyFAQ
10 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
11 * @copyright 2023-2026 phpMyFAQ Team
12 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
13 * @link      https://www.phpmyfaq.de
14 * @since     2023-10-28
15 */
16
17declare(strict_types=1);
18
19namespace phpMyFAQ\Controller\Administration\Api;
20
21use DateTime;
22use Exception;
23use phpMyFAQ\Administration\AdminLog;
24use phpMyFAQ\Administration\Changelog;
25use phpMyFAQ\Administration\Faq as FaqAdministration;
26use phpMyFAQ\Administration\Revision;
27use phpMyFAQ\Attachment\AttachmentException;
28use phpMyFAQ\Attachment\Filesystem\File\FileException;
29use phpMyFAQ\Category;
30use phpMyFAQ\Category\Permission as CategoryPermission;
31use phpMyFAQ\Category\Relation;
32use phpMyFAQ\Entity\FaqEntity;
33use phpMyFAQ\Entity\SeoEntity;
34use phpMyFAQ\Enums\AdminLogType;
35use phpMyFAQ\Enums\PermissionType;
36use phpMyFAQ\Enums\SeoType;
37use phpMyFAQ\Faq;
38use phpMyFAQ\Faq\Import;
39use phpMyFAQ\Faq\Permission as FaqPermission;
40use phpMyFAQ\Filter;
41use phpMyFAQ\Helper\CategoryHelper;
42use phpMyFAQ\Helper\SearchHelper;
43use phpMyFAQ\Instance\Search\Elasticsearch;
44use phpMyFAQ\Instance\Search\OpenSearch;
45use phpMyFAQ\Language;
46use phpMyFAQ\Link;
47use phpMyFAQ\Link\Util\TitleSlugifier;
48use phpMyFAQ\Notification;
49use phpMyFAQ\Push\WebPushService;
50use phpMyFAQ\Question;
51use phpMyFAQ\Search;
52use phpMyFAQ\Search\SearchResultSet;
53use phpMyFAQ\Seo;
54use phpMyFAQ\Session\Token;
55use phpMyFAQ\Tags;
56use phpMyFAQ\Translation;
57use phpMyFAQ\User\CurrentUser;
58use phpMyFAQ\Visits;
59use stdClass;
60use Symfony\Component\HttpFoundation\File\UploadedFile;
61use Symfony\Component\HttpFoundation\JsonResponse;
62use Symfony\Component\HttpFoundation\Request;
63use Symfony\Component\HttpFoundation\Response;
64use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
65use Symfony\Component\Routing\Attribute\Route;
66
67/* @mago-expect lint:cyclomatic-complexity - the create/update endpoints validate every field inline; split planned with the admin API rework */
68final class FaqController extends AbstractAdministrationApiController
69{
70    /* @mago-expect lint:excessive-parameter-list - the endpoint dependencies are injected explicitly; a service split is planned with the admin API rework */
71    public function __construct(
72        private readonly Faq $faq,
73        private readonly FaqAdministration $adminFaq,
74        private readonly Tags $tags,
75        private readonly Notification $notification,
76        private readonly Changelog $changelog,
77        private readonly Visits $visits,
78        private readonly Seo $seo,
79        private readonly Question $question,
80        private readonly AdminLog $logging,
81        private readonly WebPushService $webPushService,
82    ) {
83        parent::__construct();
84    }
85
86    /**
87     * @throws \phpMyFAQ\Core\Exception
88     * @throws Exception
89     */
90    /* @mago-expect lint:halstead - validates and persists the full FAQ payload in one endpoint */
91    #[Route(path: 'faq/create', name: 'admin.api.faq.create', methods: ['POST'])]
92    public function create(Request $request): JsonResponse
93    {
94        $this->userHasPermission(PermissionType::FAQ_ADD);
95
96        [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
97
98        $this->tags->setBypassPermissionCheck();
99        $categoryPermission = new CategoryPermission($this->configuration);
100        $faqPermission = new FaqPermission($this->configuration);
101
102        $category = new Category($this->configuration, [], withPermission: false);
103        $category->setUser($currentUser);
104        $category->setGroups($currentGroups);
105
106        $data = $this->getJsonObject($request)->data ?? null;
107        if (!$data instanceof stdClass) {
108            return $this->json(['error' => 'The request body must contain a data object.'], Response::HTTP_BAD_REQUEST);
109        }
110
111        if (!Token::getInstance($this->session)->verifyToken(
112            page: 'pmf-csrf-token',
113            requestToken: (string) ($data->{'pmf-csrf-token'} ?? ''),
114        )) {
115            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
116        }
117
118        // Collect FAQ data
119        $question = Filter::filterVar($data->question ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
120
121        $rawCategories = $data->{'categories[]'} ?? null;
122        $categories = is_array($rawCategories)
123            ? array_map(static fn(mixed $categoryId): int => (int) $categoryId, $rawCategories)
124            : [(int) Filter::filterVar($rawCategories, FILTER_VALIDATE_INT)];
125
126        $language = Filter::filterVar($data->lang ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
127        $tags = Filter::filterVar($data->tags ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
128        $active = Filter::filterVar($data->active ?? 'no', FILTER_SANITIZE_SPECIAL_CHARS, 'no');
129        $sticky = Filter::filterVar($data->sticky ?? 'no', FILTER_SANITIZE_SPECIAL_CHARS, 'no');
130        $content = Filter::filterVar($data->answer ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
131        $keywords = Filter::filterVar($data->keywords ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
132        $author = Filter::filterVar($data->author ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
133        $email = (string) Filter::filterEmail($data->email ?? '', default: '');
134        $comment = Filter::filterVar($data->comment ?? 'n', FILTER_SANITIZE_SPECIAL_CHARS, 'n');
135        $changed = Filter::filterVar($data->changed ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
136        $notes = Filter::filterVar($data->notes ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
137
138        $serpTitle = Filter::filterVar($data->serpTitle ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
139        $serpDescription = Filter::filterVar($data->serpDescription ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
140
141        // Permissions
142        $permissions = $faqPermission->createPermissionArray();
143
144        $this->adminLog->log($this->currentUser, AdminLogType::FAQ_ADD->value);
145
146        if ($question === '' && $content === '') {
147            return $this->json(['error' => Translation::get(key: 'msgNoQuestionAndAnswer')], Response::HTTP_CONFLICT);
148        }
149
150        $faqData = new FaqEntity();
151        $faqData
152            ->setLanguage($language)
153            ->setActive($active === 'yes')
154            ->setSticky($sticky !== 'no')
155            ->setQuestion(Filter::removeAttributes(html_entity_decode(
156                $question,
157                ENT_QUOTES | ENT_HTML5,
158                encoding: 'UTF-8',
159            )))
160            ->setAnswer(Filter::removeAttributes(html_entity_decode(
161                $content,
162                ENT_QUOTES | ENT_HTML5,
163                encoding: 'UTF-8',
164            )))
165            ->setKeywords($keywords)
166            ->setAuthor($author)
167            ->setEmail($email)
168            ->setComment($comment === 'y')
169            ->setCreatedDate(new DateTime())
170            ->setNotes(Filter::removeAttributes($notes));
171
172        // Add a new record and get that ID
173        $faqData = $this->faq->create($faqData);
174
175        $faqId = $faqData->getId();
176        if ($faqId) {
177            // Create ChangeLog entry
178            $this->changelog->add($faqId, $this->currentUser->getUserId(), nl2br($changed), $faqData->getLanguage());
179
180            // Create the visit entry
181            $this->visits->logViews($faqId);
182
183            $categoryRelation = new Relation($this->configuration, $category);
184            $categoryRelation->add($categories, $faqId, $faqData->getLanguage());
185
186            // Insert the tags
187            if ($tags !== '') {
188                $this->tags->create($faqId, explode(separator: ',', string: trim($tags)));
189            }
190
191            // Add user permissions
192            $faqPermission->add(FaqPermission::USER, $faqId, $permissions['restricted_user']);
193            $categoryPermission->add(CategoryPermission::USER, $categories, $permissions['restricted_user']);
194            // Add group permission
195            if ($this->configuration->get(item: 'security.permLevel') !== 'basic') {
196                $faqPermission->add(FaqPermission::GROUP, $faqId, $permissions['restricted_groups']);
197                $categoryPermission->add(CategoryPermission::GROUP, $categories, $permissions['restricted_groups']);
198            }
199
200            // Add the SEO data
201            $seoEntity = new SeoEntity();
202            $seoEntity
203                ->setSeoType(SeoType::FAQ)
204                ->setReferenceId($faqId)
205                ->setReferenceLanguage($faqData->getLanguage())
206                ->setTitle($serpTitle)
207                ->setDescription($serpDescription);
208            $this->seo->create($seoEntity);
209
210            // Open question answered
211            $openQuestionId = (int) Filter::filterVar($data->openQuestionId ?? null, FILTER_VALIDATE_INT);
212            if (0 !== $openQuestionId) {
213                if ($this->configuration->get(item: 'records.enableDeleteQuestion')) {
214                    // deletes question
215                    $this->question->delete($openQuestionId);
216                }
217
218                if (!$this->configuration->get(item: 'records.enableDeleteQuestion')) {
219                    // adds this faq record id to the related open question
220                    $this->question->updateQuestionAnswer($openQuestionId, $faqId, $categories[0] ?? 0);
221                }
222
223                $url = sprintf(
224                    '%scontent/%d/%d/%s/%s.html',
225                    $this->configuration->getDefaultUrl(),
226                    $categories[0] ?? 0,
227                    $faqId,
228                    $faqData->getLanguage(),
229                    TitleSlugifier::slug($faqData->getQuestion()),
230                );
231                $oLink = new Link($url, $this->configuration);
232
233                // notify the user who added the question
234                try {
235                    $notifyEmail = (string) Filter::filterVar($data->notifyEmail ?? '', FILTER_SANITIZE_EMAIL, '');
236                    $notifyUser = Filter::filterVar($data->notifyUser ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
237                    $this->notification->sendOpenQuestionAnswered($notifyEmail, $notifyUser, $oLink->toString());
238                } catch (Exception|TransportExceptionInterface $e) {
239                    $this->configuration
240                        ->getLogger()
241                        ->error('Send open question answered notification failed: ' . $e->getMessage());
242                }
243            }
244
245            // Let the admin and the category owners be informed by email of this new entry
246            try {
247                $categoryHelper = new CategoryHelper();
248                $categoryHelper->setCategory($category)->setConfiguration($this->configuration);
249                $moderators = $categoryHelper->getModerators($categories);
250                $this->notification->sendNewFaqAdded($moderators, $faqData);
251            } catch (Exception|TransportExceptionInterface $e) {
252                $this->configuration->getLogger()->error('Send moderator notification failed: ' . $e->getMessage());
253            }
254
255            // If Elasticsearch is enabled, index the new FAQ document
256            if ($this->configuration->get(item: 'search.enableElasticsearch')) {
257                $elasticsearch = new Elasticsearch($this->configuration);
258                $elasticsearch->index([
259                    'id' => $faqId,
260                    'lang' => $faqData->getLanguage(),
261                    'solution_id' => $faqData->getSolutionId(),
262                    'question' => $faqData->getQuestion(),
263                    'answer' => $faqData->getAnswer(),
264                    'keywords' => $faqData->getKeywords(),
265                    'category_id' => $categories[0] ?? 0,
266                ]);
267            }
268
269            // If OpenSearch is enabled, index the new FAQ document
270            if ($this->configuration->get(item: 'search.enableOpenSearch')) {
271                $openSearch = new OpenSearch($this->configuration);
272                $openSearch->index([
273                    'id' => $faqId,
274                    'lang' => $faqData->getLanguage(),
275                    'solution_id' => $faqData->getSolutionId(),
276                    'question' => $faqData->getQuestion(),
277                    'answer' => $faqData->getAnswer(),
278                    'keywords' => $faqData->getKeywords(),
279                    'category_id' => $categories[0] ?? 0,
280                ]);
281            }
282
283            // Send Web Push notification for new active FAQs.
284            // This is done here (not in Notification::sendNewFaqAdded) to provide
285            // the public FAQ URL, which is more useful for end-users.
286            if ($faqData->isActive()) {
287                try {
288                    $faqUrl = sprintf(
289                        '%scontent/%d/%d/%s/%s.html',
290                        $this->configuration->getDefaultUrl(),
291                        $categories[0] ?? 0,
292                        $faqId,
293                        $faqData->getLanguage(),
294                        TitleSlugifier::slug($faqData->getQuestion()),
295                    );
296                    $this->webPushService->sendToAll(
297                        Translation::getString('msgPushNewFaq'),
298                        $faqData->getQuestion(),
299                        $faqUrl,
300                        'new-faq-' . $faqId,
301                    );
302                } catch (\Throwable $e) {
303                    $this->configuration->getLogger()->error('Send web push notification failed: ' . $e->getMessage());
304                }
305            }
306
307            return $this->json([
308                'success' => Translation::get(key: 'ad_entry_savedsuc'),
309                'data' => $faqData->getJson(),
310            ], Response::HTTP_OK);
311        }
312
313        return $this->json(['error' => Translation::get(key: 'ad_entry_savedfail')], Response::HTTP_BAD_REQUEST);
314    }
315
316    /**
317     * @throws \phpMyFAQ\Core\Exception
318     * @throws Exception
319     */
320    /* @mago-expect lint:halstead - validates and persists the full FAQ payload in one endpoint */
321    #[Route(path: 'faq/update', name: 'admin.api.faq.update', methods: ['POST', 'PUT'])]
322    public function update(Request $request): JsonResponse
323    {
324        $this->userHasPermission(PermissionType::FAQ_EDIT);
325
326        [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
327
328        $this->tags->setBypassPermissionCheck();
329        $faqPermission = new FaqPermission($this->configuration);
330
331        $category = new Category($this->configuration, [], withPermission: false);
332        $category->setUser($currentUser);
333        $category->setGroups($currentGroups);
334
335        $data = $this->getJsonObject($request)->data ?? null;
336        if (!$data instanceof stdClass) {
337            return $this->json(['error' => 'The request body must contain a data object.'], Response::HTTP_BAD_REQUEST);
338        }
339
340        if (!Token::getInstance($this->session)->verifyToken(
341            page: 'pmf-csrf-token',
342            requestToken: (string) ($data->{'pmf-csrf-token'} ?? ''),
343        )) {
344            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
345        }
346
347        // Collect FAQ data
348        $faqId = (int) Filter::filterVar($data->faqId ?? null, FILTER_VALIDATE_INT);
349        $solutionId = (int) Filter::filterVar($data->solutionId ?? null, FILTER_VALIDATE_INT);
350        $revisionId = (int) Filter::filterVar($data->revisionId ?? null, FILTER_VALIDATE_INT);
351        $question = Filter::filterVar($data->question ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
352        $rawCategories = $data->{'categories[]'} ?? null;
353        $categories = is_array($rawCategories)
354            ? array_map(static fn(mixed $categoryId): int => (int) $categoryId, $rawCategories)
355            : [(int) Filter::filterVar($rawCategories, FILTER_VALIDATE_INT)];
356
357        $faqLang = Filter::filterVar($data->lang ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
358        $tags = Filter::filterVar($data->tags ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
359        $active = Filter::filterVar($data->active ?? 'no', FILTER_SANITIZE_SPECIAL_CHARS, 'no');
360        $sticky = Filter::filterVar($data->sticky ?? 'no', FILTER_SANITIZE_SPECIAL_CHARS, 'no');
361        $content = Filter::filterVar($data->answer ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
362        $keywords = Filter::filterVar($data->keywords ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
363        $author = Filter::filterVar($data->author ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
364        $email = (string) Filter::filterEmail($data->email ?? '', default: '');
365        $comment = Filter::filterVar($data->comment ?? 'n', FILTER_SANITIZE_SPECIAL_CHARS, 'n');
366        $changed = Filter::filterVar($data->changed ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
367        $date = Filter::filterVar($data->date ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
368        $notes = Filter::filterVar($data->notes ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
369        $revision = Filter::filterVar($data->revision ?? 'no', FILTER_SANITIZE_SPECIAL_CHARS, 'no');
370        $recordDateHandling = Filter::filterVar($data->recordDateHandling ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
371
372        $serpTitle = Filter::filterVar($data->serpTitle ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
373        $serpDescription = Filter::filterVar($data->serpDescription ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
374
375        if ($question === '' && $content === '') {
376            return $this->json(['error' => Translation::get(key: 'msgNoQuestionAndAnswer')], Response::HTTP_CONFLICT);
377        }
378
379        // Permissions
380        $permissions = $faqPermission->createPermissionArray();
381
382        $this->logging->log($this->currentUser, AdminLogType::FAQ_EDIT->value . ':' . $faqId);
383        if ($active === 'yes') {
384            $this->logging->log($this->currentUser, AdminLogType::FAQ_PUBLISH->value . ':' . $faqId);
385        }
386
387        if ('yes' === $revision && true === $this->configuration->get(item: 'records.enableAutoRevisions')) {
388            $faqRevision = new Revision($this->configuration);
389            $faqRevision->create($faqId, $faqLang);
390            ++$revisionId;
391        }
392
393        $faqData = new FaqEntity();
394        $faqData
395            ->setId($faqId)
396            ->setLanguage($faqLang)
397            ->setRevisionId($revisionId)
398            ->setSolutionId($solutionId)
399            ->setActive($active === 'yes')
400            ->setSticky($sticky !== 'no')
401            ->setQuestion(Filter::removeAttributes(html_entity_decode(
402                $question,
403                ENT_QUOTES | ENT_HTML5,
404                encoding: 'UTF-8',
405            )))
406            ->setAnswer(Filter::removeAttributes(html_entity_decode(
407                $content,
408                ENT_QUOTES | ENT_HTML5,
409                encoding: 'UTF-8',
410            )))
411            ->setKeywords($keywords)
412            ->setAuthor($author)
413            ->setEmail($email)
414            ->setComment($comment === 'y')
415            ->setNotes(Filter::removeAttributes($notes));
416
417        switch ($recordDateHandling) {
418            case 'updateDate':
419                $faqData->setUpdatedDate(new DateTime());
420                break;
421            case 'manualDate':
422                $faqData->setUpdatedDate(new DateTime($date));
423                break;
424            case 'keepDate':
425                break;
426        }
427
428        // Create ChangeLog entry
429        $this->changelog->add($faqId, $this->currentUser->getUserId(), $changed, $faqLang, $revisionId);
430
431        // Create the visit entry
432        $this->visits->logViews($faqId);
433
434        // save or update the FAQ record
435        if ($this->faq->hasTranslation($faqId, $faqLang)) {
436            $faqData = $this->faq->update($faqData);
437        }
438
439        if (!$this->faq->hasTranslation($faqId, $faqLang)) {
440            $faqData = $this->faq->create($faqData);
441        }
442
443        $faqId = $faqData->getId() ?? $faqId;
444
445        $categoryRelation = new Relation($this->configuration, $category);
446        $categoryRelation->deleteByFaq($faqId, $faqLang);
447        $categoryRelation->add($categories, $faqId, $faqLang);
448
449        // Insert the tags
450        if ($tags !== '') {
451            $this->tags->create($faqId, explode(separator: ',', string: trim($tags)));
452        }
453
454        if ($tags === '') {
455            $this->tags->deleteByRecordId($faqId);
456        }
457
458        // Update the SEO data
459        $seoEntity = new SeoEntity();
460        $seoEntity
461            ->setSeoType(SeoType::FAQ)
462            ->setReferenceId($faqId)
463            ->setReferenceLanguage($faqLang)
464            ->setTitle($serpTitle)
465            ->setDescription($serpDescription);
466
467        if ($this->seo->get($seoEntity)->getId() === null) {
468            $seoEntity->setTitle($serpTitle)->setDescription($serpDescription);
469            $this->seo->create($seoEntity);
470        }
471
472        if ($this->seo->get($seoEntity)->getId() !== null) {
473            $seoEntity->setTitle($serpTitle)->setDescription($serpDescription);
474            $this->seo->update($seoEntity);
475        }
476
477        // Add user permissions
478        $faqPermission->delete(FaqPermission::USER, $faqId);
479        $faqPermission->add(FaqPermission::USER, $faqId, $permissions['restricted_user']);
480        // Add group permission
481        if ($this->configuration->get(item: 'security.permLevel') !== 'basic') {
482            $faqPermission->delete(FaqPermission::GROUP, $faqId);
483            $faqPermission->add(FaqPermission::GROUP, $faqId, $permissions['restricted_groups']);
484        }
485
486        // If Elasticsearch is enabled, update an active or delete inactive FAQ document
487        if ($this->configuration->get(item: 'search.enableElasticsearch')) {
488            $elasticsearch = new Elasticsearch($this->configuration);
489            if ('yes' === $active) {
490                $elasticsearch->update([
491                    'id' => $faqId,
492                    'lang' => $faqLang,
493                    'solution_id' => $faqData->getSolutionId(),
494                    'question' => $faqData->getQuestion(),
495                    'answer' => $faqData->getAnswer(),
496                    'keywords' => $faqData->getKeywords(),
497                    'category_id' => $categories[0] ?? 0,
498                ]);
499            }
500        }
501
502        // If OpenSearch is enabled, update an active or delete an inactive FAQ document
503        if ($this->configuration->get(item: 'search.enableOpenSearch')) {
504            $openSearch = new OpenSearch($this->configuration);
505            if ('yes' === $active) {
506                $openSearch->update([
507                    'id' => $faqId,
508                    'lang' => $faqLang,
509                    'solution_id' => $faqData->getSolutionId(),
510                    'question' => $faqData->getQuestion(),
511                    'answer' => $faqData->getAnswer(),
512                    'keywords' => $faqData->getKeywords(),
513                    'category_id' => $categories[0] ?? 0,
514                ]);
515            }
516        }
517
518        return $this->json([
519            'success' => Translation::get(key: 'ad_entry_savedsuc'),
520            'data' => $faqData->getJson(),
521        ], Response::HTTP_OK);
522    }
523
524    /**
525     * @throws Exception
526     */
527    #[Route(path: 'faq/permissions', name: 'admin.api.faq.permissions', methods: ['GET'])]
528    public function listPermissions(Request $request): JsonResponse
529    {
530        $this->userHasPermission(PermissionType::FAQ_EDIT);
531
532        $faqId = (int) Filter::filterVar($request->attributes->get(key: 'faqId'), FILTER_VALIDATE_INT);
533
534        $faqPermission = new FaqPermission($this->configuration);
535
536        return $this->json([
537            'user' => $faqPermission->get(FaqPermission::USER, $faqId),
538            'group' => $faqPermission->get(FaqPermission::GROUP, $faqId),
539        ], Response::HTTP_OK);
540    }
541
542    /**
543     * @throws Exception
544     */
545    #[Route(path: 'faqs/{categoryId}/{language}', name: 'admin.api.faqs', methods: ['GET'])]
546    public function listByCategory(Request $request): JsonResponse
547    {
548        $this->userHasPermission(PermissionType::FAQ_EDIT);
549
550        $categoryId = (int) Filter::filterVar($request->attributes->get(key: 'categoryId'), FILTER_VALIDATE_INT);
551        $language = Filter::filterVar($request->attributes->get(key: 'language'), FILTER_SANITIZE_SPECIAL_CHARS, '');
552
553        $onlyInactive = Filter::filterVar(
554            $request->query->get(key: 'only-inactive'),
555            FILTER_VALIDATE_BOOLEAN,
556            default: false,
557        );
558        $onlyNew = Filter::filterVar($request->query->get(key: 'only-new'), FILTER_VALIDATE_BOOLEAN, default: false);
559
560        $faq = new FaqAdministration($this->configuration);
561        $faq->setLanguage($language);
562
563        return $this->json([
564            'faqs' => $faq->getAllFaqsByCategory($categoryId, $onlyInactive, $onlyNew),
565            'isAllowedToTranslate' => $this->currentUser?->perm->hasPermission(
566                $this->currentUser->getUserId(),
567                PermissionType::FAQ_TRANSLATE->value,
568            ),
569        ], Response::HTTP_OK);
570    }
571
572    /**
573     * @throws Exception
574     */
575    #[Route(path: 'faq/activate', name: 'admin.api.faq.activate', methods: ['POST'])]
576    public function activate(Request $request): JsonResponse
577    {
578        $this->userHasPermission(PermissionType::FAQ_APPROVE);
579
580        $data = $this->getJsonObject($request);
581
582        $rawFaqIds = $data->faqIds ?? null;
583        $faqIds = is_array($rawFaqIds) ? array_map(static fn(mixed $faqId): int => (int) $faqId, $rawFaqIds) : [];
584        $faqLanguage = Filter::filterVar($data->faqLanguage ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
585        $checked = Filter::filterVar($data->checked ?? false, FILTER_VALIDATE_BOOLEAN, false);
586
587        if (!Token::getInstance($this->session)->verifyToken(
588            page: 'pmf-csrf-token',
589            requestToken: (string) ($data->csrf ?? ''),
590        )) {
591            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
592        }
593
594        if ($faqIds !== []) {
595            $faq = new FaqAdministration($this->configuration);
596            $success = false;
597
598            foreach ($faqIds as $faqId) {
599                if (!Language::isASupportedLanguage($faqLanguage)) {
600                    continue;
601                }
602
603                $success = $faq->updateRecordFlag($faqId, $faqLanguage, $checked, type: 'active');
604            }
605
606            if ($success) {
607                $this->adminLog->log($this->currentUser, AdminLogType::FAQ_EDIT->value);
608                return $this->json(['success' => Translation::get(key: 'ad_entry_savedsuc')], Response::HTTP_OK);
609            }
610
611            return $this->json(['error' => Translation::get(key: 'ad_entry_savedfail')], Response::HTTP_BAD_REQUEST);
612        }
613
614        return $this->json(['error' => 'No FAQ IDs provided.'], Response::HTTP_BAD_REQUEST);
615    }
616
617    /**
618     * @throws Exception
619     */
620    #[Route(path: 'faq/sticky', name: 'admin.api.faq.sticky', methods: ['POST'])]
621    public function sticky(Request $request): JsonResponse
622    {
623        $this->userHasPermission(PermissionType::FAQ_EDIT);
624
625        $data = $this->getJsonObject($request);
626
627        $rawFaqIds = $data->faqIds ?? null;
628        $faqIds = is_array($rawFaqIds) ? array_map(static fn(mixed $faqId): int => (int) $faqId, $rawFaqIds) : [];
629        $faqLanguage = Filter::filterVar($data->faqLanguage ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
630        $checked = Filter::filterVar($data->checked ?? false, FILTER_VALIDATE_BOOLEAN, false);
631
632        if (!Token::getInstance($this->session)->verifyToken(
633            page: 'pmf-csrf-token',
634            requestToken: (string) ($data->csrf ?? ''),
635        )) {
636            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
637        }
638
639        if ($faqIds !== []) {
640            $faq = new FaqAdministration($this->configuration);
641            $success = false;
642
643            foreach ($faqIds as $faqId) {
644                if (!Language::isASupportedLanguage($faqLanguage)) {
645                    continue;
646                }
647
648                $success = $faq->updateRecordFlag($faqId, $faqLanguage, $checked, type: 'sticky');
649            }
650
651            if ($success) {
652                return $this->json(['success' => Translation::get(key: 'ad_entry_savedsuc')], Response::HTTP_OK);
653            }
654
655            return $this->json(['error' => Translation::get(key: 'ad_entry_savedfail')], Response::HTTP_BAD_REQUEST);
656        }
657
658        return $this->json(['error' => 'No FAQ IDs provided.'], Response::HTTP_BAD_REQUEST);
659    }
660
661    /**
662     * @throws Exception
663     */
664    #[Route(path: 'faq/delete', name: 'admin.api.faq.delete', methods: ['DELETE'])]
665    public function delete(Request $request): JsonResponse
666    {
667        $this->userHasPermission(PermissionType::FAQ_DELETE);
668
669        $faq = new Faq($this->configuration);
670
671        $data = $this->getJsonObject($request);
672
673        $faqId = (int) Filter::filterVar($data->faqId ?? null, FILTER_VALIDATE_INT);
674        $faqLanguage = Filter::filterVar($data->faqLanguage ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
675
676        if (!Token::getInstance($this->session)->verifyToken(
677            page: 'pmf-csrf-token',
678            requestToken: (string) ($data->csrf ?? ''),
679        )) {
680            return $this->json([
681                'error' => 'CSRF Token - ' . Translation::getString(key: 'msgNoPermission'),
682            ], Response::HTTP_UNAUTHORIZED);
683        }
684
685        $this->adminLog->log($this->currentUser, AdminLogType::FAQ_DELETE->value . ':' . $faqId);
686
687        try {
688            $faq->delete($faqId, $faqLanguage);
689        } catch (FileException|AttachmentException $e) {
690            return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
691        }
692
693        return $this->json(['success' => Translation::get(key: 'ad_entry_delsuc')], Response::HTTP_OK);
694    }
695
696    /**
697     * @throws Exception
698     */
699    #[Route(path: 'faq/search', name: 'admin.api.faq.search', methods: ['POST'])]
700    public function search(Request $request): JsonResponse
701    {
702        $this->userHasPermission(PermissionType::FAQ_EDIT);
703
704        $data = $this->getJsonObject($request);
705
706        if (!Token::getInstance($this->session)->verifyToken(
707            page: 'pmf-csrf-token',
708            requestToken: (string) ($data->csrf ?? ''),
709        )) {
710            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
711        }
712
713        $faqPermission = new FaqPermission($this->configuration);
714        $faqSearch = new Search($this->configuration);
715        $faqSearch->setCategory(new Category($this->configuration));
716
717        $searchResultSet = new SearchResultSet($this->currentUser, $faqPermission, $this->configuration);
718        $searchString = Filter::filterVar($data->search ?? null, FILTER_SANITIZE_SPECIAL_CHARS);
719
720        if (is_string($searchString)) {
721            $searchResult = $faqSearch->search($searchString, allLanguages: false);
722
723            $searchResultSet->reviewResultSet($searchResult);
724
725            $searchHelper = new SearchHelper($this->configuration);
726            $searchHelper->setSearchTerm($searchString);
727
728            return $this->json([
729                'success' => $searchHelper->renderAdminSuggestionResult($searchResultSet),
730            ], Response::HTTP_OK);
731        }
732
733        return $this->json(['error' => 'No search string provided.'], Response::HTTP_BAD_REQUEST);
734    }
735
736    /**
737     * @throws Exception
738     */
739    #[Route(path: 'faqs/sticky/order', name: 'admin.api.faqs.sticky.order', methods: ['POST'])]
740    public function saveOrderOfStickyFaqs(Request $request): JsonResponse
741    {
742        $this->userHasPermission(PermissionType::FAQ_EDIT);
743
744        [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
745
746        $data = $this->getJsonObject($request);
747
748        if (!Token::getInstance($this->session)->verifyToken(
749            page: 'order-stickyfaqs',
750            requestToken: (string) ($data->csrf ?? ''),
751        )) {
752            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
753        }
754
755        $faqIds = $data->faqIds ?? null;
756        if (!is_array($faqIds)) {
757            return $this->json(['error' => 'No FAQ IDs provided.'], Response::HTTP_BAD_REQUEST);
758        }
759
760        if (!$this->adminFaq->setStickyFaqOrder(
761            array_values(array_map(intval(...), $faqIds)),
762            $currentUser,
763            $currentGroups,
764        )) {
765            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
766        }
767
768        return $this->json(['success' => Translation::get(key: 'ad_categ_save_order')], Response::HTTP_OK);
769    }
770
771    /**
772     * @throws Exception
773     */
774    #[Route(path: 'faq/import', name: 'admin.api.faq.import', methods: ['POST'])]
775    public function import(Request $request): JsonResponse
776    {
777        $this->userHasPermission(PermissionType::FAQ_ADD);
778
779        $file = $request->files->get(key: 'file');
780        if (!$file instanceof UploadedFile) {
781            return $this->json(['error' => 'Bad request: There is no file submitted.'], Response::HTTP_BAD_REQUEST);
782        }
783
784        if (!Token::getInstance($this->session)->verifyToken(
785            page: 'importfaqs',
786            requestToken: (string) $request->request->get(key: 'csrf'),
787        )) {
788            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
789        }
790
791        $faqImport = new Import($this->configuration);
792
793        $errors = [];
794
795        if (0 === $file->getError() && $faqImport->isCSVFile($file)) {
796            $handle = fopen(filename: (string) $file->getRealPath(), mode: 'r');
797            if ($handle === false) {
798                return $this->json(['error' => 'The uploaded file could not be read.'], Response::HTTP_BAD_REQUEST);
799            }
800
801            $csvData = $faqImport->parseCSV($handle);
802
803            if (!$faqImport->validateCSV($csvData)) {
804                $result = [
805                    'storedAll' => false,
806                    'error' => Translation::get(key: 'msgCSVFileNotValidated'),
807                ];
808                return $this->json($result, Response::HTTP_BAD_REQUEST);
809            }
810
811            foreach ($csvData as $index => $record) {
812                try {
813                    if (!$faqImport->import($record)) {
814                        $errors[] = sprintf('Row %d: import failed.', $index + 1);
815                    }
816                } catch (\Throwable $throwable) {
817                    $errors[] = sprintf('Row %d: %s', $index + 1, $throwable->getMessage());
818                }
819            }
820
821            if ($errors === []) {
822                $result = [
823                    'storedAll' => true,
824                    'success' => Translation::get(key: 'msgImportSuccessful'),
825                ];
826                return $this->json($result, Response::HTTP_OK);
827            }
828
829            $result = [
830                'storedAll' => false,
831                'messages' => $errors,
832            ];
833
834            return $this->json($result, Response::HTTP_BAD_REQUEST);
835        }
836
837        $result = [
838            'storedAll' => false,
839            'error' => 'Bad request: The file is not a CSV file.',
840        ];
841
842        return $this->json($result, Response::HTTP_BAD_REQUEST);
843    }
844}