Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.97% covered (success)
91.97%
229 / 249
64.29% covered (warning)
64.29%
9 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
FaqController
91.97% covered (success)
91.97%
229 / 249
64.29% covered (warning)
64.29%
9 / 14
48.14
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
 getByCategoryId
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getById
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 getByTagId
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 getPopular
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getLatest
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 getTrending
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 getSticky
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 list
83.33% covered (success)
83.33%
35 / 42
0.00% covered (danger)
0.00%
0 / 1
7.23
 create
90.91% covered (success)
90.91%
60 / 66
0.00% covered (danger)
0.00%
0 / 1
7.04
 update
90.00% covered (success)
90.00%
45 / 50
0.00% covered (danger)
0.00%
0 / 1
3.01
 applyRequestLanguage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 resolveRequestLanguage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 withRequestLanguage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * The Faq Controller for the REST API
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2024-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2024-02-26
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Api;
21
22use Exception;
23use League\CommonMark\Exception\CommonMarkException;
24use OpenApi\Attributes as OA;
25use phpMyFAQ\Category;
26use phpMyFAQ\Entity\FaqEntity;
27use phpMyFAQ\Enums\PermissionType;
28use phpMyFAQ\Faq;
29use phpMyFAQ\Faq\MetaData as FaqMetaData;
30use phpMyFAQ\Faq\Statistics as FaqStatistics;
31use phpMyFAQ\Filter;
32use phpMyFAQ\Language;
33use phpMyFAQ\Tags;
34use phpMyFAQ\User\CurrentUser;
35use stdClass;
36use Symfony\Component\HttpFoundation\JsonResponse;
37use Symfony\Component\HttpFoundation\Request;
38use Symfony\Component\HttpFoundation\Response;
39use Symfony\Component\Routing\Attribute\Route;
40
41final class FaqController extends AbstractApiController
42{
43    public function __construct(
44        private readonly Faq $faq,
45        private readonly Tags $tags,
46        private readonly FaqStatistics $faqStatistics,
47        private readonly FaqMetaData $faqMetaData,
48        private readonly Language $language,
49    ) {
50        parent::__construct();
51    }
52
53    /**
54     * @throws \phpMyFAQ\Core\Exception|Exception
55     */
56    #[OA\Get(
57        path: '/api/v4.0/faqs/{categoryId}',
58        operationId: 'getByCategoryId',
59        description: 'This endpoint returns all the FAQs with a preview of the answer for the given category ID and '
60        . 'the language provided by "Accept-Language".',
61        tags: ['Public Endpoints'],
62    )]
63    #[OA\Header(
64        header: 'Accept-Language',
65        description: 'The language code for the FAQ.',
66        schema: new OA\Schema(type: 'string'),
67    )]
68    #[OA\Parameter(
69        name: 'categoryId',
70        description: 'The category ID.',
71        in: 'path',
72        required: true,
73        schema: new OA\Schema(type: 'integer'),
74    )]
75    #[OA\Response(
76        response: 200,
77        description: 'If the category returns at least one FAQ.',
78        content: new OA\JsonContent(example: [[
79            'record_id' => 1,
80            'record_lang' => 'en',
81            'category_id' => 1,
82            'record_title' => 'Is there life after death?',
83            'record_preview' => 'Maybe!',
84            'record_link' => '/phpmyfaq/content/1/1/en/is-there-life-after-death.html',
85            'record_updated' => '20191010175452',
86            'visits' => 3,
87            'record_created' => '2018-09-03T21:30:17+02:00',
88        ]]),
89    )]
90    #[OA\Response(response: 500, description: 'If fetching the FAQs fails.', content: new OA\JsonContent(example: [
91        'error' => 'Error message',
92    ]))]
93    #[Route(
94        path: 'v4.0/faqs/{categoryId}',
95        name: 'api.faqs.by-category-id',
96        requirements: ['categoryId' => '\d+'],
97        methods: ['GET'],
98    )]
99    public function getByCategoryId(Request $request): JsonResponse
100    {
101        return $this->withRequestLanguage($request, function () use ($request): JsonResponse {
102            [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
103
104            $this->faq->setUser($currentUser);
105            $this->faq->setGroups($currentGroups);
106
107            $categoryId = (int) Filter::filterVar($request->attributes->get(key: 'categoryId'), FILTER_VALIDATE_INT);
108
109            try {
110                $result = $this->faq->getAllAvailableFaqsByCategoryId($categoryId);
111                return $this->json($result, Response::HTTP_OK);
112            } catch (Exception|CommonMarkException $exception) {
113                return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
114            }
115        });
116    }
117
118    /**
119     * @throws \phpMyFAQ\Core\Exception|Exception
120     */
121    #[OA\Get(
122        path: '/api/v4.0/faq/{categoryId}/{faqId}',
123        operationId: 'getFaqById',
124        description: 'This endpoint returns the FAQ for the given FAQ ID and the language provided by '
125        . '"Accept-Language".',
126        tags: ['Public Endpoints'],
127    )]
128    #[OA\Header(
129        header: 'Accept-Language',
130        description: 'The language code for the FAQ.',
131        schema: new OA\Schema(type: 'string'),
132    )]
133    #[OA\Parameter(
134        name: 'categoryId',
135        description: 'The category ID.',
136        in: 'path',
137        required: true,
138        schema: new OA\Schema(type: 'integer'),
139    )]
140    #[OA\Parameter(
141        name: 'faqId',
142        description: 'The FAQ ID.',
143        in: 'path',
144        required: true,
145        schema: new OA\Schema(type: 'integer'),
146    )]
147    #[OA\Response(response: 200, description: 'If the FAQ exists.', content: new OA\JsonContent(example: [
148        'id' => 1,
149        'lang' => 'en',
150        'solution_id' => 1000,
151        'revision_id' => 0,
152        'active' => 'yes',
153        'sticky' => 0,
154        'keywords' => '',
155        'question' => 'Is there life after death?',
156        'answer' => 'Maybe!',
157        'author' => 'phpMyFAQ User',
158        'email' => 'user@example.org',
159        'comment' => 'y',
160        'updated' => '2019-10-10 17:54',
161        'dateStart' => '00000000000000',
162        'dateEnd' => '99991231235959',
163        'created' => '2019-09-03T21:30:17+02:00',
164        'category_id' => 1,
165        'link' => 'https://localhost/content/1/1/en/is_there_life_after_death.html',
166    ]))]
167    #[OA\Response(
168        response: 404,
169        description: 'If there are no FAQs for the given FAQ ID.',
170        content: new OA\JsonContent(example: new \stdClass()),
171    )]
172    #[Route(path: 'v4.0/faq/{categoryId}/{faqId}', name: 'api.faq.by-id', methods: ['GET'])]
173    public function getById(Request $request): JsonResponse
174    {
175        return $this->withRequestLanguage($request, function () use ($request): JsonResponse {
176            [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
177
178            $this->faq->setUser($currentUser);
179            $this->faq->setGroups($currentGroups);
180
181            $faqId = (int) Filter::filterVar($request->attributes->get(key: 'faqId'), FILTER_VALIDATE_INT);
182            $categoryId = (int) Filter::filterVar($request->attributes->get(key: 'categoryId'), FILTER_VALIDATE_INT);
183            $onlyActive = (bool) $this->configuration->get('api.onlyActiveFaqs');
184
185            $result = $this->faq->getFaqByIdAndCategoryId($faqId, $categoryId);
186
187            if (
188                (is_countable($result) ? count($result) : 0) === 0
189                || $result['solution_id'] === 42
190                || $onlyActive && $result['active'] !== 'yes'
191            ) {
192                $result = new stdClass();
193                return $this->json($result, Response::HTTP_NOT_FOUND);
194            }
195
196            return $this->json($result, Response::HTTP_OK);
197        });
198    }
199
200    /**
201     * @throws Exception
202     */
203    #[OA\Get(
204        path: '/api/v4.0/faqs/tags/{tagId}',
205        operationId: 'getByTagId',
206        description: 'This endpoint returns all the FAQs for the given tag ID and the language provided by '
207        . '
208        "Accept-Language"',
209        tags: ['Public Endpoints'],
210    )]
211    #[OA\Header(
212        header: 'Accept-Language',
213        description: 'The language code for the FAQ.',
214        schema: new OA\Schema(type: 'string'),
215    )]
216    #[OA\Parameter(
217        name: 'tagId',
218        description: 'The tag ID.',
219        in: 'path',
220        required: true,
221        schema: new OA\Schema(type: 'integer'),
222    )]
223    #[OA\Response(
224        response: 200,
225        description: 'If the tag ID returns at least one FAQ.',
226        content: new OA\JsonContent(example: [[
227            'record_id' => 1,
228            'record_lang' => 'en',
229            'category_id' => 1,
230            'record_title' => 'Is there life after death?',
231            'record_preview' => 'Maybe!',
232            'record_link' => '/phpmyfaq/content/1/1/en/is-there-life-after-death.html',
233            'record_updated' => '20191010175452',
234            'visits' => 3,
235            'record_created' => '2018-09-03T21:30:17+02:00',
236        ]]),
237    )]
238    #[OA\Response(
239        response: 500,
240        description: 'If fetching the tagged FAQs fails.',
241        content: new OA\JsonContent(example: ['error' => 'Error message']),
242    )]
243    #[Route(path: 'v4.0/faqs/tags/{tagId}', name: 'api.faqs.by-tag-id', methods: ['GET'])]
244    public function getByTagId(Request $request): JsonResponse
245    {
246        return $this->withRequestLanguage($request, function () use ($request): JsonResponse {
247            [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
248
249            $this->faq->setUser($currentUser);
250            $this->faq->setGroups($currentGroups);
251
252            $tagId = (int) Filter::filterVar($request->attributes->get(key: 'tagId'), FILTER_VALIDATE_INT);
253
254            $recordIds = $this->tags->getFaqsByTagId($tagId);
255
256            try {
257                $result = $this->faq->getFaqsByIds($recordIds);
258                return $this->json($result, Response::HTTP_OK);
259            } catch (Exception $exception) {
260                return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
261            }
262        });
263    }
264
265    /**
266     * @throws \phpMyFAQ\Core\Exception|Exception
267     */
268    #[OA\Get(
269        path: '/api/v4.0/faqs/popular',
270        operationId: 'getPopular',
271        description: 'This endpoint returns the popular FAQs for the given language provided by "Accept-Language".',
272        tags: ['Public Endpoints'],
273    )]
274    #[OA\Header(
275        header: 'Accept-Language',
276        description: 'The language code for the FAQ.',
277        schema: new OA\Schema(type: 'string'),
278    )]
279    #[OA\Response(
280        response: 200,
281        description: "If there's at least one popular FAQ.",
282        content: new OA\JsonContent(example: [[
283            'date' => '2019-07-13T11:28:00+0200',
284            'question' => 'How can I survive without phpMyFAQ?',
285            'answer' => 'A good question!',
286            'visits' => 10,
287            'url' => 'https://www.example.org/content/1/36/de/how-can-i-survive-without-phpmyfaq.html',
288        ]]),
289    )]
290    #[OA\Response(
291        response: 404,
292        description: "If there's not a single popular FAQ.",
293        content: new OA\JsonContent(example: []),
294    )]
295    #[Route(path: 'v4.0/faqs/popular', name: 'api.faqs.popular', methods: ['GET'])]
296    public function getPopular(Request $request): JsonResponse
297    {
298        return $this->withRequestLanguage($request, function (): JsonResponse {
299            [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
300
301            $this->faqStatistics->setUser($currentUser);
302            $this->faqStatistics->setGroups($currentGroups);
303
304            $result = array_values($this->faqStatistics->getTopTenData());
305
306            if ((is_countable($result) ? count($result) : 0) === 0) {
307                return $this->json($result, Response::HTTP_NOT_FOUND);
308            }
309
310            return $this->json($result, Response::HTTP_OK);
311        });
312    }
313
314    /**
315     * @throws \phpMyFAQ\Core\Exception
316     * @throws Exception
317     */
318    #[OA\Get(
319        path: 'v4.0/faqs/latest',
320        operationId: 'getLatest',
321        description: 'This endpoint returns the latest FAQs for the given language provided by "Accept-Language".',
322        tags: ['Public Endpoints'],
323    )]
324    #[OA\Header(
325        header: 'Accept-Language',
326        description: 'The language code for the FAQ.',
327        schema: new OA\Schema(type: 'string'),
328    )]
329    #[OA\Response(
330        response: 200,
331        description: "If there's at least one latest FAQ.",
332        content: new OA\JsonContent(example: [[
333            'date' => '2019-07-13T11:28:00+0200',
334            'question' => 'How can I survive without phpMyFAQ?',
335            'answer' => 'A good question!',
336            'visits' => 10,
337            'url' => 'https://www.example.org/content/1/36/de/how-can-i-survive-without-phpmyfaq.html',
338        ]]),
339    )]
340    #[OA\Response(
341        response: 404,
342        description: "If there's not one latest FAQ.",
343        content: new OA\JsonContent(example: []),
344    )]
345    #[Route(path: 'v4.0/faqs/latest', name: 'api.faqs.latest', methods: ['GET'])]
346    public function getLatest(Request $request): JsonResponse
347    {
348        return $this->withRequestLanguage($request, function (): JsonResponse {
349            [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
350
351            $this->faqStatistics->setUser($currentUser);
352            $this->faqStatistics->setGroups($currentGroups);
353
354            $result = array_values($this->faqStatistics->getLatestData());
355
356            if ((is_countable($result) ? count($result) : 0) === 0) {
357                return $this->json($result, Response::HTTP_NOT_FOUND);
358            }
359
360            return $this->json($result, Response::HTTP_OK);
361        });
362    }
363
364    /**
365     * @throws \phpMyFAQ\Core\Exception|Exception
366     */
367    #[OA\Get(
368        path: '/api/v4.0/faqs/trending',
369        operationId: 'getTrending',
370        description: 'This endpoint returns the trending FAQs for the given language provided by "Accept-Language".',
371        tags: ['Public Endpoints'],
372    )]
373    #[OA\Header(
374        header: 'Accept-Language',
375        description: 'The language code for the FAQ.',
376        schema: new OA\Schema(type: 'string'),
377    )]
378    #[OA\Response(
379        response: 200,
380        description: "If there's at least one trending FAQ.",
381        content: new OA\JsonContent(example: [[
382            'date' => '2019-07-13T11:28:00+0200',
383            'question' => 'How can I survive without phpMyFAQ?',
384            'answer' => 'A good question!',
385            'visits' => 10,
386            'url' => 'https://www.example.org/content/1/36/de/how-can-i-survive-without-phpmyfaq.html',
387        ]]),
388    )]
389    #[OA\Response(
390        response: 404,
391        description: "If there's not a single trending FAQ.",
392        content: new OA\JsonContent(example: []),
393    )]
394    #[Route(path: 'v4.0/faqs/trending', name: 'api.faqs.trending', methods: ['GET'])]
395    public function getTrending(Request $request): JsonResponse
396    {
397        return $this->withRequestLanguage($request, function (): JsonResponse {
398            [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
399
400            $this->faqStatistics->setUser($currentUser);
401            $this->faqStatistics->setGroups($currentGroups);
402
403            $result = array_values($this->faqStatistics->getTrendingData());
404
405            if ((is_countable($result) ? count($result) : 0) === 0) {
406                return $this->json($result, Response::HTTP_NOT_FOUND);
407            }
408
409            return $this->json($result, Response::HTTP_OK);
410        });
411    }
412
413    /**
414     * @throws \phpMyFAQ\Core\Exception|Exception
415     */
416    #[OA\Get(
417        path: '/api/v4.0/faqs/sticky',
418        operationId: 'getSticky',
419        description: 'This endpoint returns the sticky FAQs for the given language provided by "Accept-Language".',
420        tags: ['Public Endpoints'],
421    )]
422    #[OA\Header(
423        header: 'Accept-Language',
424        description: 'The language code for the FAQ.',
425        schema: new OA\Schema(type: 'string'),
426    )]
427    #[OA\Response(
428        response: 200,
429        description: "If there's at least one sticky FAQ.",
430        content: new OA\JsonContent(example: [
431            [
432                'question' => 'How can I survive without phpMyFAQ?',
433                'url' => 'https://www.example.org/content/1/36/de/how-can-i-survive-without-phpmyfaq.html',
434                'id' => 8,
435                'order' => 1,
436            ],
437            [
438                'question' => 'Is there life after death?',
439                'url' => 'https://www.example.org/content/1/1/de/is-there-life-after-death.html',
440                'id' => 10,
441                'order' => 2,
442            ],
443        ]),
444    )]
445    #[OA\Response(
446        response: 404,
447        description: "If there's not one sticky FAQ.",
448        content: new OA\JsonContent(example: []),
449    )]
450    #[Route(path: 'v4.0/faqs/sticky', name: 'api.faqs.sticky', methods: ['GET'])]
451    public function getSticky(Request $request): JsonResponse
452    {
453        return $this->withRequestLanguage($request, function (): JsonResponse {
454            [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
455
456            $this->faq->setUser($currentUser);
457            $this->faq->setGroups($currentGroups);
458
459            $result = array_values($this->faq->getStickyFaqsData());
460
461            if ((is_countable($result) ? count($result) : 0) === 0) {
462                return $this->json($result, Response::HTTP_NOT_FOUND);
463            }
464
465            return $this->json($result, Response::HTTP_OK);
466        });
467    }
468
469    /**
470     * @throws \phpMyFAQ\Core\Exception|Exception
471     */
472    #[OA\Get(
473        path: '/api/v4.0/faqs',
474        operationId: 'getAll',
475        description: 'This endpoint returns paginated FAQs for the given language provided by "Accept-Language".',
476        tags: ['Public Endpoints'],
477    )]
478    #[OA\Header(
479        header: 'Accept-Language',
480        description: 'The language code for the FAQ.',
481        schema: new OA\Schema(type: 'string'),
482    )]
483    #[OA\Parameter(
484        name: 'page',
485        description: 'Page number for pagination (page-based)',
486        in: 'query',
487        required: false,
488        schema: new OA\Schema(type: 'integer', default: 1),
489    )]
490    #[OA\Parameter(
491        name: 'per_page',
492        description: 'Items per page (page-based, max 100)',
493        in: 'query',
494        required: false,
495        schema: new OA\Schema(type: 'integer', default: 25),
496    )]
497    #[OA\Parameter(
498        name: 'limit',
499        description: 'Number of items to return (offset-based, max 100)',
500        in: 'query',
501        required: false,
502        schema: new OA\Schema(type: 'integer', default: 25),
503    )]
504    #[OA\Parameter(
505        name: 'offset',
506        description: 'Starting offset (offset-based)',
507        in: 'query',
508        required: false,
509        schema: new OA\Schema(type: 'integer', default: 0),
510    )]
511    #[OA\Parameter(name: 'sort', description: 'Field to sort by', in: 'query', required: false, schema: new OA\Schema(
512        type: 'string',
513        default: 'id',
514        enum: ['id', 'title', 'author', 'updated', 'created'],
515    ))]
516    #[OA\Parameter(
517        name: 'order',
518        description: 'Sort direction',
519        in: 'query',
520        required: false,
521        schema: new OA\Schema(type: 'string', default: 'asc', enum: ['asc', 'desc']),
522    )]
523    #[OA\Response(response: 200, description: 'Returns paginated FAQs.', content: new OA\JsonContent(example: [
524        'success' => true,
525        'data' => [[
526            'id' => '1',
527            'lang' => 'en',
528            'solution_id' => '1000',
529            'revision_id' => '0',
530            'active' => 'yes',
531            'sticky' => '0',
532            'keywords' => '',
533            'title' => 'Is there life after death?',
534            'content' => 'Maybe!',
535            'author' => 'phpMyFAQ User',
536            'email' => 'user@example.org',
537            'comment' => 'y',
538            'updated' => '2009-10-10 17:54:00',
539            'dateStart' => '00000000000000',
540            'dateEnd' => '99991231235959',
541            'created' => '2008-09-03T21:30:17+02:00',
542            'notes' => '',
543        ]],
544        'meta' => [
545            'pagination' => [
546                'total' => 50,
547                'count' => 25,
548                'per_page' => 25,
549                'current_page' => 1,
550                'total_pages' => 2,
551                'links' => [
552                    'first' => '/api/v4.0/faqs?page=1&per_page=25',
553                    'last' => '/api/v4.0/faqs?page=2&per_page=25',
554                    'prev' => null,
555                    'next' => '/api/v4.0/faqs?page=2&per_page=25',
556                ],
557            ],
558            'sorting' => [
559                'field' => 'id',
560                'order' => 'asc',
561            ],
562        ],
563    ]))]
564    #[Route(path: 'v4.0/faqs', name: 'api.faqs.list', methods: ['GET'])]
565    public function list(?Request $request = null): JsonResponse
566    {
567        $request ??= Request::createFromGlobals();
568        return $this->withRequestLanguage($request, function () use ($request): JsonResponse {
569            [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
570
571            $this->faq->setUser($currentUser);
572            $this->faq->setGroups($currentGroups);
573
574            // Get pagination and sorting parameters
575            $pagination = $this->getPaginationRequest($request);
576            $sort = $this->getSortRequest(
577                $request,
578                allowedFields: ['id', 'title', 'author', 'updated', 'created'],
579                defaultField: 'id',
580                defaultOrder: 'asc',
581            );
582
583            $onlyActive = (bool) $this->configuration->get('api.onlyActiveFaqs');
584            $ignoreOrphanedFaqs = (bool) $this->configuration->get('api.ignoreOrphanedFaqs');
585
586            // Get all FAQs (this populates $this->faq->faqRecords)
587            $this->faq->getAllFaqs(
588                Faq::SORTING_TYPE_CATID_FAQID,
589                [
590                    'lang' => $this->configuration->getLanguage()->getLanguage(),
591                    'fcr.category_id' => $ignoreOrphanedFaqs ? 'IS NOT NULL' : null,
592                    'fd.active' => $onlyActive ? 'yes' : null,
593                ],
594                $sort->getOrderSql(),
595            );
596
597            $allFaqs = $this->faq->faqRecords;
598            $total = is_countable($allFaqs) ? count($allFaqs) : 0;
599
600            if ($sort->getField() && $sort->getField() !== 'id') {
601                usort($allFaqs, static function (array $a, array $b) use ($sort): int {
602                    $field = (string) $sort->getField();
603                    $aVal = (string) ($a[$field] ?? '');
604                    $bVal = (string) ($b[$field] ?? '');
605                    $result = $aVal <=> $bVal;
606                    return $sort->getOrderSql() === 'DESC' ? -$result : $result;
607                });
608            }
609
610            $result = array_slice($allFaqs, $pagination->offset, $pagination->limit);
611
612            return $this->paginatedResponse(
613                $request,
614                data: array_values($result),
615                total: $total,
616                pagination: $pagination,
617                options: new PaginatedResponseOptions(sort: $sort),
618            );
619        });
620    }
621
622    /**
623     * @throws \phpMyFAQ\Core\Exception|\JsonException|Exception
624     */
625    #[OA\Post(path: '/api/v4.0/faq/create', operationId: 'createFaq', tags: ['Endpoints with Authentication'])]
626    #[OA\Header(
627        header: 'Accept-Language',
628        description: 'The language code for the login.',
629        schema: new OA\Schema(type: 'string'),
630    )]
631    #[OA\Header(
632        header: 'x-pmf-token',
633        description: 'phpMyFAQ client API Token, generated in admin backend',
634        schema: new OA\Schema(type: 'string'),
635    )]
636    #[OA\RequestBody(
637        description: 'The category ID is a required value, the category name is optional. If the category name is '
638        . 'present and the ID can be mapped, the category ID from the name will be used. If the category name '
639        . 'cannot be mapped, a 409 error is thrown.',
640        required: true,
641        content: new OA\MediaType(
642            mediaType: 'application/json',
643            schema: new OA\Schema(
644                required: [
645                    'language',
646                    'category-id',
647                    'category-name',
648                    'question',
649                    'answer',
650                    'keywords',
651                    'author',
652                    'email',
653                    'is-active',
654                    'is-sticky',
655                ],
656                properties: [
657                    new OA\Property(property: 'language', type: 'string'),
658                    new OA\Property(property: 'category-id', type: 'integer'),
659                    new OA\Property(property: 'category-name', type: 'string'),
660                    new OA\Property(property: 'question', type: 'string'),
661                    new OA\Property(property: 'answer', type: 'string'),
662                    new OA\Property(property: 'keywords', type: 'string'),
663                    new OA\Property(property: 'author', type: 'string'),
664                    new OA\Property(property: 'email', type: 'string'),
665                    new OA\Property(property: 'is-active', type: 'boolean'),
666                    new OA\Property(property: 'is-sticky', type: 'boolean'),
667                ],
668                type: 'object',
669            ),
670            example: '{
671                "language": "de",
672                "category-id": 1,
673                "category-name": "Queen Songs",
674                "question": "Is this the world we created?",
675                "answer": "What did we do it for, is this the world we invaded, against the law, so it seems in the '
676            . 'end, is this what we\'re all living for today",
677                "keywords": "phpMyFAQ, FAQ, Foo, Bar",
678                "author": "Freddie Mercury",
679                "email": "freddie.mercury@example.org",
680                "is-active": "true",
681                "is-sticky": "false"
682            }',
683        ),
684    )]
685    #[OA\Response(response: 201, description: 'If all posted data is correct.', content: new OA\JsonContent(example: [
686        'stored' => true,
687    ]))]
688    #[OA\Response(response: 400, description: "If something didn't worked out.", content: new OA\JsonContent(example: [
689        'stored' => false,
690        'error' => 'It is not allowed, that the question title contains a hash.',
691    ]))]
692    #[OA\Response(
693        response: 409,
694        description: 'If the parent category name cannot be mapped.',
695        content: new OA\JsonContent(example: [
696            'stored' => false,
697            'error' => 'The given category name was not found',
698        ]),
699    )]
700    #[OA\Response(response: 401, description: 'If the user is not authenticated.')]
701    #[Route(path: 'v4.0/faq/create', name: 'api.faq.create', methods: ['POST'])]
702    public function create(Request $request): JsonResponse
703    {
704        $this->hasValidToken();
705        $this->userHasPermission(PermissionType::FAQ_ADD);
706
707        [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
708
709        $data = $this->getJsonObject($request);
710
711        $currentLanguage = $this->configuration->getLanguage()->getLanguage();
712
713        $category = new Category($this->configuration, $currentGroups, withPermission: true);
714        $category->setUser($currentUser);
715        $category->setGroups($currentGroups);
716        $category->setLanguage($currentLanguage);
717
718        $this->faq->setUser($currentUser);
719        $this->faq->setGroups($currentGroups);
720
721        $languageCode = Filter::filterVar($data->language ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
722        $categoryId = Filter::filterVar($data->{'category-id'} ?? null, FILTER_VALIDATE_INT);
723        $categoryName = null;
724
725        if (property_exists($data, 'category-name') && $data->{'category-name'} !== null) {
726            $categoryName = Filter::filterVar($data->{'category-name'}, FILTER_SANITIZE_SPECIAL_CHARS);
727        }
728
729        $question = Filter::filterHtml($data->question ?? '', '');
730        $answer = Filter::filterHtml($data->answer ?? '', '');
731        $keywords = Filter::filterVar($data->keywords ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
732        $author = Filter::filterVar($data->author ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
733        $email = (string) Filter::filterVar($data->email ?? '', FILTER_SANITIZE_EMAIL, '');
734        $isActive = Filter::filterVar($data->{'is-active'} ?? null, FILTER_VALIDATE_BOOLEAN);
735        $isSticky = Filter::filterVar($data->{'is-sticky'} ?? null, FILTER_VALIDATE_BOOLEAN);
736
737        // Check if the category name can be mapped
738        if (!is_null($categoryName)) {
739            $categoryIdFound = $category->getCategoryIdFromName($categoryName);
740            if ($categoryIdFound === false) {
741                $result = [
742                    'stored' => false,
743                    'error' => 'The given category name was not found.',
744                ];
745
746                return $this->json($result, Response::HTTP_CONFLICT);
747            }
748
749            $categoryId = $categoryIdFound;
750        }
751
752        if ($this->faq->hasTitleAHash($question)) {
753            $result = [
754                'stored' => false,
755                'error' => 'It is not allowed, that the question title contains a hash.',
756            ];
757            return $this->json($result, Response::HTTP_BAD_REQUEST);
758        }
759
760        $categories = [$categoryId];
761        $isActive = !is_null($isActive);
762        $isSticky = !is_null($isSticky);
763
764        $faqData = new FaqEntity();
765        $faqData
766            ->setLanguage($languageCode)
767            ->setQuestion($question)
768            ->setAnswer($answer)
769            ->setKeywords($keywords)
770            ->setAuthor($author)
771            ->setEmail($email)
772            ->setActive($isActive)
773            ->setSticky($isSticky)
774            ->setComment(comment: false)
775            ->setNotes(notes: '');
776
777        $faqEntity = $this->faq->create($faqData);
778
779        if ($faqEntity->getId() === null) {
780            $result = [
781                'stored' => false,
782                'error' => 'Cannot add FAQ',
783            ];
784            return $this->json($result, Response::HTTP_BAD_REQUEST);
785        }
786
787        $this->faqMetaData
788            ->setFaqId((int) $faqEntity->getId())
789            ->setFaqLanguage($languageCode)
790            ->setCategories(array_map(intval(...), $categories))
791            ->save();
792
793        return $this->json(['stored' => true], Response::HTTP_CREATED);
794    }
795
796    /**
797     * @throws \phpMyFAQ\Core\Exception|\JsonException|Exception
798     */
799    #[OA\Put(
800        path: '/api/v4.0/faq/update',
801        operationId: 'updateFaq',
802        description: 'Used to update a FAQ in one existing category.',
803        tags: ['Endpoints with Authentication'],
804    )]
805    #[OA\Header(
806        header: 'Accept-Language',
807        description: 'The language code for the login.',
808        schema: new OA\Schema(type: 'string'),
809    )]
810    #[OA\Header(
811        header: 'x-pmf-token',
812        description: 'phpMyFAQ client API Token, generated in admin backend',
813        schema: new OA\Schema(type: 'string'),
814    )]
815    #[OA\RequestBody(required: true, content: new OA\MediaType(
816        mediaType: 'application/json',
817        schema: new OA\Schema(
818            required: [
819                'faq-id',
820                'language',
821                'category-id',
822                'question',
823                'answer',
824                'keywords',
825                'author',
826                'email',
827                'is-active',
828                'is-sticky',
829            ],
830            properties: [
831                new OA\Property(property: 'faq-id', type: 'integer'),
832                new OA\Property(property: 'language', type: 'string'),
833                new OA\Property(property: 'category-id', type: 'integer'),
834                new OA\Property(property: 'question', type: 'string'),
835                new OA\Property(property: 'answer', type: 'string'),
836                new OA\Property(property: 'keywords', type: 'string'),
837                new OA\Property(property: 'author', type: 'string'),
838                new OA\Property(property: 'email', type: 'string'),
839                new OA\Property(property: 'is-active', type: 'boolean'),
840                new OA\Property(property: 'is-sticky', type: 'boolean'),
841            ],
842            type: 'object',
843        ),
844        example: '{
845                "faq-id": 1,
846                "language": "de",
847                "category-id": 1,
848                "question": "Is this the world we updated?",
849                "answer": "What did we do it for, is this the world we invaded, against the law, so it seems in the " .
850                    "end, is this what we\'re all living for today",
851                "keywords": "phpMyFAQ, FAQ, Foo, Bar",
852                "author": "Freddie Mercury",
853                "email": "freddie.mercury@example.org",
854                "is-active": "true",
855                "is-sticky": "false"
856            }',
857    ))]
858    #[OA\Response(response: 200, description: 'If all posted data is correct.', content: new OA\JsonContent(example: [
859        'stored' => true,
860    ]))]
861    #[OA\Response(response: 400, description: "If something didn't worked out.", content: new OA\JsonContent(example: [
862        'stored' => false,
863        'error' => 'It is not allowed, that the question title contains a hash.',
864    ]))]
865    #[OA\Response(response: 401, description: 'If the user is not authenticated.')]
866    #[Route(path: 'v4.0/faq/update', name: 'api.faq.update', methods: ['PUT'])]
867    public function update(Request $request): JsonResponse
868    {
869        $this->hasValidToken();
870        $this->userHasPermission(PermissionType::FAQ_EDIT);
871
872        [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
873
874        $data = $this->getJsonObject($request);
875
876        $currentLanguage = $this->configuration->getLanguage()->getLanguage();
877
878        $category = new Category($this->configuration, $currentGroups, withPermission: true);
879        $category->setUser($currentUser);
880        $category->setGroups($currentGroups);
881        $category->setLanguage($currentLanguage);
882
883        $this->faq->setUser($currentUser);
884        $this->faq->setGroups($currentGroups);
885
886        $faqId = Filter::filterVar($data->{'faq-id'} ?? null, FILTER_VALIDATE_INT);
887        $languageCode = Filter::filterVar($data->language ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
888        $question = Filter::filterHtml($data->question ?? '', '');
889        $answer = Filter::filterHtml($data->answer ?? '', '');
890        $keywords = Filter::filterVar($data->keywords ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
891        $author = Filter::filterVar($data->author ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
892        $email = (string) Filter::filterVar($data->email ?? '', FILTER_SANITIZE_EMAIL, '');
893        $isActive = Filter::filterVar($data->{'is-active'} ?? null, FILTER_VALIDATE_BOOLEAN);
894        $isSticky = Filter::filterVar($data->{'is-sticky'} ?? null, FILTER_VALIDATE_BOOLEAN);
895
896        if ($faqId === null) {
897            $result = [
898                'stored' => false,
899                'error' => 'Cannot update FAQ',
900            ];
901            return $this->json($result, Response::HTTP_BAD_REQUEST);
902        }
903
904        if ($this->faq->hasTitleAHash($question)) {
905            $result = [
906                'stored' => false,
907                'error' => 'It is not allowed, that the question title contains a hash.',
908            ];
909            return $this->json($result, Response::HTTP_BAD_REQUEST);
910        }
911
912        $isActive = !is_null($isActive);
913        $isSticky = !is_null($isSticky);
914
915        $faqEntity = new FaqEntity();
916        $faqEntity
917            ->setId($faqId)
918            ->setRevisionId(revisionId: 0)
919            ->setLanguage($languageCode)
920            ->setQuestion($question)
921            ->setAnswer($answer)
922            ->setKeywords($keywords)
923            ->setAuthor($author)
924            ->setEmail($email)
925            ->setActive($isActive)
926            ->setSticky($isSticky)
927            ->setComment(comment: false)
928            ->setNotes(notes: '');
929
930        $this->faq->update($faqEntity);
931
932        return $this->json(['stored' => true], Response::HTTP_OK);
933    }
934
935    private function applyRequestLanguage(Request $request): void
936    {
937        $currentLanguage = $this->resolveRequestLanguage($request);
938        Language::$language = $currentLanguage;
939        $this->session->set(name: 'lang', value: $currentLanguage);
940        $this->configuration->setLanguage($this->language);
941    }
942
943    private function resolveRequestLanguage(Request $request): string
944    {
945        foreach ($request->getLanguages() as $language) {
946            if (Language::isASupportedLanguage(strtoupper($language))) {
947                return strtolower($language);
948            }
949
950            $shortLanguage = substr(string: $language, offset: 0, length: 2);
951            if (Language::isASupportedLanguage(strtoupper($shortLanguage))) {
952                return strtolower($shortLanguage);
953            }
954        }
955
956        return $this->configuration->getLanguage()->getLanguage();
957    }
958
959    /** @param callable(): JsonResponse $callback */
960    private function withRequestLanguage(Request $request, callable $callback): JsonResponse
961    {
962        $previousLanguage = $this->configuration->getLanguage()->getLanguage();
963        $previousStaticLanguage = Language::$language;
964
965        try {
966            $this->applyRequestLanguage($request);
967            return $callback();
968        } finally {
969            Language::$language = $previousStaticLanguage !== '' ? $previousStaticLanguage : $previousLanguage;
970            $this->session->set(name: 'lang', value: Language::$language);
971            $this->configuration->setLanguage($this->language);
972        }
973    }
974}