Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
46.97% covered (danger)
46.97%
31 / 66
36.36% covered (danger)
36.36%
4 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
PersonalizedPraiseNotificationsDispatcher
46.97% covered (danger)
46.97%
31 / 66
36.36% covered (danger)
36.36%
4 / 11
101.89
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 makeLastNotifiedKey
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getLastNotified
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setLastNotified
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 makePendingMenteesKey
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 doesMentorHavePendingMentees
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 purgePendingMenteesForMentor
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 markMenteeAsPendingForMentor
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 notifyMentor
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 onMenteeSuggested
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 maybeNotifyAboutPendingMentees
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
6.01
1<?php
2
3namespace GrowthExperiments\MentorDashboard\PersonalizedPraise;
4
5use BagOStuff;
6use GrowthExperiments\EventLogging\PersonalizedPraiseLogger;
7use MediaWiki\Config\Config;
8use MediaWiki\Extension\Notifications\Model\Event;
9use MediaWiki\SpecialPage\SpecialPageFactory;
10use MediaWiki\User\UserIdentity;
11use MediaWiki\Utils\MWTimestamp;
12use Wikimedia\LightweightObjectStore\ExpirationAwareness;
13
14class PersonalizedPraiseNotificationsDispatcher implements ExpirationAwareness {
15
16    private Config $config;
17    private BagOStuff $cache;
18    private SpecialPageFactory $specialPageFactory;
19    private PersonalizedPraiseSettings $personalizedPraiseSettings;
20    private PersonalizedPraiseLogger $eventLogger;
21
22    /**
23     * @param Config $config
24     * @param BagOStuff $cache
25     * @param SpecialPageFactory $specialPageFactory
26     * @param PersonalizedPraiseSettings $personalizedPraiseSettings
27     * @param PersonalizedPraiseLogger $personalizedPraiseLogger
28     */
29    public function __construct(
30        Config $config,
31        BagOStuff $cache,
32        SpecialPageFactory $specialPageFactory,
33        PersonalizedPraiseSettings $personalizedPraiseSettings,
34        PersonalizedPraiseLogger $personalizedPraiseLogger
35    ) {
36        $this->config = $config;
37        $this->cache = $cache;
38        $this->specialPageFactory = $specialPageFactory;
39        $this->personalizedPraiseSettings = $personalizedPraiseSettings;
40        $this->eventLogger = $personalizedPraiseLogger;
41    }
42
43    /**
44     * @param UserIdentity $mentor
45     * @return string
46     */
47    private function makeLastNotifiedKey( UserIdentity $mentor ): string {
48        return $this->cache->makeKey(
49            'GrowthExperiments', self::class,
50            'last-notified', $mentor->getId()
51        );
52    }
53
54    /**
55     * If available, get last notified timestamp
56     *
57     * @param UserIdentity $userIdentity
58     * @return string|null
59     */
60    private function getLastNotified( UserIdentity $userIdentity ): ?string {
61        $res = $this->cache->get( $this->makeLastNotifiedKey( $userIdentity ) );
62        if ( !is_string( $res ) ) {
63            return null;
64        }
65
66        return $res;
67    }
68
69    /**
70     * Set last notified timestamp
71     *
72     * @param UserIdentity $userIdentity
73     * @param string $lastNotified
74     */
75    private function setLastNotified( UserIdentity $userIdentity, string $lastNotified ): void {
76        $this->cache->set(
77            $this->makeLastNotifiedKey( $userIdentity ),
78            $lastNotified,
79            self::TTL_INDEFINITE
80        );
81    }
82
83    /**
84     * @param UserIdentity $mentor
85     * @return string
86     */
87    private function makePendingMenteesKey( UserIdentity $mentor ): string {
88        return $this->cache->makeKey(
89            'GrowthExperiments', self::class,
90            'pending-mentees', $mentor->getId()
91        );
92    }
93
94    /**
95     * Are there any mentees the mentor was not yet notified about?
96     *
97     * @param UserIdentity $mentor
98     * @return bool
99     */
100    private function doesMentorHavePendingMentees( UserIdentity $mentor ): bool {
101        $res = $this->cache->get( $this->makePendingMenteesKey( $mentor ) );
102        return is_array( $res ) && $res;
103    }
104
105    /**
106     * Purge the list of mentees the mentor was not yet notified about
107     *
108     * This should be called upon mentor getting a notification.
109     *
110     * @param UserIdentity $mentor
111     */
112    private function purgePendingMenteesForMentor( UserIdentity $mentor ): void {
113        $this->cache->delete( $this->makePendingMenteesKey( $mentor ) );
114    }
115
116    /**
117     * @param UserIdentity $mentor
118     * @param UserIdentity $mentee
119     */
120    private function markMenteeAsPendingForMentor(
121        UserIdentity $mentor, UserIdentity $mentee
122    ): void {
123        $this->cache->merge(
124            $this->makePendingMenteesKey( $mentor ),
125            static function ( $cache, $key, $value ) use ( $mentee ) {
126                if ( !is_array( $value ) ) {
127                    $value = [];
128                }
129                $value[] = $mentee->getId();
130                return array_unique( $value );
131            }
132        );
133    }
134
135    /**
136     * Notify a mentor about new praiseworthy mentees
137     *
138     * @param UserIdentity $mentor
139     */
140    private function notifyMentor( UserIdentity $mentor ) {
141        if ( !$this->config->get( 'GEPersonalizedPraiseNotificationsEnabled' ) ) {
142            return;
143        }
144
145        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
146            // Event::create accesses global state, avoid calling it when running the test
147            // suite.
148            Event::create( [
149                'type' => 'new-praiseworthy-mentees',
150                'title' => $this->specialPageFactory->getTitleForAlias( 'MentorDashboard' ),
151                'agent' => $mentor,
152            ] );
153        }
154
155        $this->eventLogger->logNotified( $mentor );
156        $this->setLastNotified( $mentor, MWTimestamp::getInstance()->getTimestamp( TS_MW ) );
157        $this->purgePendingMenteesForMentor( $mentor );
158    }
159
160    /**
161     * Called whenever GrowthExperiments suggests a new mentee to praise
162     *
163     * @param UserIdentity $mentor Mentor to whom the suggestion was made
164     * @param UserIdentity $mentee Mentee which was suggested
165     */
166    public function onMenteeSuggested( UserIdentity $mentor, UserIdentity $mentee ): void {
167        $freq = $this->personalizedPraiseSettings->getNotificationsFrequency( $mentor );
168        if ( $freq === PersonalizedPraiseSettings::NOTIFY_IMMEDIATELY ) {
169            $this->notifyMentor( $mentor );
170        } elseif ( $freq !== PersonalizedPraiseSettings::NOTIFY_NEVER ) {
171            $this->markMenteeAsPendingForMentor( $mentor, $mentee );
172        }
173    }
174
175    /**
176     * If mentor has any mentees they were not yet notified about, notify them
177     *
178     * @param UserIdentity $mentor
179     * @return bool Was the mentor notified?
180     */
181    public function maybeNotifyAboutPendingMentees( UserIdentity $mentor ): bool {
182        if ( !$this->doesMentorHavePendingMentees( $mentor ) ) {
183            return false;
184        }
185
186        $hoursToWait = $this->personalizedPraiseSettings->getNotificationsFrequency( $mentor );
187        if ( $hoursToWait === PersonalizedPraiseSettings::NOTIFY_IMMEDIATELY ) {
188            // NOTE: immediate notification is normally handled in onMenteeSuggested; only
189            // process pending mentees that were not yet processed.
190            $this->notifyMentor( $mentor );
191            return true;
192        } elseif ( $hoursToWait === PersonalizedPraiseSettings::NOTIFY_NEVER ) {
193            return false;
194        }
195
196        $rawLastNotifiedTS = $this->getLastNotified( $mentor );
197        $notifiedSecondsAgo = (int)MWTimestamp::getInstance()->getTimestamp( TS_UNIX ) -
198            (int)MWTimestamp::getInstance( $rawLastNotifiedTS ?? false )->getTimestamp( TS_UNIX );
199
200        if (
201            $rawLastNotifiedTS === null ||
202            $notifiedSecondsAgo >= $hoursToWait * ExpirationAwareness::TTL_HOUR
203        ) {
204            $this->notifyMentor( $mentor );
205            return true;
206        }
207        return false;
208    }
209}