Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.64% covered (success)
96.64%
546 / 565
82.35% covered (success)
82.35%
28 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
Faq
96.64% covered (success)
96.64%
546 / 565
82.35% covered (success)
82.35%
28 / 34
104
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setGroups
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getAllAvailableFaqsByCategoryId
100.00% covered (success)
100.00%
61 / 61
100.00% covered (success)
100.00%
1 / 1
5
 getFaqsDataByCategoryId
98.91% covered (success)
98.91%
91 / 92
0.00% covered (danger)
0.00%
0 / 1
7
 getFaqsDataByIds
98.25% covered (success)
98.25%
56 / 57
0.00% covered (danger)
0.00%
0 / 1
10
 getFaq
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
1 / 1
6
 isFaqRecordVisible
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFaqResult
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getFaqsByIds
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
2
 isFaqAccessibleForUser
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 getCurrentLanguage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getFaqByIdAndCategoryId
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
2
 create
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getTenantQuotaEnforcer
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getNextSolutionId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 update
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 delete
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
3.58
 getSolutionIdFromId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasTranslation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isActive
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFaqBySolutionId
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
4
 getIdFromSolutionId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllFaqs
90.48% covered (success)
90.48%
38 / 42
0.00% covered (danger)
0.00%
0 / 1
9.07
 getQuestion
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getKeywords
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 get
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
4
 getStickyFaqsData
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
4
 sortStickyArrayByOrder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizeCategoryOrder
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
23.62
 normalizeFaqOrderBy
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
16.67
 normalizeSortDirection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 normalizeFaqIds
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 hasTitleAHash
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The main FAQ class. Yes, it's very huge.
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 * @author    Matteo Scaramuccia <matteo@scaramuccia.com>
13 * @author    Georgi Korchev <korchev@yahoo.com>
14 * @author    Adrianna Musiol <musiol@imageaccess.de>
15 * @author    Peter Caesar <p.caesar@osmaco.de>
16 * @author    Jan Harms <model_railroader@gmx-topmail.de>
17 * @copyright 2005-2026 phpMyFAQ Team
18 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
19 * @link      https://www.phpmyfaq.de
20 * @since     2005-12-20
21 */
22
23declare(strict_types=1);
24
25namespace phpMyFAQ;
26
27use Exception;
28use League\CommonMark\Exception\CommonMarkException;
29use phpMyFAQ\Attachment\AttachmentFactory;
30use phpMyFAQ\Entity\FaqEntity;
31use phpMyFAQ\Faq\FaqRepository;
32use phpMyFAQ\Faq\QueryHelper;
33use phpMyFAQ\Faq\RecordVisibility;
34use phpMyFAQ\Helper\FaqHelper;
35use phpMyFAQ\Instance\Search\Elasticsearch;
36use phpMyFAQ\Language\Plurals;
37use phpMyFAQ\Link\Util\TitleSlugifier;
38use phpMyFAQ\Pagination\UrlConfig;
39use phpMyFAQ\Tenant\TenantQuotaEnforcer;
40use stdClass;
41
42/**
43 * Class Faq
44 *
45 * @todo Refactor this class and split it into smaller classes.
46 *
47 * @package phpMyFAQ
48 */
49/* @mago-expect lint:too-many-methods - legacy facade over FAQ retrieval; split into repository/services is in progress */
50class Faq
51{
52    public const string QUERY_TYPE_DEFAULT = 'faq_default';
53    public const int SORTING_TYPE_NONE = 0;
54    public const int SORTING_TYPE_CATID_FAQID = 1;
55    public const int SORTING_TYPE_FAQTITLE_FAQID = 2;
56    public const int SORTING_TYPE_DATE_FAQID = 3;
57    public const int SORTING_TYPE_FAQID = 4;
58
59    /**
60     * The current FAQ record.
61     */
62    /** @var array<string, mixed> */
63    public array $faqRecord = [];
64
65    /**
66     * All current FAQ records in an array.
67     */
68    /** @var array<int, array<string, mixed>> */
69    public array $faqRecords = [];
70
71    /**
72     * Plural form support.
73     */
74    private readonly Plurals $plurals;
75
76    /**
77     * Users.
78     */
79    private int $user = -1;
80
81    /**
82     * Groups.
83     *
84     * @var int[]
85     */
86    private array $groups = [-1];
87
88    /**
89     * Flag for Group support.
90     */
91    private bool $groupSupport = false;
92    private ?TenantQuotaEnforcer $tenantQuotaEnforcer = null;
93
94    private readonly FaqRepository $faqRepository;
95
96    /**
97     * Constructor.
98     */
99    public function __construct(
100        private readonly Configuration $configuration,
101    ) {
102        $this->plurals = new Plurals();
103        $this->faqRepository = new FaqRepository($this->configuration);
104
105        if ($this->configuration->get(item: 'security.permLevel') !== 'basic') {
106            $this->groupSupport = true;
107        }
108    }
109
110    public function setUser(int $userId = -1): Faq
111    {
112        $this->user = $userId;
113        return $this;
114    }
115
116    /**
117     * @param int[] $groups
118     */
119    public function setGroups(array $groups): Faq
120    {
121        $this->groups = $groups;
122        return $this;
123    }
124
125    /**
126     * This function returns all not expired FAQs from one category.
127     *
128     * @param int    $categoryId Entity ID
129     * @param string $orderBy    Order by
130     * @param string $sortBy     Sort by
131     * @throws Exception|CommonMarkException
132     */
133    public function getAllAvailableFaqsByCategoryId(
134        int $categoryId,
135        string $orderBy = 'id',
136        string $sortBy = 'ASC',
137        bool $preview = true,
138    ): array {
139        $faqData = [];
140        [$currentTable, $orderColumn] = $this->normalizeCategoryOrder($orderBy);
141        $sortDirection = $this->normalizeSortDirection($sortBy);
142
143        $rows = $this->faqRepository->fetchAvailableFaqsByCategoryId(
144            $categoryId,
145            $currentTable,
146            $orderColumn,
147            $sortDirection,
148            $this->user,
149            $this->groups,
150            $this->groupSupport,
151        );
152
153        if ($rows === []) {
154            return $faqData;
155        }
156
157        $faqHelper = new FaqHelper($this->configuration);
158        foreach ($rows as $row) {
159            $faqId = (int) $row->id;
160            $faqLanguage = (string) $row->lang;
161            $faqCategoryId = (int) $row->category_id;
162            $question = (string) $row->thema;
163            $answer = (string) $row->record_content;
164            $updated = (string) $row->updated;
165            $created = (string) $row->created;
166            $visits = (int) ($row->visits ?? 0);
167
168            $url = sprintf(
169                '%scontent/%d/%d/%s/%s.html',
170                $this->configuration->getDefaultUrl(),
171                $faqCategoryId,
172                $faqId,
173                $faqLanguage,
174                TitleSlugifier::slug($question),
175            );
176            $oLink = new Link($url, $this->configuration);
177            $oLink->setTitle($question);
178            $oLink->text = $question;
179            $oLink->tooltip = $question;
180
181            if ($preview) {
182                $faqData[] = [
183                    'record_id' => $faqId,
184                    'record_lang' => $faqLanguage,
185                    'category_id' => $faqCategoryId,
186                    'record_title' => $question,
187                    'record_preview' => $faqHelper->renderAnswerPreview($answer, 25),
188                    'record_link' => $oLink->toString(),
189                    'record_updated' => $updated,
190                    'visits' => $visits,
191                    'record_created' => $created,
192                ];
193            }
194
195            if (!$preview) {
196                $faqData[] = [
197                    'faq_id' => $faqId,
198                    'faq_lang' => $faqLanguage,
199                    'category_id' => $faqCategoryId,
200                    'question' => $question,
201                    'answer' => $answer,
202                    'link' => $oLink->toString(),
203                    'updated' => $updated,
204                    'visits' => $visits,
205                    'created' => $created,
206                ];
207            }
208        }
209
210        return $faqData;
211    }
212
213    /**
214     * Returns the data needed to render the FAQ list of a category: pagination metadata, the
215     * paginated items (each with its rendered link anchor, answer preview, views label and
216     * sticky flag) and the pre-rendered pagination control. The markup itself lives in the
217     * category-faq-list.twig template.
218     *
219     * @param int    $categoryId Entity ID
220     * @param string $orderBy    Order by
221     * @param string $sortBy     Sort by
222     * @return array<string, mixed>
223     * @throws Exception|CommonMarkException
224     */
225    public function getFaqsDataByCategoryId(
226        int $categoryId,
227        string $orderBy = 'id',
228        string $sortBy = 'ASC',
229        ?int $page = null,
230    ): array {
231        $numPerPage = (int) $this->configuration->get(item: 'records.numberOfRecordsPerPage');
232        $page ??= (int) Filter::filterInput(INPUT_GET, 'seite', FILTER_VALIDATE_INT, 1);
233        $page = max(1, (int) $page);
234        $title = '';
235        [$currentTable, $orderColumn] = $this->normalizeCategoryOrder($orderBy);
236        $sortDirection = $this->normalizeSortDirection($sortBy);
237
238        // If random FAQs are activated, we don't need an order
239        $order = sprintf('ORDER BY fd.sticky DESC, %s.%s %s', $currentTable, $orderColumn, $sortDirection);
240
241        $num = $this->faqRepository->countRenderableFaqsByCategoryId(
242            $categoryId,
243            $this->user,
244            $this->groups,
245            $this->groupSupport,
246        );
247        $pages = (int) ceil($num / $numPerPage);
248
249        $first = ($page - 1) * $numPerPage;
250
251        $items = [];
252        if ($num > 0) {
253            /* @mago-expect analysis:mixed-assignment - DB layer query results are untyped by design */
254            $result = $this->faqRepository->queryRenderableFaqsByCategoryId(
255                $categoryId,
256                $order,
257                $this->user,
258                $this->groups,
259                $this->groupSupport,
260                $first,
261                $numPerPage,
262            );
263            $renderedItems = [];
264            while (true) {
265                $row = $this->configuration->getDb()->fetchObject($result);
266                if (!$row instanceof stdClass) {
267                    break;
268                }
269
270                $faqId = (int) $row->id;
271                $question = (string) $row->question;
272                $visits = (int) ($row->visits ?? 0);
273
274                $title = Strings::htmlentities($question);
275                $url = sprintf(
276                    '%scontent/%d/%d/%s/%s.html',
277                    $this->configuration->getDefaultUrl(),
278                    (int) $row->category_id,
279                    $faqId,
280                    (string) $row->lang,
281                    TitleSlugifier::slug($question),
282                );
283
284                $oLink = new Link($url, $this->configuration);
285                $oLink->setTitle($title);
286                $oLink->text = $title;
287                $oLink->tooltip = $title;
288                $oLink->class = 'text-decoration-none';
289
290                // If random FAQs are activated, we don't need sticky FAQs
291                $isSticky =
292                    (int) ($row->sticky ?? 0) !== 0 && true !== $this->configuration->get(item: 'records.randomSort');
293
294                $renderedItems[$faqId] = [
295                    'anchor' => $oLink->toHtmlAnchor(),
296                    'preview' => Utils::chopString(strip_tags((string) $row->answer), 20),
297                    'views' => $this->plurals->get(key: 'plmsgViews', number: $visits),
298                    'sticky' => $isSticky,
299                ];
300            }
301
302            // If random FAQs are activated, shuffle the FAQs :-)
303            if (true === $this->configuration->get(item: 'records.randomSort')) {
304                shuffle($renderedItems);
305            }
306
307            $items = array_values($renderedItems);
308        }
309
310        $pagination = '';
311        if ($pages > 1) {
312            $link = new Link($this->configuration->getDefaultUrl(), $this->configuration);
313            $rewriteUrl = sprintf(
314                '%scategory/%d/%%d/%s.html',
315                $this->configuration->getDefaultUrl(),
316                $categoryId,
317                $link->getSEOTitle($title),
318            );
319
320            $category = new Category($this->configuration);
321
322            $baseUrl = sprintf(
323                '%scategory/%d/%s.html?seite=%d',
324                $this->configuration->getDefaultUrl(),
325                $categoryId,
326                TitleSlugifier::slug($category->getCategoryName($categoryId)),
327                $page,
328            );
329
330            $paginationControl = new Pagination(
331                baseUrl: $baseUrl,
332                total: $num,
333                perPage: (int) $this->configuration->get(item: 'records.numberOfRecordsPerPage'),
334                urlConfig: new UrlConfig(pageParamName: 'seite', rewriteUrl: $rewriteUrl),
335            );
336            $pagination = $paginationControl->render();
337        }
338
339        return [
340            'page' => $page,
341            'pages' => $pages,
342            'msgPage' => Translation::get(key: 'msgPage'),
343            'msgVoteFrom' => Translation::get(key: 'msgVoteFrom'),
344            'msgPages' => Translation::get(key: 'msgPages'),
345            'items' => $items,
346            'pagination' => $pagination,
347        ];
348    }
349
350    /**
351     * Returns the search-result view model for the given, not-expired record ids: a list of
352     * objects carrying the FAQ link, the (chopped) question and an answer preview. The markup
353     * is rendered by search.twig, so this method only builds data.
354     *
355     * @param int[]  $faqIds Array of record ids
356     * @param string $orderBy Order by
357     * @param string $sortBy Sort by
358     * @param bool   $usePagination Whether to use internal pagination
359     * @return stdClass[]
360     * @throws CommonMarkException
361     */
362    public function getFaqsDataByIds(
363        array $faqIds,
364        string $orderBy = 'fd.id',
365        string $sortBy = 'ASC',
366        bool $usePagination = true,
367    ): array {
368        $records = $this->normalizeFaqIds($faqIds);
369        $orderExpression = $this->normalizeFaqOrderBy($orderBy);
370        $sortDirection = $this->normalizeSortDirection($sortBy);
371        $page = (int) Filter::filterInput(INPUT_GET, 'seite', FILTER_VALIDATE_INT, 1);
372
373        /* @mago-expect analysis:mixed-assignment - DB layer query results are untyped by design */
374        $result = $this->faqRepository->queryRenderableFaqsByIds(
375            $records,
376            $orderExpression,
377            $sortDirection,
378            $this->user,
379            $this->groups,
380            $this->groupSupport,
381        );
382
383        $num = $this->configuration->getDb()->numRows($result);
384        $numberPerPage = (int) $this->configuration->get(item: 'records.numberOfRecordsPerPage');
385
386        $first = $usePagination && $page > 1 ? ($page * $numberPerPage) - $numberPerPage : 0;
387
388        $searchResults = [];
389        if ($num > 0) {
390            $counter = 0;
391            $displayedCounter = 0;
392            $lastFaqId = 0;
393            $faqHelper = new FaqHelper($this->configuration);
394            while (!$usePagination || $displayedCounter < $numberPerPage) {
395                $row = $this->configuration->getDb()->fetchObject($result);
396                if (!$row instanceof stdClass) {
397                    break;
398                }
399
400                ++$counter;
401                if ($usePagination && $counter <= $first) {
402                    continue;
403                }
404
405                ++$displayedCounter;
406
407                $faqId = (int) $row->id;
408                if ($lastFaqId === $faqId) {
409                    continue; // Don't show multiple FAQs
410                }
411
412                $rowResult = new stdClass();
413
414                $title = (string) $row->question;
415                $url = sprintf(
416                    '%scontent/%d/%d/%s/%s.html',
417                    $this->configuration->getDefaultUrl(),
418                    (int) $row->category_id,
419                    $faqId,
420                    (string) $row->lang,
421                    TitleSlugifier::slug($title),
422                );
423
424                $oLink = new Link($url, $this->configuration);
425                $oLink->setTitle($title);
426                $oLink->text = $title;
427                $oLink->tooltip = $title;
428
429                $rowResult->renderedScore = 0;
430                $rowResult->question = Utils::chopString(Strings::htmlentities($title), 15);
431                $rowResult->path = '';
432                $rowResult->url = $oLink->toString();
433                $rowResult->answerPreview = Strings::htmlentities($faqHelper->renderAnswerPreview(
434                    (string) $row->answer,
435                    20,
436                ));
437
438                $lastFaqId = $faqId;
439                $searchResults[] = $rowResult;
440            }
441        }
442
443        return $searchResults;
444    }
445
446    /**
447     * Returns an array with all data from a FAQ record.
448     *
449     * @param int      $faqId FAQ ID
450     * @param int|null $faqRevisionId Revision ID
451     * @param bool     $isAdmin Must be true if it is called by an admin/author context
452     */
453    public function getFaq(int $faqId, ?int $faqRevisionId = null, bool $isAdmin = false): void
454    {
455        $currentLanguage = $this->configuration->getLanguage()->getLanguage();
456        $defaultLanguage = $this->configuration->getDefaultLanguage();
457
458        /* @mago-expect analysis:mixed-assignment - DB layer query results are untyped by design */
459        $result = $this->getFaqResult($faqId, $currentLanguage, $faqRevisionId, $isAdmin);
460
461        if (0 === $this->configuration->getDb()->numRows($result)) {
462            /* @mago-expect analysis:mixed-assignment - DB layer query results are untyped by design */
463            $result = $this->getFaqResult($faqId, $defaultLanguage, $faqRevisionId, $isAdmin);
464        }
465
466        $this->faqRecord = [
467            'id' => $faqId,
468            'lang' => $currentLanguage,
469            'solution_id' => 42,
470            'revision_id' => $faqRevisionId,
471            'active' => 'no',
472            'sticky' => 0,
473            'keywords' => '',
474            'title' => '',
475            'content' => Translation::get(key: 'msgAccessDenied'),
476            'author' => '',
477            'email' => '',
478            'comment' => '',
479            'date' => Date::createIsoDate(date(format: 'YmdHis')),
480            'dateStart' => '',
481            'dateEnd' => '',
482            'notes' => '',
483            'created' => date(format: 'c'),
484        ];
485
486        $row = $this->configuration->getDb()->fetchObject($result);
487        if ($row instanceof stdClass) {
488            $question = nl2br((string) $row->thema);
489            $answer = (string) $row->content;
490            $active = 'yes' === $row->active;
491            $expired = date(format: 'YmdHis') > (string) $row->date_end;
492
493            if (!$isAdmin) {
494                if (!$active) {
495                    $answer = Translation::getString(key: 'err_inactiveArticle');
496                }
497
498                if ($expired) {
499                    $answer = Translation::getString(key: 'err_expiredArticle');
500                }
501            }
502
503            $this->faqRecord = [
504                'id' => $row->id,
505                'lang' => $row->lang,
506                'solution_id' => $row->solution_id,
507                'revision_id' => $row->revision_id,
508                'active' => $row->active,
509                'sticky' => $row->sticky,
510                'keywords' => $row->keywords,
511                'title' => $question,
512                'content' => $answer,
513                'author' => $row->author,
514                'email' => $row->email,
515                'comment' => $row->comment,
516                'date' => Date::createIsoDate((string) $row->updated),
517                'dateStart' => $row->date_start,
518                'dateEnd' => $row->date_end,
519                'notes' => $row->notes,
520                'created' => $row->created,
521            ];
522        }
523    }
524
525    /**
526     * Whether the record currently held in $faqRecord may be disclosed to the current
527     * requester. See RecordVisibility for why public read paths have to ask.
528     */
529    public function isFaqRecordVisible(): bool
530    {
531        return new RecordVisibility($this->faqRecord)->isVisible();
532    }
533
534    /**
535     * Executes a query to retrieve a single FAQ.
536     */
537    public function getFaqResult(
538        int $faqId,
539        string $faqLanguage,
540        ?int $faqRevisionId = null,
541        bool $isAdmin = false,
542    ): mixed {
543        return $this->faqRepository->getFaqResult(
544            $faqId,
545            $faqLanguage,
546            $faqRevisionId,
547            $isAdmin,
548            $this->user,
549            $this->groups,
550            $this->groupSupport,
551        );
552    }
553
554    /**
555     * Return FAQs from given IDs
556     *
557     * @param int[] $faqIds
558     * @throws Exception
559     */
560    public function getFaqsByIds(array $faqIds, bool $onlyActive = true): array
561    {
562        $faqRecords = [];
563        $records = $this->normalizeFaqIds($faqIds);
564
565        $rows = $this->faqRepository->fetchFaqsByIds(
566            $records,
567            $onlyActive,
568            $this->user,
569            $this->groups,
570            $this->groupSupport,
571        );
572
573        $faqHelper = new FaqHelper($this->configuration);
574        foreach ($rows as $row) {
575            $question = (string) $row->question;
576            $visits = (int) ($row->visits ?? 0);
577
578            $url = sprintf(
579                '%scontent/%d/%d/%s/%s.html',
580                $this->configuration->getDefaultUrl(),
581                (int) $row->category_id,
582                (int) $row->id,
583                (string) $row->lang,
584                TitleSlugifier::slug($question),
585            );
586
587            $oLink = new Link($url, $this->configuration);
588            $oLink->setTitle($question);
589            $oLink->text = $question;
590            $oLink->tooltip = $question;
591
592            $faqRecords[] = [
593                'record_id' => (int) $row->id,
594                'record_lang' => (string) $row->lang,
595                'category_id' => (int) $row->category_id,
596                'record_title' => $question,
597                'record_preview' => $faqHelper->renderAnswerPreview((string) $row->answer, 25),
598                'record_link' => $oLink->toString(),
599                'record_updated' => Date::createIsoDate((string) $row->updated) . ':00',
600                'visits' => $visits,
601                'record_created' => (string) $row->created,
602            ];
603        }
604
605        return $faqRecords;
606    }
607
608    /**
609     * Checks whether the FAQ record with the given ID is visible to the current
610     * user and groups in the current language. This is used to gate access to
611     * child resources such as comments and attachments, which are keyed only by
612     * the record ID and would otherwise leak data from restricted FAQs.
613     *
614     * The visibility rules mirror getFaqByIdAndCategoryId(): the record must be
615     * active, within its active date window, and permitted for the current user
616     * and groups.
617     *
618     * A record that does not exist at all is reported as accessible: there is
619     * nothing to protect, and the child resource lookup simply comes back empty.
620     */
621    public function isFaqAccessibleForUser(int $faqId): bool
622    {
623        $currentLanguage = $this->getCurrentLanguage();
624
625        return (
626            !$this->faqRepository->hasTranslation($faqId, $currentLanguage)
627            || $this->faqRepository->isFaqVisibleForUser(
628                $faqId,
629                $currentLanguage,
630                $this->user,
631                $this->groups,
632                $this->groupSupport,
633            )
634        );
635    }
636
637    /**
638     * Resolves the current language, preferring the already-detected static value
639     * so callers without a Language object registered in the Configuration
640     * (e.g. plain API requests) still work.
641     */
642    private function getCurrentLanguage(): string
643    {
644        return Language::$language !== ''
645            ? strtolower(Language::$language)
646            : $this->configuration->getLanguage()->getLanguage();
647    }
648
649    /**
650     * Returns a FAQ by ID and category ID.
651     *
652     * @param int $faqId FAQ ID
653     * @param int $categoryId Category ID
654     * @return array<string, mixed>
655     * @throws Exception
656     */
657    public function getFaqByIdAndCategoryId(int $faqId, int $categoryId, bool $onlyActive = true): array
658    {
659        $row = $this->faqRepository->fetchFaqByIdAndCategoryId(
660            $faqId,
661            $categoryId,
662            $onlyActive,
663            $this->user,
664            $this->groups,
665            $this->groupSupport,
666        );
667
668        if ($row instanceof stdClass) {
669            $question = (string) $row->question;
670            $url = sprintf(
671                '%scontent/%d/%d/%s/%s.html',
672                $this->configuration->getDefaultUrl(),
673                (int) $row->category_id,
674                (int) $row->id,
675                (string) $row->lang,
676                TitleSlugifier::slug($question),
677            );
678
679            $link = new Link($url, $this->configuration);
680            $link->setTitle($question);
681
682            return [
683                'id' => (int) $row->id,
684                'lang' => $row->lang,
685                'solution_id' => (int) $row->solution_id,
686                'revision_id' => (int) $row->revision_id,
687                'active' => $row->active,
688                'sticky' => (int) $row->sticky,
689                'keywords' => $row->keywords,
690                'question' => $question,
691                'answer' => $row->answer,
692                'author' => $row->author,
693                'email' => $row->email,
694                'comment' => $row->comment,
695                'updated' => $row->updated,
696                'date_start' => $row->date_start,
697                'date_end' => $row->date_end,
698                'created' => $row->created,
699                'category_id' => (int) $row->category_id,
700                'link' => $link->toString(),
701            ];
702        }
703
704        return [];
705    }
706
707    /**
708     * Creates a new FAQ.
709     */
710    public function create(FaqEntity $faqEntity): FaqEntity
711    {
712        $this->getTenantQuotaEnforcer()->assertCanCreateFaq();
713
714        if (is_null($faqEntity->getId())) {
715            $faqEntity->setId($this->configuration->getDb()->nextId(Database::getTablePrefix() . 'faqdata', 'id'));
716        }
717
718        // Only assign a new solutionId if none was provided (or invalid)
719        $solutionId = $faqEntity->getSolutionId();
720        if ($solutionId === null || $solutionId <= 0) {
721            $faqEntity->setSolutionId($this->getNextSolutionId());
722        }
723
724        $faqEntity->setRevisionId(0);
725
726        $this->faqRepository->insert($faqEntity);
727
728        return $faqEntity;
729    }
730
731    private function getTenantQuotaEnforcer(): TenantQuotaEnforcer
732    {
733        return $this->tenantQuotaEnforcer ??= TenantQuotaEnforcer::createFromDatabaseDriver(
734            $this->configuration->getDb(),
735        );
736    }
737
738    /**
739     * Gets the latest solution id for a FAQ record.
740     */
741    public function getNextSolutionId(): int
742    {
743        return $this->faqRepository->getNextSolutionId();
744    }
745
746    public function update(FaqEntity $faqEntity): FaqEntity
747    {
748        $this->faqRepository->update($faqEntity);
749
750        return $faqEntity;
751    }
752
753    /**
754     * Deletes a record and all the dependencies.
755     *
756     * @param int    $faqId   Record id
757     * @param string $faqLang Record language
758     * @throws Attachment\AttachmentException
759     * @throws Attachment\Filesystem\File\FileException
760     */
761    public function delete(int $faqId, string $faqLang): bool
762    {
763        $solutionId = $this->getSolutionIdFromId($faqId, $faqLang);
764
765        $this->faqRepository->deleteByIdAndLanguage($faqId, $faqLang);
766
767        // Delete possible attachments
768        $attachments = AttachmentFactory::fetchByRecordId($this->configuration, $faqId);
769        foreach ($attachments as $attachment) {
770            $currentAttachment = AttachmentFactory::create($attachment->getId());
771            $currentAttachment->delete();
772        }
773
774        // Delete possible Elasticsearch documents
775        if ($this->configuration->get(item: 'search.enableElasticsearch')) {
776            $elasticsearch = new Elasticsearch($this->configuration);
777            $elasticsearch->delete($solutionId);
778        }
779
780        return true;
781    }
782
783    /**
784     * Returns the solution ID from a given ID and language
785     */
786    public function getSolutionIdFromId(int $faqId, string $faqLang): int
787    {
788        return $this->faqRepository->getSolutionIdFromId($faqId, $faqLang);
789    }
790
791    /**
792     * Checks if a FAQ is already translated.
793     *
794     * @param int    $faqId   FAQ ID
795     * @param string $faqLang FAQ language
796     */
797    public function hasTranslation(int $faqId, string $faqLang): bool
798    {
799        return $this->faqRepository->hasTranslation($faqId, $faqLang);
800    }
801
802    public function isActive(int $faqId, string $faqLang, string $commentType = 'faq'): bool
803    {
804        return $this->faqRepository->isActive($faqId, $faqLang, $commentType);
805    }
806
807    /**
808     * Returns an array with all data from a FAQ record.
809     *
810     * @param int $solutionId Solution ID
811     */
812    public function getFaqBySolutionId(int $solutionId): void
813    {
814        $row = $this->faqRepository->fetchRowBySolutionId($solutionId, $this->user, $this->groups, $this->groupSupport);
815
816        $this->faqRecord = [
817            // Ensure faqRecord has at least the requested solution_id to keep API stable
818            'solution_id' => $solutionId,
819        ];
820
821        if ($row instanceof \stdClass) {
822            $question = nl2br((string) $row->thema);
823            $content = (string) $row->content;
824            $active = 'yes' === $row->active;
825            $expired = date(format: 'YmdHis') > (string) $row->date_end;
826
827            if (!$active) {
828                $content = Translation::getString(key: 'err_inactiveArticle');
829            }
830
831            if ($expired) {
832                $content = Translation::getString(key: 'err_expiredArticle');
833            }
834
835            $this->faqRecord = [
836                'id' => $row->id,
837                'lang' => $row->lang,
838                'solution_id' => $row->solution_id,
839                'revision_id' => $row->revision_id,
840                'active' => $row->active,
841                'sticky' => $row->sticky,
842                'keywords' => $row->keywords,
843                'title' => $question,
844                'content' => $content,
845                'author' => $row->author,
846                'email' => $row->email,
847                'comment' => $row->comment,
848                'date' => Date::createIsoDate((string) $row->updated),
849                'dateStart' => $row->date_start,
850                'dateEnd' => $row->date_end,
851                'notes' => $row->notes,
852                'created' => $row->created,
853            ];
854        }
855    }
856
857    /**
858     * Gets the record ID from a given solution ID.
859     *
860     * @param int $solutionId Solution ID
861     */
862    public function getIdFromSolutionId(int $solutionId): array
863    {
864        return $this->faqRepository->getIdFromSolutionId($solutionId, $this->user, $this->groups, $this->groupSupport);
865    }
866
867    /**
868     * Returns an array with all data from all FAQ records.
869     *
870     * @param int        $sortType  Sorting type
871     * @param array<string, mixed>|null $condition Condition
872     * @param ?string    $sortOrder Sorting order
873     */
874    public function getAllFaqs(
875        int $sortType = self::SORTING_TYPE_CATID_FAQID,
876        ?array $condition = null,
877        ?string $sortOrder = 'ASC',
878    ): void {
879        $sortDirection = $this->normalizeSortDirection((string) $sortOrder);
880        $orderBy = match ($sortType) {
881            self::SORTING_TYPE_CATID_FAQID => sprintf('ORDER BY fcr.category_id, fd.id %s', $sortDirection),
882            self::SORTING_TYPE_FAQID => sprintf('ORDER BY fd.id %s', $sortDirection),
883            self::SORTING_TYPE_FAQTITLE_FAQID => sprintf('ORDER BY fcr.category_id, fd.thema %s', $sortDirection),
884            self::SORTING_TYPE_DATE_FAQID => sprintf('ORDER BY fcr.category_id, fd.updated %s', $sortDirection),
885            default => '',
886        };
887
888        $rows = $this->faqRepository->fetchAllFaqs(
889            $condition,
890            $orderBy,
891            $this->user,
892            $this->groups,
893            $this->groupSupport,
894        );
895
896        foreach ($rows as $row) {
897            $content = (string) $row->content;
898            $active = 'yes' === $row->active;
899            $expired = date(format: 'YmdHis') > (string) $row->date_end;
900
901            if (!$active) {
902                $content = Translation::getString(key: 'err_inactiveArticle');
903            }
904
905            if ($expired) {
906                $content = Translation::getString(key: 'err_expiredArticle');
907            }
908
909            $this->faqRecords[] = [
910                'id' => $row->id,
911                'category_id' => $row->category_id,
912                'lang' => $row->lang,
913                'solution_id' => $row->solution_id,
914                'revision_id' => $row->revision_id,
915                'active' => $row->active,
916                'sticky' => $row->sticky,
917                'keywords' => $row->keywords,
918                'title' => $row->thema,
919                'content' => $content,
920                'author' => $row->author,
921                'email' => $row->email,
922                'comment' => $row->comment,
923                'updated' => Date::createIsoDate((string) $row->updated, 'Y-m-d H:i:s'),
924                'dateStart' => $row->date_start,
925                'dateEnd' => $row->date_end,
926                'created' => $row->created,
927                'notes' => $row->notes,
928            ];
929        }
930    }
931
932    /**
933     * Returns the FAQ question from the ID.
934     *
935     * @param int $faqId Record id
936     */
937    public function getQuestion(int $faqId): string
938    {
939        if (array_key_exists('id', $this->faqRecord) && $this->faqRecord['id'] === $faqId) {
940            return (string) $this->faqRecord['title'];
941        }
942
943        $question = $this->faqRepository->fetchQuestion($faqId, $this->configuration->getLanguage()->getLanguage());
944
945        return $question ?? Translation::getString(key: 'no_cats');
946    }
947
948    /**
949     * Returns the keywords of a FAQ from the ID.
950     *
951     * @param int $faqId record id
952     */
953    public function getKeywords(int $faqId): string
954    {
955        if (array_key_exists('id', $this->faqRecord) && $this->faqRecord['id'] === $faqId) {
956            return (string) $this->faqRecord['keywords'];
957        }
958
959        $keywords = $this->faqRepository->fetchKeywords($faqId, $this->configuration->getLanguage()->getLanguage());
960
961        return $keywords === null ? '' : Strings::htmlspecialchars($keywords, ENT_QUOTES);
962    }
963
964    /**
965     * Retrieve faq records according to the constraints provided.
966     */
967    public function get(
968        string $queryType = self::QUERY_TYPE_DEFAULT,
969        int $categoryId = 0,
970        bool $downwards = true,
971        string $lang = '',
972        string $date = '',
973    ): array {
974        $faqs = [];
975
976        $queryHelper = new QueryHelper($this->user, $this->groups);
977        $query = $queryHelper->getQuery($queryType, $categoryId, $downwards, $lang, $date);
978        $result = $this->configuration->getDb()->query($query);
979
980        if ($this->configuration->getDb()->numRows($result) > 0) {
981            $i = 0;
982            while (true) {
983                $row = $this->configuration->getDb()->fetchObject($result);
984                if (!$row instanceof stdClass) {
985                    break;
986                }
987
988                $faq = [];
989                $faq['id'] = $row->id;
990                $faq['solution_id'] = $row->solution_id;
991                $faq['revision_id'] = $row->revision_id;
992                $faq['lang'] = $row->lang;
993                $faq['category_id'] = $row->category_id;
994                $faq['active'] = $row->active;
995                $faq['sticky'] = $row->sticky;
996                $faq['keywords'] = $row->keywords;
997                $faq['topic'] = $row->thema;
998                $faq['content'] = $row->content;
999                $faq['author_name'] = $row->author;
1000                $faq['author_email'] = $row->email;
1001                $faq['comment_enable'] = $row->comment;
1002                $faq['lastmodified'] = $row->updated;
1003                $faq['hits'] = $row->visits;
1004                $faq['hits_last'] = $row->last_visit;
1005                $faq['notes'] = $row->notes;
1006                $faqs[$i] = $faq;
1007                ++$i;
1008            }
1009        }
1010
1011        return $faqs;
1012    }
1013
1014    /**
1015     * Returns the sticky records
1016     * with URL, Faq_ID, Category_ID, Language and Title.
1017     */
1018    public function getStickyFaqsData(): array
1019    {
1020        $rows = $this->faqRepository->fetchStickyFaqs($this->user, $this->groups, $this->groupSupport);
1021        $sticky = [];
1022        $data = [];
1023
1024        $oldId = 0;
1025        foreach ($rows as $row) {
1026            $faqId = (int) $row->id;
1027            if ($oldId !== $faqId) {
1028                $question = (string) $row->thema;
1029                $data['question'] = $question;
1030
1031                $url = sprintf(
1032                    '%scontent/%d/%d/%s/%s.html',
1033                    $this->configuration->getDefaultUrl(),
1034                    (int) $row->category_id,
1035                    $faqId,
1036                    (string) $row->lang,
1037                    TitleSlugifier::slug($question),
1038                );
1039                $oLink = new Link($url, $this->configuration);
1040                $oLink->setTitle($question);
1041                $oLink->tooltip = $question;
1042                $data['url'] = $oLink->toString();
1043                $data['id'] = $faqId;
1044                $data['order'] = (int) $row->sticky_order;
1045                $data['category_id'] = (int) $row->category_id;
1046                $data['lang'] = (string) $row->lang;
1047
1048                $sticky[] = $data;
1049            }
1050
1051            $oldId = $faqId;
1052        }
1053
1054        // Sort stickyData by order if activated
1055        if ($this->configuration->get(item: 'records.orderStickyFaqsCustom') === true) {
1056            usort($sticky, $this->sortStickyArrayByOrder(...));
1057        }
1058
1059        return $sticky;
1060    }
1061
1062    /**
1063     * Comparison function for usort() of sticky faqs.
1064     */
1065    private function sortStickyArrayByOrder(array $first, array $second): int
1066    {
1067        return (int) $first['order'] - (int) $second['order'];
1068    }
1069
1070    /**
1071     * @return array{string, string}
1072     */
1073    private function normalizeCategoryOrder(string $orderBy): array
1074    {
1075        return match ($orderBy) {
1076            'visits' => ['fv', 'visits'],
1077            'updated' => ['fd', 'updated'],
1078            'created' => ['fd', 'created'],
1079            'thema', 'question' => ['fd', 'thema'],
1080            'sticky' => ['fd', 'sticky'],
1081            'sticky_order' => ['fd', 'sticky_order'],
1082            default => ['fd', 'id'],
1083        };
1084    }
1085
1086    private function normalizeFaqOrderBy(string $orderBy): string
1087    {
1088        return match ($orderBy) {
1089            'fv.visits', 'visits' => 'fv.visits',
1090            'fd.updated', 'updated' => 'fd.updated',
1091            'fd.created', 'created' => 'fd.created',
1092            'fd.thema', 'thema', 'question' => 'fd.thema',
1093            default => 'fd.id',
1094        };
1095    }
1096
1097    private function normalizeSortDirection(string $sortBy): string
1098    {
1099        return strtoupper($sortBy) === 'DESC' ? 'DESC' : 'ASC';
1100    }
1101
1102    /**
1103     * @param array<int|string> $faqIds
1104     */
1105    private function normalizeFaqIds(array $faqIds): string
1106    {
1107        $normalizedFaqIds = array_map(static fn($faqId): int => (int) $faqId, $faqIds);
1108
1109        return $normalizedFaqIds === [] ? '0' : implode(', ', $normalizedFaqIds);
1110    }
1111
1112    public function hasTitleAHash(string $title): bool
1113    {
1114        return (bool) strpos(haystack: $title, needle: '#');
1115    }
1116}