Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
51.43% covered (warning)
51.43%
36 / 70
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExportHandler
51.43% covered (warning)
51.43%
36 / 70
50.00% covered (danger)
50.00%
1 / 2
90.00
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
 __invoke
50.72% covered (warning)
50.72%
35 / 69
0.00% covered (danger)
0.00%
0 / 1
86.29
1<?php
2
3/**
4 * Handler for queued exports.
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 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     2026-02-11
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Queue\Handler;
21
22use Closure;
23use phpMyFAQ\Category;
24use phpMyFAQ\Configuration;
25use phpMyFAQ\Enums\PermissionType;
26use phpMyFAQ\Export;
27use phpMyFAQ\Faq;
28use phpMyFAQ\Mail;
29use phpMyFAQ\Queue\Message\ExportMessage;
30use phpMyFAQ\User;
31use RuntimeException;
32
33final readonly class ExportHandler
34{
35    public function __construct(
36        private Configuration $configuration,
37        private ?Closure $userFactory = null,
38        private ?Closure $faqFactory = null,
39        private ?Closure $categoryFactory = null,
40        private ?Closure $exportFactory = null,
41        private ?Closure $mailFactory = null,
42    ) {
43    }
44
45    public function __invoke(ExportMessage $message): void
46    {
47        $user = null;
48        if ($this->userFactory instanceof Closure) {
49            $createdUser = ($this->userFactory)();
50            if ($createdUser instanceof User) {
51                $user = $createdUser;
52            }
53        }
54
55        $user ??= new User($this->configuration);
56        if (!$user->getUserById($message->userId)) {
57            throw new RuntimeException(sprintf('Export requested by unknown user ID %d', $message->userId));
58        }
59
60        if (!$user->perm->hasPermission($message->userId, PermissionType::EXPORT->value)) {
61            throw new RuntimeException(sprintf('User ID %d does not have export permission', $message->userId));
62        }
63
64        $faq = null;
65        if ($this->faqFactory instanceof Closure) {
66            $createdFaq = ($this->faqFactory)();
67            if ($createdFaq instanceof Faq) {
68                $faq = $createdFaq;
69            }
70        }
71
72        $faq ??= new Faq($this->configuration);
73
74        $category = null;
75        if ($this->categoryFactory instanceof Closure) {
76            $createdCategory = ($this->categoryFactory)();
77            if ($createdCategory instanceof Category) {
78                $category = $createdCategory;
79            }
80        }
81
82        $category ??= new Category($this->configuration);
83
84        $exporter = null;
85        if ($this->exportFactory instanceof Closure) {
86            // The factory seam is duck-typed on purpose so tests can inject lightweight fakes
87            $createdExporter = ($this->exportFactory)($faq, $category, $message->format);
88            if (is_object($createdExporter) && method_exists($createdExporter, 'generate')) {
89                $exporter = $createdExporter;
90            }
91        }
92
93        $exporter ??= Export::create($faq, $category, $this->configuration, $message->format);
94        $content = (string) $exporter->generate(
95            categoryId: (int) ($message->options['categoryId'] ?? 0),
96            downwards: (bool) ($message->options['downwards'] ?? true),
97            language: (string) ($message->options['language'] ?? ''),
98        );
99
100        if ($content === '') {
101            throw new RuntimeException('Export generated empty content');
102        }
103
104        $exportDir = (string) PMF_ROOT_DIR . '/content/user/exports';
105        if (
106            !is_dir($exportDir)
107            && !mkdir(directory: $exportDir, permissions: 0o775, recursive: true)
108            && !is_dir($exportDir)
109        ) {
110            throw new RuntimeException('Unable to create export directory: ' . $exportDir);
111        }
112
113        $extension = $message->format === 'json' ? 'json' : 'pdf';
114        $filename = sprintf('export-%d-%s.%s', $message->userId, date('Y-m-d-H-i-s'), $extension);
115        $filePath = $exportDir . '/' . $filename;
116
117        if (file_put_contents($filePath, $content) === false) {
118            throw new RuntimeException('Unable to write export file: ' . $filePath);
119        }
120
121        $email = $user->getUserData('email');
122        if (is_string($email) && $email !== '') {
123            try {
124                $mail = null;
125                if ($this->mailFactory instanceof Closure) {
126                    $createdMail = ($this->mailFactory)();
127                    if ($createdMail instanceof Mail) {
128                        $mail = $createdMail;
129                    }
130                }
131
132                $mail ??= new Mail($this->configuration);
133                $mail->addTo($email);
134                $mail->subject = 'Your phpMyFAQ export is ready';
135                $mail->message = sprintf(
136                    'Your %s export has been generated and is available for download: %s',
137                    strtoupper($message->format),
138                    $filename,
139                );
140                $mail->send();
141            } catch (\Throwable $e) {
142                error_log(sprintf(
143                    'ExportHandler: failed to send notification email to %s for %s export file %s: %s',
144                    $email,
145                    $message->format,
146                    $filename,
147                    $e->getMessage(),
148                ));
149            }
150        }
151    }
152}