Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
61.03% covered (warning)
61.03%
83 / 136
60.00% covered (warning)
60.00%
9 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReminderService
61.03% covered (warning)
61.03%
83 / 136
60.00% covered (warning)
60.00%
9 / 15
129.02
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
 getSettings
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 save
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 saveConfig
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
4
 normalizeTime
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getEffectiveSettings
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 saveSystemSettings
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 scheduleJob
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 getStartTime
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
3.14
 sendReminders
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 fetchJob
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 willNotify
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 isMaxReached
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 shouldNotifyAfterSomeDays
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getNotificationsSummarized
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service;
10
11use DateTime;
12use OCA\Libresign\BackgroundJob\Reminder;
13use OCA\Libresign\Db\SignRequestMapper;
14use OCA\Libresign\Exception\LibresignException;
15use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod;
16use OCA\Libresign\Service\Policy\PolicyService;
17use OCA\Libresign\Service\Policy\Provider\Reminder\ReminderPolicy;
18use OCA\Libresign\Service\Policy\Provider\Reminder\ReminderPolicyValue;
19use OCP\AppFramework\Utility\ITimeFactory;
20use OCP\BackgroundJob\IJobList;
21use OCP\IDateTimeZone;
22use Psr\Log\LoggerInterface;
23
24class ReminderService {
25    public function __construct(
26        protected IJobList $jobList,
27        protected PolicyService $policyService,
28        protected IDateTimeZone $dateTimeZone,
29        protected ITimeFactory $time,
30        protected SignRequestMapper $signRequestMapper,
31        protected IdentifyMethodService $identifyMethodService,
32        protected LoggerInterface $logger,
33    ) {
34    }
35
36    public function getSettings(): array {
37        $settings = $this->getEffectiveSettings();
38        $settings['next_run'] = null;
39        foreach ($this->jobList->getJobsIterator(Reminder::class, 1, 0) as $job) {
40            $details = $this->jobList->getDetailsById($job->getId());
41            $settings['next_run'] = new \DateTime('@' . $details['last_checked'], new \DateTimeZone('UTC'));
42        }
43        return $settings;
44    }
45
46    public function save(
47        int $daysBefore,
48        int $daysBetween,
49        int $max,
50        string $sendTimer,
51    ): array {
52        $config = $this->saveConfig($daysBefore, $daysBetween, $max, $sendTimer);
53        $config['next_run'] = $this->scheduleJob($config['send_timer']);
54        return $config;
55    }
56
57    protected function saveConfig(
58        int $daysBefore,
59        int $daysBetween,
60        int $max,
61        string $sendTimer,
62    ): array {
63        if ($daysBetween <= 0
64            || $daysBefore <= 0
65            || $max <= 0
66        ) {
67            $normalized = [
68                'days_before' => 0,
69                'days_between' => 0,
70                'max' => 0,
71                'send_timer' => '',
72            ];
73
74            $this->saveSystemSettings($normalized);
75            return $normalized;
76        }
77
78        $sendTimer = $this->normalizeTime($sendTimer);
79
80        $normalized = [
81            'days_before' => $daysBefore,
82            'days_between' => $daysBetween,
83            'max' => $max,
84            'send_timer' => $sendTimer,
85        ];
86
87        $this->saveSystemSettings($normalized);
88        return $normalized;
89    }
90
91    private function normalizeTime(string $time): string {
92        if (!$time || !preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time)) {
93            return '10:00';
94        }
95        return $time;
96    }
97
98    /** @return array{days_before: int, days_between: int, max: int, send_timer: string} */
99    private function getEffectiveSettings(): array {
100        $resolvedValue = $this->policyService->resolve(ReminderPolicy::KEY)->getEffectiveValue();
101        return ReminderPolicyValue::normalize($resolvedValue);
102    }
103
104    /** @param array{days_before: int, days_between: int, max: int, send_timer: string} $settings */
105    private function saveSystemSettings(array $settings): void {
106        $allowChildOverride = $this->policyService->getSystemPolicy(ReminderPolicy::KEY)?->isAllowChildOverride() ?? false;
107        $this->policyService->saveSystem(
108            ReminderPolicy::KEY,
109            ReminderPolicyValue::encode($settings),
110            $allowChildOverride,
111        );
112    }
113
114    protected function scheduleJob(string $startTime): ?DateTime {
115        $this->jobList->remove(
116            Reminder::class,
117        );
118
119        if ($startTime === '') {
120            return null;
121        }
122        $runAfter = $this->getStartTime($startTime);
123        if (!$runAfter) {
124            return null;
125        }
126
127        $this->jobList->scheduleAfter(
128            Reminder::class,
129            $runAfter->getTimestamp(),
130        );
131        return $runAfter;
132    }
133
134    protected function getStartTime(string $startTime): ?\DateTime {
135        $timezone = $this->dateTimeZone->getTimeZone();
136
137        $now = $this->time->getDateTime('now', new \DateTimeZone('UTC'));
138        $dateTime = clone $now;
139
140        try {
141            $time = new \DateTime($startTime, $timezone);
142        } catch (\Exception $e) {
143            $this->logger->error('Failed to parse reminder send time: ' . $e->getMessage());
144            return null;
145        }
146        // 'G' = 24-hour format hour (no leading zeros),
147        // 'i' = minutes with leading zeros
148        $dateTime->setTime((int)$time->format('G'), (int)$time->format('i'));
149        $dateTime->setTimezone(new \DateTimeZone('UTC'));
150        if ($dateTime <= $now) {
151            $dateTime->modify('+1 day');
152        }
153
154        return $dateTime;
155    }
156
157    public function sendReminders(): void {
158        $settings = $this->getEffectiveSettings();
159
160        $daysBefore = $settings['days_before'];
161        if ($daysBefore <= 0) {
162            return;
163        }
164
165        $daysBetween = $settings['days_between'];
166        if ($daysBetween <= 0) {
167            return;
168        }
169
170        $max = $settings['max'];
171        if ($max === 0) {
172            return;
173        }
174
175        foreach ($this->fetchJob($daysBefore, $daysBetween, $max) as $job) {
176            // Repeat of a daily reminder run must not submit twice.
177            $entity = $job->getEntity();
178            $eventId = sprintf(
179                'reminder:%d:%d:%s',
180                $entity->getSignRequestId(),
181                $entity->getId() ?? 0,
182                $this->time->getDateTime('now', new \DateTimeZone('UTC'))->format('Y-m-d'),
183            );
184            $job->notify($eventId);
185        }
186    }
187
188    /**
189     * @return \Generator<IIdentifyMethod>
190     */
191    protected function fetchJob(int $daysBefore, int $daysBetween, int $max): \Generator {
192        $now = $this->time->getDateTime('now', $this->dateTimeZone->getTimeZone());
193        foreach ($this->signRequestMapper->findRemindersCandidates() as $entityIdentifyMethod) {
194            $signRequest = $this->signRequestMapper->getById($entityIdentifyMethod->getSignRequestId());
195
196            $metadata = $signRequest->getMetadata();
197            $summarized = $this->getNotificationsSummarized($metadata['notify'] ?? []);
198            if (!$this->willNotify($summarized, $now, $daysBefore, $daysBetween, $max)) {
199                continue;
200            }
201
202            $this->identifyMethodService->setCurrentIdentifyMethod($entityIdentifyMethod);
203            try {
204                $identifyMethod = $this->identifyMethodService->getInstanceOfIdentifyMethod(
205                    $entityIdentifyMethod->getIdentifierKey(),
206                    $entityIdentifyMethod->getIdentifierValue(),
207                );
208            } catch (LibresignException $e) {
209                $this->logger->error('Failed to get instance of identify method', [
210                    'error' => $e->getMessage(),
211                    'identifier_key' => $entityIdentifyMethod->getIdentifierKey(),
212                    'identifier_value' => $entityIdentifyMethod->getIdentifierValue(),
213                    'sign_request_id' => $entityIdentifyMethod->getSignRequestId(),
214                    'metadata' => $metadata,
215                ]);
216                continue;
217            }
218            yield $identifyMethod;
219        };
220    }
221
222    protected function willNotify(array $summarized, \DateTime $now, int $daysBefore, int $daysBetween, int $max): bool {
223        if ($this->isMaxReached($summarized['total'], $max)) {
224            return false;
225        }
226
227        if ($daysBetween === 0) {
228            return false;
229        }
230
231        if ($summarized['total'] === 1) {
232            return $this->shouldNotifyAfterSomeDays($summarized['first'], $now, $daysBefore);
233        }
234        return $this->shouldNotifyAfterSomeDays($summarized['last'], $now, $daysBetween);
235    }
236
237    private function isMaxReached(int $total, int $max): bool {
238        return $max > 0 && $total >= $max;
239    }
240
241    private function shouldNotifyAfterSomeDays(?\DateTime $date, \DateTime $now, int $maxDays): bool {
242        $daysAfter = $date?->diff($now)?->days ?? 0;
243        return $maxDays > 0 && $daysAfter >= $maxDays;
244    }
245
246    protected function getNotificationsSummarized(array $notifications): array {
247        if (empty($notifications)) {
248            return [
249                'first' => null,
250                'last' => null,
251                'total' => 0,
252            ];
253        }
254        $dates = [];
255        $timeZone = new \DateTimeZone('UTC');
256        foreach ($notifications as $notification) {
257            $dateTime = new \DateTime('@' . $notification['date'], $timeZone);
258            $dateTime->setTime(0, 0, 0);
259            $dates[$dateTime->format('Y-m-d')] = $dateTime;
260        }
261        $dates = array_values($dates);
262        usort($dates, fn ($a, $b) => $a <=> $b);
263        return [
264            'first' => $dates[0],
265            'last' => end($dates),
266            'total' => count($dates),
267        ];
268    }
269}