Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.82% covered (success)
91.82%
146 / 159
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
NotificationDeliveryMapper
91.82% covered (success)
91.82%
146 / 159
78.57% covered (warning)
78.57%
11 / 14
31.53
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
 register
87.50% covered (warning)
87.50%
28 / 32
0.00% covered (danger)
0.00%
0 / 1
12.28
 findByEventKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 findForEvent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 claim
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 finish
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
6
 cancelPendingInvitations
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 findById
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 findPending
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 findDue
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 failPending
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 findStaleSending
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 markStaleUnknown
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 redactOlderThan
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Db;
10
11use OCA\Libresign\Enum\NotificationDeliveryState;
12use OCP\AppFramework\Db\DoesNotExistException;
13use OCP\AppFramework\Db\QBMapper;
14use OCP\DB\Exception as DatabaseException;
15use OCP\DB\QueryBuilder\IQueryBuilder;
16use OCP\IDBConnection;
17
18/** @template-extends QBMapper<NotificationDelivery> */
19class NotificationDeliveryMapper extends QBMapper {
20    public const MAX_ATTEMPTS = 5;
21    public function __construct(IDBConnection $db) {
22        parent::__construct($db, 'libresign_notification_delivery', NotificationDelivery::class);
23    }
24
25    /**
26     * Registers one delivery for an event, channel and recipient.
27     * A duplicate is never an instruction to resubmit an uncertain delivery.
28     *
29     * @return NotificationDelivery The new or existing record.
30     *                              A previously committed PENDING event may be safely claimed by a later
31     *                              dispatch when a process died before transport submission.
32     */
33    public function register(
34        string $eventKey,
35        string $correlationId,
36        string $eventType,
37        string $channel,
38        int $fileId,
39        ?int $signRequestId,
40        string $ownerId,
41        string $encryptedRecipient,
42        ?string $encryptedMessage = null,
43    ): NotificationDelivery {
44        if (strlen($eventKey) !== 64 || !ctype_xdigit($eventKey)
45            || strlen($correlationId) !== 64 || !ctype_xdigit($correlationId)) {
46            throw new \InvalidArgumentException('A 64-character event hash is required');
47        }
48        $delivery = new NotificationDelivery();
49        $delivery->setEventKey($eventKey);
50        $delivery->setCorrelationId($correlationId);
51        $delivery->setEventType($eventType);
52        $delivery->setChannel($channel);
53        $delivery->setFileId($fileId);
54        $delivery->setSignRequestId($signRequestId);
55        $delivery->setOwnerId($ownerId);
56        $delivery->setRecipientEnc($encryptedRecipient);
57        $delivery->setMessageEnc($encryptedMessage);
58        $delivery->setState(NotificationDeliveryState::PENDING->value);
59        $delivery->setAttemptCount(0);
60        $now = new \DateTime('now', new \DateTimeZone('UTC'));
61        $delivery->setCreatedAt($now);
62        $delivery->setUpdatedAt($now);
63
64        // PostgreSQL aborts the entire surrounding transaction on a duplicate
65        // key error. A savepoint keeps the sign request transaction usable while
66        // still allowing the unique index to arbitrate concurrent registrations.
67        $savepoint = $this->db->inTransaction() ? 'ls_notify_' . bin2hex(random_bytes(8)) : null;
68        if ($savepoint !== null) {
69            $this->db->executeStatement('SAVEPOINT ' . $savepoint);
70        }
71        try {
72            try {
73                return $this->insert($delivery);
74            } catch (DatabaseException $e) {
75                if ($savepoint !== null) {
76                    $this->db->executeStatement('ROLLBACK TO SAVEPOINT ' . $savepoint);
77                }
78                if ($e->getReason() !== DatabaseException::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
79                    throw $e;
80                }
81                // A second writer raced us. Verify the event key; the exception
82                // could also be an unrelated constraint violation.
83                try {
84                    return $this->findByEventKey($eventKey);
85                } catch (DoesNotExistException) {
86                    throw $e;
87                }
88            }
89        } finally {
90            if ($savepoint !== null) {
91                $this->db->executeStatement('RELEASE SAVEPOINT ' . $savepoint);
92            }
93        }
94    }
95
96    public function findByEventKey(string $eventKey): NotificationDelivery {
97        $qb = $this->db->getQueryBuilder();
98        $qb->select('*')->from($this->getTableName())
99            ->where($qb->expr()->eq('event_key', $qb->createNamedParameter($eventKey)));
100        /** @var NotificationDelivery */
101        return $this->findEntity($qb);
102    }
103
104    /** @return list<NotificationDelivery> */
105    public function findForEvent(string $correlationId): array {
106        $qb = $this->db->getQueryBuilder();
107        $qb->select('*')->from($this->getTableName())
108            ->where($qb->expr()->eq('correlation_id', $qb->createNamedParameter($correlationId)));
109        return $this->findEntities($qb);
110    }
111
112    /** Claim is a compare-and-swap, so a second dispatcher cannot send again. */
113    public function claim(int $deliveryId): bool {
114        $qb = $this->db->getQueryBuilder();
115        $now = new \DateTime('now', new \DateTimeZone('UTC'));
116        return $qb->update($this->getTableName())
117            ->set('state', $qb->createNamedParameter(NotificationDeliveryState::SENDING->value))
118            ->set('attempt_count', $qb->createFunction('attempt_count + 1'))
119            ->set('updated_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_MUTABLE))
120            ->where($qb->expr()->eq('id', $qb->createNamedParameter($deliveryId, IQueryBuilder::PARAM_INT)))
121            ->andWhere($qb->expr()->lt('attempt_count', $qb->createNamedParameter(self::MAX_ATTEMPTS, IQueryBuilder::PARAM_INT)))
122            ->andWhere($qb->expr()->orX(
123                $qb->expr()->eq('state', $qb->createNamedParameter(NotificationDeliveryState::PENDING->value)),
124                $qb->expr()->andX(
125                    $qb->expr()->eq('state', $qb->createNamedParameter(NotificationDeliveryState::TEMPORARY_FAILURE->value)),
126                    $qb->expr()->lte('next_attempt_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_MUTABLE)),
127                ),
128            ))
129            ->executeStatement() === 1;
130    }
131
132    public function finish(int $deliveryId, NotificationDeliveryState $state, ?\DateTime $retryAt = null): void {
133        if (!in_array($state, [
134            NotificationDeliveryState::ACCEPTED,
135            NotificationDeliveryState::TEMPORARY_FAILURE,
136            NotificationDeliveryState::PERMANENT_FAILURE,
137            NotificationDeliveryState::FAILED,
138            NotificationDeliveryState::UNKNOWN,
139        ], true)) {
140            throw new \InvalidArgumentException('Invalid terminal notification state');
141        }
142        if (($state === NotificationDeliveryState::TEMPORARY_FAILURE) !== ($retryAt !== null)) {
143            throw new \InvalidArgumentException('Only a scheduled temporary failure can retain a notification handoff');
144        }
145        $qb = $this->db->getQueryBuilder();
146        $now = new \DateTime('now', new \DateTimeZone('UTC'));
147        $update = $qb->update($this->getTableName());
148        if ($retryAt === null) {
149            $update->set('message_enc', $qb->createNamedParameter(null));
150        }
151        $updated = $update
152            ->set('state', $qb->createNamedParameter($state->value))
153            ->set('updated_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_MUTABLE))
154            ->set('next_attempt_at', $qb->createNamedParameter($retryAt, $retryAt === null ? IQueryBuilder::PARAM_NULL : IQueryBuilder::PARAM_DATETIME_MUTABLE))
155            ->where($qb->expr()->eq('id', $qb->createNamedParameter($deliveryId, IQueryBuilder::PARAM_INT)))
156            ->andWhere($qb->expr()->eq('state', $qb->createNamedParameter(NotificationDeliveryState::SENDING->value)))
157            ->executeStatement();
158        if ($updated !== 1) {
159            throw new \LogicException('Notification delivery state changed during dispatch');
160        }
161    }
162
163    /**
164     * Invalidate obsolete invitations while the signing-state mutation is
165     * protected by its database transaction. A claimed submission is never
166     * rewritten because the provider may already have accepted it.
167     */
168    public function cancelPendingInvitations(int $signRequestId): int {
169        $qb = $this->db->getQueryBuilder();
170        return $qb->update($this->getTableName())
171            ->set('state', $qb->createNamedParameter(NotificationDeliveryState::CANCELED->value))
172            ->set('message_enc', $qb->createNamedParameter(null))
173            ->set('updated_at', $qb->createNamedParameter(new \DateTime('now', new \DateTimeZone('UTC')), IQueryBuilder::PARAM_DATETIME_MUTABLE))
174            ->where($qb->expr()->eq('sign_request_id', $qb->createNamedParameter($signRequestId, IQueryBuilder::PARAM_INT)))
175            ->andWhere($qb->expr()->eq('event_type', $qb->createNamedParameter('sign_request')))
176            ->andWhere($qb->expr()->in('state', $qb->createNamedParameter([NotificationDeliveryState::PENDING->value, NotificationDeliveryState::TEMPORARY_FAILURE->value], IQueryBuilder::PARAM_STR_ARRAY)))
177            ->executeStatement();
178    }
179
180    public function findById(int $deliveryId): NotificationDelivery {
181        $qb = $this->db->getQueryBuilder();
182        $qb->select('*')->from($this->getTableName())
183            ->where($qb->expr()->eq('id', $qb->createNamedParameter($deliveryId, IQueryBuilder::PARAM_INT)));
184        /** @var NotificationDelivery */
185        return $this->findEntity($qb);
186    }
187
188    /** @return list<NotificationDelivery> */
189    public function findPending(int $limit = 50): array {
190        $qb = $this->db->getQueryBuilder();
191        $qb->select('*')->from($this->getTableName())
192            ->where($qb->expr()->eq('state', $qb->createNamedParameter(NotificationDeliveryState::PENDING->value)))
193            ->orderBy('created_at', 'ASC')->addOrderBy('id', 'ASC')
194            ->setMaxResults($limit);
195        return $this->findEntities($qb);
196    }
197
198    /**
199     * Work due now, after the last scanned ID. Keyset pagination stays stable
200     * when other workers change delivery states while recovery is scanning.
201     *
202     * @return list<NotificationDelivery>
203     */
204    public function findDue(int $limit = 50, int $afterId = 0): array {
205        $qb = $this->db->getQueryBuilder();
206        $now = new \DateTime('now', new \DateTimeZone('UTC'));
207        $qb->select('*')->from($this->getTableName())
208            ->where($qb->expr()->gt('id', $qb->createNamedParameter($afterId, IQueryBuilder::PARAM_INT)))
209            ->andWhere($qb->expr()->orX(
210                $qb->expr()->eq('state', $qb->createNamedParameter(NotificationDeliveryState::PENDING->value)),
211                $qb->expr()->andX(
212                    $qb->expr()->eq('state', $qb->createNamedParameter(NotificationDeliveryState::TEMPORARY_FAILURE->value)),
213                    $qb->expr()->lte('next_attempt_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_MUTABLE)),
214                ),
215            ))
216            ->orderBy('id', 'ASC')->setMaxResults($limit);
217        return $this->findEntities($qb);
218    }
219
220    /** A corrupt handoff cannot safely enter a transport attempt. */
221    public function failPending(int $deliveryId): bool {
222        $qb = $this->db->getQueryBuilder();
223        return $qb->update($this->getTableName())
224            ->set('state', $qb->createNamedParameter(NotificationDeliveryState::FAILED->value))
225            ->set('message_enc', $qb->createNamedParameter(null))
226            ->set('updated_at', $qb->createNamedParameter(new \DateTime('now', new \DateTimeZone('UTC')), IQueryBuilder::PARAM_DATETIME_MUTABLE))
227            ->where($qb->expr()->eq('id', $qb->createNamedParameter($deliveryId, IQueryBuilder::PARAM_INT)))
228            ->andWhere($qb->expr()->in('state', $qb->createNamedParameter([NotificationDeliveryState::PENDING->value, NotificationDeliveryState::TEMPORARY_FAILURE->value], IQueryBuilder::PARAM_STR_ARRAY)))
229            ->executeStatement() === 1;
230    }
231
232    /** @return list<NotificationDelivery> */
233    public function findStaleSending(\DateTime $before, int $limit = 200): array {
234        $qb = $this->db->getQueryBuilder();
235        $qb->select('*')->from($this->getTableName())
236            ->where($qb->expr()->eq('state', $qb->createNamedParameter(NotificationDeliveryState::SENDING->value)))
237            ->andWhere($qb->expr()->lt('updated_at', $qb->createNamedParameter($before, IQueryBuilder::PARAM_DATETIME_MUTABLE)))
238            ->setMaxResults($limit);
239        return $this->findEntities($qb);
240    }
241
242    /**
243     * An interrupted submission has no reliable acceptance result.
244     * Never move a stale sending record back to pending.
245     */
246    public function markStaleUnknown(int $deliveryId, \DateTime $before): bool {
247        $qb = $this->db->getQueryBuilder();
248        return $qb->update($this->getTableName())
249            ->set('state', $qb->createNamedParameter(NotificationDeliveryState::UNKNOWN->value))
250            ->set('message_enc', $qb->createNamedParameter(null))
251            ->set('updated_at', $qb->createNamedParameter(new \DateTime('now', new \DateTimeZone('UTC')), IQueryBuilder::PARAM_DATETIME_MUTABLE))
252            ->where($qb->expr()->eq('id', $qb->createNamedParameter($deliveryId, IQueryBuilder::PARAM_INT)))
253            ->andWhere($qb->expr()->eq('state', $qb->createNamedParameter(NotificationDeliveryState::SENDING->value)))
254            ->andWhere($qb->expr()->lt('updated_at', $qb->createNamedParameter($before, IQueryBuilder::PARAM_DATETIME_MUTABLE)))
255            ->executeStatement() === 1;
256    }
257
258    /**
259     * Redact recipient and owner data after an explicitly configured retention
260     * period, but KEEP the event key and terminal state as a deduplication
261     * tombstone. Deleting the delivery would allow a replay to submit again.
262     */
263    public function redactOlderThan(\DateTime $before, int $limit = 500): int {
264        $qb = $this->db->getQueryBuilder();
265        $rows = $qb->select('id')->from($this->getTableName())
266            ->where($qb->expr()->lt('updated_at', $qb->createNamedParameter($before, IQueryBuilder::PARAM_DATETIME_MUTABLE)))
267            ->andWhere($qb->expr()->neq('recipient_enc', $qb->createNamedParameter('')))
268            ->andWhere($qb->expr()->in('state', $qb->createNamedParameter([
269                NotificationDeliveryState::ACCEPTED->value,
270                NotificationDeliveryState::PERMANENT_FAILURE->value,
271                NotificationDeliveryState::FAILED->value,
272                NotificationDeliveryState::UNKNOWN->value,
273                NotificationDeliveryState::CANCELED->value,
274            ], IQueryBuilder::PARAM_STR_ARRAY)))
275            ->setMaxResults($limit)
276            ->executeQuery()->fetchAllAssociative();
277        if ($rows === []) {
278            return 0;
279        }
280        $ids = array_map(static fn (array $row): int => (int)$row['id'], $rows);
281        $update = $this->db->getQueryBuilder();
282        return $update->update($this->getTableName())
283            ->set('recipient_enc', $update->createNamedParameter(''))
284            ->set('owner_id', $update->createNamedParameter(''))
285            ->set('message_enc', $update->createNamedParameter(null))
286            ->where($update->expr()->in('id', $update->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)))
287            ->executeStatement();
288    }
289}