Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
NotificationEventRouter
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
3 / 3
16
100.00% covered (success)
100.00%
1 / 1
 fromModules
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handle
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
11
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\Service\Notification;
10
11use OCA\Libresign\Enum\NotificationDeliveryState;
12use OCA\Libresign\Events\SendSignNotificationEvent;
13use OCA\Libresign\Exception\LibresignException;
14use OCA\Libresign\Exception\NotificationTransportException;
15use OCP\EventDispatcher\Event;
16use OCP\EventDispatcher\IEventListener;
17use OCP\IDBConnection;
18use Psr\Log\LoggerInterface;
19
20/**
21 * Deliver each event to independent channel handlers.
22 *
23 * To add a channel, register a handler in Application. No existing handler or
24 * ledger code needs to know its transport. Channel exceptions are reported
25 * independently. Invariant violations and PHP errors remain visible.
26 *
27 * @template-implements IEventListener<Event>
28 */
29final class NotificationEventRouter implements IEventListener {
30    /**
31     * Convert a list of channel modules into supported-event routing.
32     *
33     * @param list<NotificationModule> $modules
34     */
35    public static function fromModules(array $modules, LoggerInterface $logger, ?IDBConnection $db = null): self {
36        $handlers = [];
37        foreach ($modules as $module) {
38            foreach ($module::supportedEvents() as $eventClass) {
39                if (!is_a($eventClass, Event::class, true)) {
40                    throw new \InvalidArgumentException('Invalid notification module event');
41                }
42                $handlers[$eventClass][] = $module;
43            }
44        }
45        return new self($handlers, $logger, $db);
46    }
47
48    /** @param array<class-string<Event>, list<IEventListener<Event>>> $handlersByEvent */
49    public function __construct(
50        private array $handlersByEvent,
51        private LoggerInterface $logger,
52        private ?IDBConnection $db = null,
53    ) {
54    }
55
56    #[\Override]
57    public function handle(Event $event): void {
58        $firstFailure = null;
59        $programmingFailure = null;
60        foreach ($this->handlersByEvent[$event::class] ?? [] as $handler) {
61            try {
62                $handler->handle($event);
63            } catch (\Exception $exception) {
64                $firstFailure ??= $exception;
65                if ($exception instanceof \LogicException) {
66                    $programmingFailure ??= $exception;
67                }
68                $this->logger->warning('A notification channel did not confirm submission', [
69                    'handler' => get_debug_type($handler),
70                    'exceptionType' => get_debug_type($exception),
71                ]);
72            }
73        }
74
75        // A domain mutation may commit only when every intended channel has
76        // been durably recorded. An ordinary channel failure cannot be ignored
77        // while the database transaction is still open.
78        if ($firstFailure !== null && $this->db?->inTransaction()) {
79            throw new \LogicException('Unable to register notifications atomically', 0, $firstFailure);
80        }
81
82        // Do not mask invariant violations as an ordinary transport problem.
83        // Other channels were still attempted first.
84        if ($programmingFailure !== null) {
85            throw $programmingFailure;
86        }
87
88        // A manual request must report an incomplete result, but only AFTER
89        // the remaining channels have had a chance to submit independently.
90        if ($firstFailure !== null
91            && $event instanceof SendSignNotificationEvent
92            && str_starts_with($event->getDeliveryEventId(), 'manual:')) {
93            // Preserve domain validation and wrapped SMTP failure semantics:
94            // the existing controller can distinguish them by cause.
95            if ($firstFailure instanceof LibresignException) {
96                throw $firstFailure;
97            }
98            throw new NotificationTransportException(
99                'One or more notification channels did not confirm submission',
100                NotificationDeliveryState::UNKNOWN,
101                'notification_channels_not_confirmed',
102                $firstFailure,
103            );
104        }
105    }
106}