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