Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 87
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
MessageGroupSubscription
0.00% covered (danger)
0.00%
0 / 87
0.00% covered (danger)
0.00%
0 / 12
870
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 isEnabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 subscribeToGroup
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 isUserSubscribedTo
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unsubscribeFromGroup
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 queueMessage
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 queueNotificationJob
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 sendNotifications
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
56
 handleMessageIndexUpdate
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getGroupSubscribers
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getSubscriberIdsForGroups
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 canUserSubscribeToGroup
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\MessageGroupProcessing;
5
6use EmptyIterator;
7use Iterator;
8use JobQueueGroup;
9use MediaWiki\Config\ServiceOptions;
10use MediaWiki\Extension\Notifications\Model\Event;
11use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
12use MediaWiki\Title\Title;
13use MediaWiki\User\UserIdentity;
14use MediaWiki\User\UserIdentityLookup;
15use MessageGroup;
16use Psr\Log\LoggerInterface;
17use StatusValue;
18use User;
19
20/**
21 * Manage user subscriptions to message groups and trigger notifications
22 * @since 2024.04
23 * @license GPL-2.0-or-later
24 * @author Abijeet Patro
25 */
26class MessageGroupSubscription {
27    private MessageGroupSubscriptionStore $groupSubscriptionStore;
28    private JobQueueGroup $jobQueueGroup;
29    private bool $isMessageGroupSubscriptionEnabled;
30    private UserIdentityLookup $userIdentityLookup;
31    private array $queuedMessages = [];
32    private LoggerInterface $logger;
33
34    public const STATE_REMOVED = 'removed';
35    public const STATE_ADDED = 'added';
36    public const CONSTRUCTOR_OPTIONS = [ 'TranslateEnableMessageGroupSubscription' ];
37
38    public const NOT_ENABLED = 'mgs-not-enabled';
39    public const UNNAMED_USER_UNSUPPORTED = 'mgs-unnamed-user-unsupported';
40    public const DYNAMIC_GROUP_UNSUPPORTED = 'mgs-dynamic-group-unsupported';
41
42    public function __construct(
43        MessageGroupSubscriptionStore $groupSubscriptionStore,
44        JobQueueGroup $jobQueueGroup,
45        UserIdentityLookup $userIdentityLookup,
46        LoggerInterface $logger,
47        ServiceOptions $options
48    ) {
49        $this->groupSubscriptionStore = $groupSubscriptionStore;
50        $this->jobQueueGroup = $jobQueueGroup;
51        $this->userIdentityLookup = $userIdentityLookup;
52        $this->logger = $logger;
53        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
54        $this->isMessageGroupSubscriptionEnabled = $options->get( 'TranslateEnableMessageGroupSubscription' );
55    }
56
57    public function isEnabled(): bool {
58        return $this->isMessageGroupSubscriptionEnabled;
59    }
60
61    public function subscribeToGroup( MessageGroup $group, User $user ): StatusValue {
62        $status = $this->canUserSubscribeToGroup( $group, $user );
63        if ( !$status->isOK() ) {
64            return $status;
65        }
66
67        $this->groupSubscriptionStore->addSubscription( $group->getId(), $user->getId() );
68        return StatusValue::newGood();
69    }
70
71    public function isUserSubscribedTo( MessageGroup $group, UserIdentity $user ): bool {
72        return $this->groupSubscriptionStore->getSubscriptions( [ $group->getId() ], $user->getId() )->count() !== 0;
73    }
74
75    public function unsubscribeFromGroup( MessageGroup $group, UserIdentity $user ): void {
76        $this->groupSubscriptionStore->removeSubscriptions( $group->getId(), $user->getId() );
77    }
78
79    /**
80     * Queue a message / group to send notifications for
81     * @param Title $messageTitle
82     * @param string $state
83     * @param string[] $groupIds
84     * @return void
85     */
86    public function queueMessage(
87        Title $messageTitle,
88        string $state,
89        array $groupIds
90    ): void {
91        foreach ( $groupIds as $groupId ) {
92            $this->queuedMessages[ $groupId ][ $state ][] = $messageTitle->getPrefixedDBkey();
93        }
94    }
95
96    public function queueNotificationJob(): void {
97        if ( !$this->isEnabled() || $this->queuedMessages === [] ) {
98            return;
99        }
100
101        $this->jobQueueGroup->push( MessageGroupSubscriptionNotificationJob::newJob( $this->queuedMessages ) );
102        $this->logger->debug(
103            'Queued job with changes for {countGroups} groups',
104            [ 'countGroups' => count( $this->queuedMessages ) ]
105        );
106        // Reset queued messages once job has been queued
107        $this->queuedMessages = [];
108    }
109
110    public function sendNotifications( array $changesToProcess ): void {
111        if ( !$this->isEnabled() || $changesToProcess === [] ) {
112            return;
113        }
114
115        $groupIds = array_keys( $changesToProcess );
116        $allGroupSubscribers = $this->getSubscriberIdsForGroups( $groupIds );
117
118        // No subscribers found for the groups
119        if ( !$allGroupSubscribers ) {
120            $this->logger->info( 'No subscribers for groups.' );
121            return;
122        }
123
124        $groups = MessageGroups::getGroupsById( $groupIds );
125        foreach ( $changesToProcess as $groupId => $state ) {
126            $group = $groups[ $groupId ] ?? null;
127            if ( !$group ) {
128                $this->logger->debug(
129                    'Group not found {groupId}.',
130                    [ 'groupId' => $groupId ]
131                );
132                continue;
133            }
134
135            $groupSubscribers = $allGroupSubscribers[ $groupId ] ?? [];
136            if ( $groupSubscribers === [] ) {
137                $this->logger->info(
138                    'No subscribers found for {groupId} group.',
139                    [ 'groupId' => $groupId ]
140                );
141                continue;
142            }
143
144            Event::create( [
145                'type' => 'translate-mgs-message-added-removed',
146                'extra' => [
147                    'groupId' => $groupId,
148                    'groupLabel' => $group->getLabel(),
149                    'changes' => $state
150                ]
151            ] );
152
153            $this->logger->info(
154                'Event created for {groupId} with {subscriberCount} subscribers.',
155                [
156                    'groupId' => $groupId,
157                    'subscriberCount' => count( $groupSubscribers )
158                ]
159            );
160        }
161    }
162
163    public function handleMessageIndexUpdate( MessageHandle $handle, array $old, array $new ): void {
164        $removedGroups = array_diff( $old, $new );
165        if ( $removedGroups ) {
166            $this->queueMessage( $handle->getTitle(), self::STATE_REMOVED, $removedGroups );
167        }
168
169        $addedGroups = array_diff( $new, $old );
170        if ( $addedGroups ) {
171            $this->queueMessage( $handle->getTitle(), self::STATE_ADDED, $addedGroups );
172        }
173    }
174
175    /**
176     * Given a group id returns an iterator to the subscribers of that group.
177     * @return Iterator<UserIdentity>
178     */
179    public function getGroupSubscribers( string $groupId ): Iterator {
180        $groupSubscriberIds = $this->getSubscriberIdsForGroups( [ $groupId ] );
181        $groupSubscriberIds = $groupSubscriberIds[ $groupId ] ?? [];
182        if ( $groupSubscriberIds === [] ) {
183            return new EmptyIterator();
184        }
185
186        return $this->userIdentityLookup->newSelectQueryBuilder()
187            ->whereUserIds( $groupSubscriberIds )
188            ->fetchUserIdentities();
189    }
190
191    /**
192     * Get all subscribers for groups. Returns an array where the keys are the
193     * group ids and value is a list of integer user ids
194     * @param string[] $groupIds
195     * @return array[] [(str) groupId => (int[]) userId, ...]
196     */
197    private function getSubscriberIdsForGroups( array $groupIds ): array {
198        $dbGroupSubscriptions = $this->groupSubscriptionStore->getSubscriptions( $groupIds, null );
199        $groupSubscriptions = [];
200
201        foreach ( $dbGroupSubscriptions as $row ) {
202            $groupSubscriptions[ $row->tmgs_group ][] = (int)$row->tmgs_user_id;
203        }
204
205        return $groupSubscriptions;
206    }
207
208    public function canUserSubscribeToGroup( MessageGroup $group, User $user ): StatusValue {
209        if ( !$this->isEnabled() ) {
210            return StatusValue::newFatal( self::NOT_ENABLED );
211        }
212
213        if ( MessageGroups::isDynamic( $group ) ) {
214            return StatusValue::newFatal( self::DYNAMIC_GROUP_UNSUPPORTED );
215        }
216
217        if ( !$user->isNamed() ) {
218            return StatusValue::newFatal( self::UNNAMED_USER_UNSUPPORTED );
219        }
220
221        return StatusValue::newGood();
222    }
223}