Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.79% covered (success)
93.79%
151 / 161
92.31% covered (success)
92.31%
12 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
TrackingToolEventWatcher
93.79% covered (success)
93.79%
151 / 161
92.31% covered (success)
92.31%
12 / 13
40.38
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 validateEventCreation
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 onEventCreated
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 validateEventUpdate
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 onEventUpdated
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
6
 splitToolsForEventUpdate
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 validateEventDeletion
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 onEventDeleted
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 validateParticipantAdded
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 onParticipantAdded
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 validateParticipantsRemoved
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 onParticipantsRemoved
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 logToolFailure
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare( strict_types=1 );
4
5namespace MediaWiki\Extension\CampaignEvents\TrackingTool;
6
7use MediaWiki\Deferred\DeferredUpdates;
8use MediaWiki\Extension\CampaignEvents\Event\EventRegistration;
9use MediaWiki\Extension\CampaignEvents\Event\ExistingEventRegistration;
10use MediaWiki\Extension\CampaignEvents\MWEntity\CentralUser;
11use Psr\Log\LoggerInterface;
12use StatusValue;
13
14/**
15 * This class watches changes to an event (e.g., creation, changes to the participants list) and informs any
16 * attached tracking tools of those changes. validate*() methods are called before the action occurs, whereas on*()
17 * methods are called once the action has occurred. on*() methods might be executed asynchronously, and no information
18 * about the success/failure of the job can be returned to the caller. See the documentation of the
19 * {@see \MediaWiki\Extension\CampaignEvents\TrackingTool\Tool\TrackingTool} class for more details.
20 *
21 * Note that tracking tools are checked sequentially, and we abort on the first failure. That's because sending
22 * updates to a tracking tool might be an expensive operation (e.g., an HTTP request), and we don't want to do it
23 * if the overall result is already known to be a failure.
24 *
25 * For methods called upon changes to the list of participants, note that the validation phase and the commit phase
26 * might send different information. For instance, this can happen if there's a concurrent update between the validation
27 * and the commit. The only guarantee is that the commit phase will always send the most up-to-date information.
28 *
29 * @todo For the future, and particolarly when we add support for multiple tracking tools, we should probably use
30 * jobs instead of DeferredUpdates for asynchronous updates. Jobs are allowed to take longer and can be retried if they
31 * fail.
32 */
33class TrackingToolEventWatcher {
34    public const SERVICE_NAME = 'CampaignEventsTrackingToolEventWatcher';
35
36    private TrackingToolRegistry $trackingToolRegistry;
37    private TrackingToolUpdater $trackingToolUpdater;
38    private LoggerInterface $logger;
39
40    /**
41     * @param TrackingToolRegistry $trackingToolRegistry
42     * @param TrackingToolUpdater $trackingToolUpdater
43     * @param LoggerInterface $logger
44     */
45    public function __construct(
46        TrackingToolRegistry $trackingToolRegistry,
47        TrackingToolUpdater $trackingToolUpdater,
48        LoggerInterface $logger
49    ) {
50        $this->trackingToolRegistry = $trackingToolRegistry;
51        $this->trackingToolUpdater = $trackingToolUpdater;
52        $this->logger = $logger;
53    }
54
55    /**
56     * @param EventRegistration $event
57     * @param CentralUser[] $organizers
58     * @return StatusValue
59     */
60    public function validateEventCreation( EventRegistration $event, array $organizers ): StatusValue {
61        foreach ( $event->getTrackingTools() as $toolAssociation ) {
62            $tool = $this->trackingToolRegistry->newFromDBID( $toolAssociation->getToolID() );
63            $status = $tool->validateToolAddition( $event, $organizers, $toolAssociation->getToolEventID() );
64            if ( !$status->isGood() ) {
65                return $status;
66            }
67        }
68        return StatusValue::newGood();
69    }
70
71    /**
72     * @param int $eventID ID assigned to the newly created event
73     * @param EventRegistration $event
74     * @param CentralUser[] $organizers
75     * @return StatusValue Good if all tools were synced successfully. Regardless of whether the status is good, the
76     * status' value shall be an array of TrackingToolAssociation objects for tools that were synced successfully, so
77     * that the database record of the registration can be updated if necessary.
78     *
79     * @note The caller is responsible for updating the database with the new tools.
80     */
81    public function onEventCreated( int $eventID, EventRegistration $event, array $organizers ): StatusValue {
82        $ret = StatusValue::newGood();
83        $newTools = [];
84        foreach ( $event->getTrackingTools() as $toolAssociation ) {
85            $tool = $this->trackingToolRegistry->newFromDBID( $toolAssociation->getToolID() );
86            $status = $tool->addToNewEvent( $eventID, $event, $organizers, $toolAssociation->getToolEventID() );
87            if ( $status->isGood() ) {
88                $newTools[] = $toolAssociation->asUpdatedWith(
89                    TrackingToolAssociation::SYNC_STATUS_SYNCED,
90                    wfTimestamp()
91                );
92            } else {
93                $ret->merge( $status );
94                $this->logToolFailure( 'event creation', $event, $toolAssociation, $status );
95            }
96        }
97
98        $ret->value = $newTools;
99        return $ret;
100    }
101
102    /**
103     * @param ExistingEventRegistration $oldVersion
104     * @param EventRegistration $newVersion
105     * @param CentralUser[] $organizers
106     * @return StatusValue
107     */
108    public function validateEventUpdate(
109        ExistingEventRegistration $oldVersion,
110        EventRegistration $newVersion,
111        array $organizers
112    ): StatusValue {
113        [ $removedTools, $addedTools ] = $this->splitToolsForEventUpdate( $oldVersion, $newVersion );
114
115        foreach ( $removedTools as $removedToolAssoc ) {
116            $tool = $this->trackingToolRegistry->newFromDBID( $removedToolAssoc->getToolID() );
117            $status = $tool->validateToolRemoval( $oldVersion, $removedToolAssoc->getToolEventID() );
118            if ( !$status->isGood() ) {
119                return $status;
120            }
121        }
122
123        foreach ( $addedTools as $addedToolAssoc ) {
124            $tool = $this->trackingToolRegistry->newFromDBID( $addedToolAssoc->getToolID() );
125            $status = $tool->validateToolAddition( $oldVersion, $organizers, $addedToolAssoc->getToolEventID() );
126            if ( !$status->isGood() ) {
127                return $status;
128            }
129        }
130
131        return StatusValue::newGood();
132    }
133
134    /**
135     * @param ExistingEventRegistration $oldVersion
136     * @param EventRegistration $newVersion
137     * @param CentralUser[] $organizers
138     * @return StatusValue Good if all tools were synced successfully. Regardless of whether the status is good, the
139     * status' value shall be an array of TrackingToolAssociation objects for tools that are now successully synced with
140     * the event, which includes both newly-synced tools and previously-synced tools that we were not able to remove.
141     * These are provided so that the database record of the registration can be updated if necessary.
142     *
143     * @note The caller is responsible for updating the database with the new tools.
144     */
145    public function onEventUpdated(
146        ExistingEventRegistration $oldVersion,
147        EventRegistration $newVersion,
148        array $organizers
149    ): StatusValue {
150        [ $removedTools, $addedTools, $unchangedTools ] = $this->splitToolsForEventUpdate( $oldVersion, $newVersion );
151
152        $ret = StatusValue::newGood();
153        $newTools = $unchangedTools;
154        foreach ( $removedTools as $removedToolAssoc ) {
155            $tool = $this->trackingToolRegistry->newFromDBID( $removedToolAssoc->getToolID() );
156            $status = $tool->removeFromEvent( $oldVersion, $removedToolAssoc->getToolEventID() );
157            if ( !$status->isGood() ) {
158                $ret->merge( $status );
159                $this->logToolFailure( 'event update (tool removal)', $oldVersion, $removedToolAssoc, $status );
160                // Preserve the existing sync status and last sync timestamp
161                $newTools[] = $removedToolAssoc;
162            }
163        }
164
165        if ( !$ret->isGood() ) {
166            // TODO: Remove this when adding support for multiple tracking tools. A failed removal means that the event
167            // might end up having 2 tools even if we support only adding one.
168            $ret->value = $newTools;
169            return $ret;
170        }
171
172        foreach ( $addedTools as $addedToolAssoc ) {
173            $tool = $this->trackingToolRegistry->newFromDBID( $addedToolAssoc->getToolID() );
174            $status = $tool->addToExistingEvent( $oldVersion, $organizers, $addedToolAssoc->getToolEventID() );
175            if ( $status->isGood() ) {
176                $newTools[] = $addedToolAssoc->asUpdatedWith(
177                    TrackingToolAssociation::SYNC_STATUS_SYNCED,
178                    wfTimestamp()
179                );
180            } else {
181                $ret->merge( $status );
182                $this->logToolFailure( 'event update (new tool)', $newVersion, $addedToolAssoc, $status );
183            }
184        }
185
186        $ret->value = $newTools;
187        return $ret;
188    }
189
190    /**
191     * Given two version of an event registration, returns an array with tools that were, respectively, removed,
192     * added, and unchanged between the two version.
193     *
194     * @param ExistingEventRegistration $oldVersion
195     * @param EventRegistration $newVersion
196     * @return TrackingToolAssociation[][]
197     * @phan-return array{0:TrackingToolAssociation[],1:TrackingToolAssociation[],2:TrackingToolAssociation[]}
198     */
199    private function splitToolsForEventUpdate(
200        ExistingEventRegistration $oldVersion,
201        EventRegistration $newVersion
202    ): array {
203        $oldTools = $oldVersion->getTrackingTools();
204        $newTools = $newVersion->getTrackingTools();
205
206        // Build fast-lookup maps to compute differences more easily.
207        $oldToolsMap = [];
208        foreach ( $oldTools as $oldToolAssoc ) {
209            $key = $oldToolAssoc->getToolID() . '|' . $oldToolAssoc->getToolEventID();
210            $oldToolsMap[$key] = $oldToolAssoc;
211        }
212        $newToolsMap = [];
213        foreach ( $newTools as $newToolAssoc ) {
214            $key = $newToolAssoc->getToolID() . '|' . $newToolAssoc->getToolEventID();
215            $newToolsMap[$key] = $newToolAssoc;
216        }
217
218        $removedTools = array_values( array_diff_key( $oldToolsMap, $newToolsMap ) );
219        $addedTools = array_values( array_diff_key( $newToolsMap, $oldToolsMap ) );
220        $unchangedTools = array_values( array_intersect_key( $oldToolsMap, $newToolsMap ) );
221        return [ $removedTools, $addedTools, $unchangedTools ];
222    }
223
224    /**
225     * @param ExistingEventRegistration $event
226     * @return StatusValue
227     */
228    public function validateEventDeletion( ExistingEventRegistration $event ): StatusValue {
229        foreach ( $event->getTrackingTools() as $toolAssociation ) {
230            $tool = $this->trackingToolRegistry->newFromDBID( $toolAssociation->getToolID() );
231            $status = $tool->validateEventDeletion( $event, $toolAssociation->getToolEventID() );
232            if ( !$status->isGood() ) {
233                return $status;
234            }
235        }
236        return StatusValue::newGood();
237    }
238
239    /**
240     * @param ExistingEventRegistration $event
241     *
242     * @note This method is also responsible for updating the database.
243     */
244    public function onEventDeleted( ExistingEventRegistration $event ): void {
245        DeferredUpdates::addCallableUpdate( function () use ( $event ) {
246            foreach ( $event->getTrackingTools() as $toolAssociation ) {
247                $toolID = $toolAssociation->getToolID();
248                $toolEventID = $toolAssociation->getToolEventID();
249                $tool = $this->trackingToolRegistry->newFromDBID( $toolID );
250                $status = $tool->onEventDeleted( $event, $toolEventID );
251                if ( $status->isGood() ) {
252                    $this->trackingToolUpdater->updateToolSyncStatus(
253                        $event->getID(),
254                        $toolID,
255                        $toolEventID,
256                        TrackingToolAssociation::SYNC_STATUS_UNKNOWN
257                    );
258                } else {
259                    $this->logToolFailure( 'event deletion', $event, $toolAssociation, $status );
260                }
261            }
262        } );
263    }
264
265    /**
266     * @param ExistingEventRegistration $event
267     * @param CentralUser $participant
268     * @param bool $private
269     * @return StatusValue
270     */
271    public function validateParticipantAdded(
272        ExistingEventRegistration $event,
273        CentralUser $participant,
274        bool $private
275    ): StatusValue {
276        foreach ( $event->getTrackingTools() as $toolAssociation ) {
277            $tool = $this->trackingToolRegistry->newFromDBID( $toolAssociation->getToolID() );
278            $status = $tool->validateParticipantAdded(
279                $event,
280                $toolAssociation->getToolEventID(),
281                $participant,
282                $private
283            );
284            if ( !$status->isGood() ) {
285                return $status;
286            }
287        }
288        return StatusValue::newGood();
289    }
290
291    /**
292     * @param ExistingEventRegistration $event
293     * @param CentralUser $participant
294     * @param bool $private
295     *
296     * @note This method is also responsible for updating the database.
297     */
298    public function onParticipantAdded(
299        ExistingEventRegistration $event,
300        CentralUser $participant,
301        bool $private
302    ): void {
303        DeferredUpdates::addCallableUpdate( function () use ( $event, $participant, $private ) {
304            foreach ( $event->getTrackingTools() as $toolAssociation ) {
305                $toolID = $toolAssociation->getToolID();
306                $toolEventID = $toolAssociation->getToolEventID();
307                $tool = $this->trackingToolRegistry->newFromDBID( $toolID );
308                $status = $tool->addParticipant( $event, $toolEventID, $participant, $private );
309                if ( $status->isGood() ) {
310                    $newSyncStatus = TrackingToolAssociation::SYNC_STATUS_SYNCED;
311                } else {
312                    $newSyncStatus = TrackingToolAssociation::SYNC_STATUS_FAILED;
313                    $this->logToolFailure( 'added participant', $event, $toolAssociation, $status );
314                }
315                $this->trackingToolUpdater->updateToolSyncStatus(
316                    $event->getID(),
317                    $toolID,
318                    $toolEventID,
319                    $newSyncStatus
320                );
321            }
322        } );
323    }
324
325    /**
326     * @param ExistingEventRegistration $event
327     * @param CentralUser[]|null $participants Array of participants to remove if $invertSelection is false,
328     * or array of participants to keep if $invertSelection is true. Null means remove everyone, regardless of
329     * $invertSelection.
330     * @param bool $invertSelection
331     * @return StatusValue
332     */
333    public function validateParticipantsRemoved(
334        ExistingEventRegistration $event,
335        ?array $participants,
336        bool $invertSelection
337    ): StatusValue {
338        foreach ( $event->getTrackingTools() as $toolAssociation ) {
339            $tool = $this->trackingToolRegistry->newFromDBID( $toolAssociation->getToolID() );
340            $status = $tool->validateParticipantsRemoved(
341                $event,
342                $toolAssociation->getToolEventID(),
343                $participants,
344                $invertSelection
345            );
346            if ( !$status->isGood() ) {
347                return $status;
348            }
349        }
350        return StatusValue::newGood();
351    }
352
353    /**
354     * @param ExistingEventRegistration $event
355     * @param CentralUser[]|null $participants Array of participants to remove if $invertSelection is false,
356     * or array of participants to keep if $invertSelection is true. Null means remove everyone, regardless of
357     * $invertSelection.
358     * @param bool $invertSelection
359     */
360    public function onParticipantsRemoved(
361        ExistingEventRegistration $event,
362        ?array $participants,
363        bool $invertSelection
364    ): void {
365        DeferredUpdates::addCallableUpdate( function () use ( $event, $participants, $invertSelection ) {
366            foreach ( $event->getTrackingTools() as $toolAssociation ) {
367                $toolID = $toolAssociation->getToolID();
368                $toolEventID = $toolAssociation->getToolEventID();
369                $tool = $this->trackingToolRegistry->newFromDBID( $toolID );
370                $status = $tool->removeParticipants( $event, $toolEventID, $participants, $invertSelection );
371                if ( $status->isGood() ) {
372                    $newSyncStatus = TrackingToolAssociation::SYNC_STATUS_SYNCED;
373                } else {
374                    $newSyncStatus = TrackingToolAssociation::SYNC_STATUS_FAILED;
375                    $this->logToolFailure( 'removed participants', $event, $toolAssociation, $status );
376                }
377                $this->trackingToolUpdater->updateToolSyncStatus(
378                    $event->getID(),
379                    $toolID,
380                    $toolEventID,
381                    $newSyncStatus
382                );
383            }
384        } );
385    }
386
387    /**
388     * Logs a tracking tool failure in the MW log, so that failures can be monitored and possibly acted upon.
389     *
390     * @param string $operation
391     * @param EventRegistration $event
392     * @param TrackingToolAssociation $toolAssoc
393     * @param StatusValue $status
394     * @return void
395     */
396    private function logToolFailure(
397        string $operation,
398        EventRegistration $event,
399        TrackingToolAssociation $toolAssoc,
400        StatusValue $status
401    ): void {
402        $this->logger->error(
403            'Tracking tool update failed for: {operation}. Event {event_id} with tool {tool_id}: {error_status}',
404            [
405                'operation' => $operation,
406                'event_id' => $event->getID(),
407                'tool_id' => $toolAssoc->getToolID(),
408                'tool_event_id' => $toolAssoc->getToolEventID(),
409                'error_status' => $status,
410            ]
411        );
412    }
413}