Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.41% covered (warning)
81.41%
127 / 156
50.00% covered (danger)
50.00%
7 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
NotifyCommand
81.41% covered (warning)
81.41%
127 / 156
50.00% covered (danger)
50.00%
7 / 14
45.79
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 msg
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 warn
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 err
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 validateSchedule
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 getUserForSearch
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
5.67
 setLanguage
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 getObjectForSearch
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
3.04
 getNewRecords
87.88% covered (warning)
87.88%
29 / 33
0.00% covered (danger)
0.00%
0 / 1
6.06
 buildEmail
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
1
 sendEmail
33.33% covered (danger)
33.33%
6 / 18
0.00% covered (danger)
0.00%
0 / 1
5.67
 processViewAlerts
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
8.25
1<?php
2
3/**
4 * Console command: notify users of scheduled searches.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2020.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Console
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development Wiki
28 */
29
30namespace VuFindConsole\Command\ScheduledSearch;
31
32use DateTime;
33use Exception;
34use Laminas\Config\Config;
35use Laminas\View\Renderer\PhpRenderer;
36use Symfony\Component\Console\Attribute\AsCommand;
37use Symfony\Component\Console\Command\Command;
38use Symfony\Component\Console\Input\InputInterface;
39use Symfony\Component\Console\Output\OutputInterface;
40use VuFind\Crypt\SecretCalculator;
41use VuFind\Db\Entity\SearchEntityInterface;
42use VuFind\Db\Entity\UserEntityInterface;
43use VuFind\Db\Service\SearchServiceInterface;
44use VuFind\I18n\Locale\LocaleSettings;
45use VuFind\I18n\Translator\TranslatorAwareInterface;
46use VuFind\Mailer\Mailer;
47use VuFind\Search\Results\PluginManager as ResultsManager;
48
49use function count;
50use function in_array;
51use function sprintf;
52
53/**
54 * Console command: notify users of scheduled searches.
55 *
56 * @category VuFind
57 * @package  Console
58 * @author   Demian Katz <demian.katz@villanova.edu>
59 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
60 * @link     https://vufind.org/wiki/development Wiki
61 */
62#[AsCommand(
63    name: 'scheduledsearch/notify',
64    description: 'Scheduled Search Notifier'
65)]
66class NotifyCommand extends Command implements TranslatorAwareInterface
67{
68    use \VuFind\I18n\Translator\TranslatorAwareTrait;
69    use \VuFind\I18n\Translator\LanguageInitializerTrait;
70
71    /**
72     * Output interface
73     *
74     * @var OutputInterface
75     */
76    protected $output = null;
77
78    /**
79     * Useful date format value
80     *
81     * @var string
82     */
83    protected $iso8601 = 'Y-m-d\TH:i:s\Z';
84
85    /**
86     * URL helper
87     *
88     * @var \Laminas\View\Helper\Url
89     */
90    protected $urlHelper;
91
92    /**
93     * Number of results to retrieve when performing searches
94     *
95     * @var int
96     */
97    protected $limit = 50;
98
99    /**
100     * Constructor
101     *
102     * @param SecretCalculator       $secretCalculator Secret calculator
103     * @param PhpRenderer            $renderer         View renderer
104     * @param ResultsManager         $resultsManager   Search results plugin manager
105     * @param array                  $scheduleOptions  Configured schedule options
106     * @param Config                 $mainConfig       Top-level VuFind configuration
107     * @param Mailer                 $mailer           Mail service
108     * @param SearchServiceInterface $searchService    Search table
109     * @param LocaleSettings         $localeSettings   Locale settings object
110     * @param string|null            $name             The name of the command; passing
111     * null means it must be set in configure()
112     */
113    public function __construct(
114        protected SecretCalculator $secretCalculator,
115        protected PhpRenderer $renderer,
116        protected ResultsManager $resultsManager,
117        protected array $scheduleOptions,
118        protected Config $mainConfig,
119        protected Mailer $mailer,
120        protected SearchServiceInterface $searchService,
121        protected LocaleSettings $localeSettings,
122        $name = null
123    ) {
124        $this->urlHelper = $renderer->plugin('url');
125        parent::__construct($name);
126    }
127
128    /**
129     * Configure the command.
130     *
131     * @return void
132     */
133    protected function configure()
134    {
135        $this->setHelp('Sends scheduled search email notifications.');
136    }
137
138    /**
139     * Display a message.
140     *
141     * @param string $msg Message to display
142     *
143     * @return void
144     */
145    protected function msg($msg)
146    {
147        if (null !== $this->output) {
148            $this->output->writeln($msg);
149        }
150    }
151
152    /**
153     * Display a warning.
154     *
155     * @param string $msg Message to display
156     *
157     * @return void
158     */
159    protected function warn($msg)
160    {
161        $this->msg('WARNING: ' . $msg);
162    }
163
164    /**
165     * Display an error.
166     *
167     * @param string $msg Message to display
168     *
169     * @return void
170     */
171    protected function err($msg)
172    {
173        $this->msg('ERROR: ' . $msg);
174    }
175
176    /**
177     * Run the command.
178     *
179     * @param InputInterface  $input  Input object
180     * @param OutputInterface $output Output object
181     *
182     * @return int 0 for success
183     *
184     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
185     */
186    protected function execute(InputInterface $input, OutputInterface $output)
187    {
188        $this->output = $output;
189        $this->processViewAlerts();
190        // Disconnect mailer to prevent exceptions from an attempt to gracefully
191        // close the connection on teardown
192        $this->mailer->resetConnection();
193        return 0;
194    }
195
196    /**
197     * Validate the schedule (return true if we should send a message).
198     *
199     * @param \DateTime             $todayTime The time the notification job started.
200     * @param \DateTime             $lastTime  Last time notification was sent.
201     * @param SearchEntityInterface $s         Search row to validate.
202     *
203     * @return bool
204     */
205    protected function validateSchedule($todayTime, $lastTime, $s)
206    {
207        $schedule = $s->getNotificationFrequency();
208        if (!isset($this->scheduleOptions[$schedule])) {
209            $this->err('Search ' . $s->getId() . ": unknown schedule: $schedule");
210            return false;
211        }
212        $diff = $todayTime->diff($lastTime);
213        if ($diff->days < $schedule) {
214            $this->msg(
215                '  Bypassing search ' . $s->getId() . ': previous execution too recent ('
216                . $this->scheduleOptions[$schedule] . ', '
217                . $lastTime->format($this->iso8601) . ')'
218            );
219            return false;
220        }
221        return true;
222    }
223
224    /**
225     * Load and validate a user object associated with the search; return null
226     * if there is a problem.
227     *
228     * @param SearchEntityInterface $s Current search row.
229     *
230     * @return ?UserEntityInterface
231     */
232    protected function getUserForSearch($s)
233    {
234        if (!$user = $s->getUser()) {
235            $this->warn('Search ' . $s->getId() . ': is missing user data.');
236            return null;
237        }
238        if (!$user->getEmail()) {
239            $this->warn(
240                'User ' . $user->getUsername() . ' does not have an email address, bypassing alert ' . $s->getId()
241            );
242            return null;
243        }
244        return $user;
245    }
246
247    /**
248     * Set up the translator language.
249     *
250     * @param string $userLang User language preference from database (may be empty).
251     *
252     * @return void
253     */
254    protected function setLanguage($userLang)
255    {
256        // Start with default language setting; override with user language
257        // preference if set and valid. Default to English if configuration
258        // is missing.
259        $language = $this->localeSettings->getDefaultLocale();
260        $allLanguages = array_keys($this->localeSettings->getEnabledLocales());
261        if ($userLang != '' && in_array($userLang, $allLanguages)) {
262            $language = $userLang;
263        }
264        $this->translator->setLocale($language);
265        $this->addLanguageToTranslator(
266            $this->translator,
267            $this->localeSettings,
268            $language
269        );
270    }
271
272    /**
273     * Load and validate the results object associated with the search; return false
274     * if there is a problem.
275     *
276     * @param SearchEntityInterface $s Current search row.
277     *
278     * @return \VuFind\Search\Base\Results|bool
279     */
280    protected function getObjectForSearch($s)
281    {
282        $minSO = $s->getSearchObject();
283        if (!$minSO) {
284            $this->err("Problem getting search object from search {$s->getId()}.");
285            return false;
286        }
287        $searchObject = $minSO->deminify($this->resultsManager);
288        if (!$searchObject->getOptions()->supportsScheduledSearch()) {
289            $this->err(
290                'Unsupported search backend ' . $searchObject->getBackendId()
291                . ' for search ' . $searchObject->getSearchId()
292            );
293            return false;
294        }
295        return $searchObject;
296    }
297
298    /**
299     * Given a search results object, fetch records that have changed since the last
300     * search. Return false on error.
301     *
302     * @param \VuFind\Search\Base\Results $searchObject Search results object
303     * @param \DateTime                   $lastTime     Last notification time
304     *
305     * @return array|bool
306     */
307    protected function getNewRecords($searchObject, $lastTime)
308    {
309        // Prepare query
310        $params = $searchObject->getParams();
311        $params->setLimit($this->limit);
312        $params->setSort('first_indexed desc', true);
313        $searchId = $searchObject->getSearchId();
314        try {
315            $records = $searchObject->getResults();
316        } catch (\Exception $e) {
317            $this->err("Error processing search $searchId" . $e->getMessage());
318            return false;
319        }
320        if (empty($records)) {
321            $this->msg(
322                "  No results found for search $searchId"
323            );
324            return false;
325        }
326        $newestRecordDate
327            = date($this->iso8601, strtotime($records[0]->getFirstIndexed() ?? ''));
328        $lastExecutionDate = $lastTime->format($this->iso8601);
329        if ($newestRecordDate < $lastExecutionDate) {
330            $this->msg(
331                "  No new results for search ($searchId): "
332                . "$newestRecordDate < $lastExecutionDate"
333            );
334            return false;
335        }
336        $this->msg(
337            "  New results for search ($searchId): "
338            . "$newestRecordDate >= $lastExecutionDate"
339        );
340        // Collect records that have been indexed (for the first time)
341        // after previous scheduled alert run
342        $newRecords = [];
343        foreach ($records as $record) {
344            $recDate = date($this->iso8601, strtotime($record->getFirstIndexed()));
345            if ($recDate < $lastExecutionDate) {
346                break;
347            }
348            $newRecords[] = $record;
349        }
350        return $newRecords;
351    }
352
353    /**
354     * Build the email message.
355     *
356     * @param SearchEntityInterface       $s            Search table row
357     * @param UserEntityInterface         $user         User owning search row
358     * @param \VuFind\Search\Base\Results $searchObject Search results object
359     * @param array                       $newRecords   New results in search
360     *
361     * @return string
362     */
363    protected function buildEmail($s, $user, $searchObject, $newRecords)
364    {
365        $viewBaseUrl = $searchUrl = $s->getNotificationBaseUrl();
366        $searchUrl .= ($this->urlHelper)(
367            $searchObject->getOptions()->getSearchAction()
368        ) . $searchObject->getUrlQuery()->getParams(false);
369        $secret = $this->secretCalculator->getSearchUnsubscribeSecret($s);
370        $unsubscribeUrl = $s->getNotificationBaseUrl()
371            . ($this->urlHelper)('myresearch-unsubscribe')
372            . "?id={$s->getId()}&key=$secret";
373        $userInstitution = $this->mainConfig->Site->institution;
374        $params = $searchObject->getParams();
375        // Filter function to only pass along selected checkboxes:
376        $selectedCheckboxes = function ($data) {
377            return $data['selected'] ?? false;
378        };
379        $viewParams = [
380            'user' => $user,
381            'records' => $newRecords,
382            'info' => [
383                'baseUrl' => $viewBaseUrl,
384                'description' => $params->getDisplayQuery(),
385                'recordCount' => count($newRecords),
386                'url' => $searchUrl,
387                'unsubscribeUrl' => $unsubscribeUrl,
388                'checkboxFilters' => array_filter(
389                    $params->getCheckboxFacets(),
390                    $selectedCheckboxes
391                ),
392                'filters' => $params->getFilterList(true),
393                'userInstitution' => $userInstitution,
394             ],
395        ];
396        return $this->renderer
397            ->render('Email/scheduled-alert.phtml', $viewParams);
398    }
399
400    /**
401     * Try to send an email message to a user. Return true on success, false on
402     * error.
403     *
404     * @param UserEntityInterface $user    User to email
405     * @param string              $message Email message body
406     *
407     * @return bool
408     */
409    protected function sendEmail($user, $message)
410    {
411        $subject = $this->mainConfig->Site->title
412            . ': ' . $this->translate('Scheduled Alert Results');
413        $from = $this->mainConfig->Site->email;
414        $to = $user->getEmail();
415        try {
416            $this->mailer->send($to, $from, $subject, $message);
417            return true;
418        } catch (\Exception $e) {
419            $this->msg(
420                'Initial email send failed; resetting connection and retrying...'
421            );
422        }
423        // If we got this far, the first attempt threw an exception; let's reset
424        // the connection, then try again....
425        $this->mailer->resetConnection();
426        try {
427            $this->mailer->send($to, $from, $subject, $message);
428        } catch (\Exception $e) {
429            $this->err(
430                "Failed to send message to {$user->getEmail()}" . $e->getMessage()
431            );
432            return false;
433        }
434        // If we got here, the retry was a success!
435        return true;
436    }
437
438    /**
439     * Send scheduled alerts for a view.
440     *
441     * @return void
442     */
443    protected function processViewAlerts()
444    {
445        $todayTime = new \DateTime();
446        $scheduled = $this->searchService->getScheduledSearches();
447        $this->msg(sprintf('Processing %d searches', count($scheduled)));
448        foreach ($scheduled as $s) {
449            $lastTime = $s->getLastNotificationSent();
450            if (
451                !$this->validateSchedule($todayTime, $lastTime, $s)
452                || !($user = $this->getUserForSearch($s))
453                || !($searchObject = $this->getObjectForSearch($s))
454                || !($newRecords = $this->getNewRecords($searchObject, $lastTime))
455            ) {
456                continue;
457            }
458            // Set email language
459            $this->setLanguage($user->getLastLanguage());
460
461            // Prepare email content
462            $message = $this->buildEmail($s, $user, $searchObject, $newRecords);
463            if (!$this->sendEmail($user, $message)) {
464                // If email send failed, move on to the next user without updating
465                // the database table.
466                continue;
467            }
468            try {
469                $s->setLastNotificationSent(new DateTime());
470                $this->searchService->persistEntity($s);
471            } catch (Exception) {
472                $this->err("Error updating last_executed date for search {$s->getId()}");
473            }
474        }
475        $this->msg('Done processing searches');
476    }
477}