PluginProbe ʕ •ᴥ•ʔ
Booking for Appointments and Events Calendar – Amelia / 2.4.4
Booking for Appointments and Events Calendar – Amelia v2.4.4
2.4.4 2.4.3 2.4.2 2.4.1 2.4 trunk 1.2.1 1.2.10 1.2.11 1.2.12 1.2.13 1.2.14 1.2.15 1.2.16 1.2.17 1.2.18 1.2.19 1.2.2 1.2.20 1.2.21 1.2.22 1.2.23 1.2.24 1.2.25 1.2.26 1.2.27 1.2.28 1.2.29 1.2.3 1.2.30 1.2.31 1.2.32 1.2.33 1.2.34 1.2.35 1.2.36 1.2.37 1.2.38 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 2.0 2.0.1 2.0.2 2.1 2.1.1 2.1.2 2.1.3 2.2 2.2.1 2.3
ameliabooking / vendor / melograno / usage-tracker / src / Core / UsageTracker.php
ameliabooking / vendor / melograno / usage-tracker / src / Core Last commit date
Anonymizer.php 1 month ago ConsentManager.php 1 month ago ConsentNoticeService.php 1 month ago HttpClient.php 1 month ago NoticeManager.php 1 month ago UsageTracker.php 1 month ago
UsageTracker.php
370 lines
1 <?php
2
3 declare(strict_types=1);
4
5 namespace AmeliaVendor\Melograno\UsageTracker\Core;
6
7 use AmeliaVendor\Melograno\UsageTracker\Collectors\ConsentNoticeCollectorInterface;
8 use AmeliaVendor\Melograno\UsageTracker\Collectors\PluginCollectorInterface;
9
10 /**
11 * Entry point and orchestrator for plugin usage telemetry.
12 *
13 * Lifecycle:
14 * - init() registers one tracker per collector slug, registers WordPress hooks per
15 * host plugin file, runs any upgrade migration, and boots scheduling once per slug.
16 * - The static facade (setConsent/isConsentEnabled/getSettings/updateSettings/
17 * renderConsentAdminNotice/deleteStoredOptions) resolves the tracker for a single
18 * registered slug automatically, or accepts an explicit collector when several
19 * Melograno plugins share this library in one request.
20 *
21 * This class is the only place that mutates consent state and (un)schedules the
22 * cron event. ConsentNoticeService only makes decisions and owns the notice option.
23 */
24 class UsageTracker
25 {
26 private const DEFAULT_ENDPOINT = 'https://bi.melograno.io/v1/usage';
27
28 /** @var array<string, bool> */
29 private static array $bootstrapped = [];
30
31 /** @var array<string, self> */
32 private static array $instances = [];
33
34 private PluginCollectorInterface $collector;
35
36 private ConsentManager $consentManager;
37
38 private Anonymizer $anonymizer;
39
40 private HttpClient $httpClient;
41
42 private ?ConsentNoticeService $consentNoticeService;
43
44 public function __construct(PluginCollectorInterface $collector)
45 {
46 $this->collector = $collector;
47 $this->consentManager = new ConsentManager($collector->getConsentOptionName());
48 $this->anonymizer = new Anonymizer();
49 $this->httpClient = new HttpClient();
50 $this->consentNoticeService = $collector instanceof ConsentNoticeCollectorInterface
51 ? new ConsentNoticeService($collector)
52 : null;
53 }
54
55 /**
56 * @param string|null $pluginFile Host plugin main file path; registers deactivation hooks when set.
57 * @param string|null $savedVersion Host plugin version stored before upgrade; runs consent migration when it differs from $currentVersion.
58 * @param string|null $currentVersion Host plugin version after upgrade.
59 */
60 public static function init(
61 PluginCollectorInterface $collector,
62 ?string $pluginFile = null,
63 ?string $savedVersion = null,
64 ?string $currentVersion = null
65 ): void {
66 $slug = $collector->getPluginSlug();
67 $tracker = new self($collector);
68 self::$instances[$slug] = $tracker;
69
70 if ($pluginFile !== null) {
71 $tracker->registerLifecycleHooks($pluginFile);
72 $tracker->registerConsentNoticeHooks();
73 $tracker->runUpgradeMigration($savedVersion, $currentVersion);
74 }
75
76 if (isset(self::$bootstrapped[$slug])) {
77 return;
78 }
79
80 self::$bootstrapped[$slug] = true;
81
82 $tracker->boot();
83 }
84
85 public static function renderConsentAdminNotice(?PluginCollectorInterface $collector = null): void
86 {
87 self::instance($collector)->renderAdminNotice();
88 }
89
90 public static function deleteStoredOptions(?PluginCollectorInterface $collector = null): void
91 {
92 self::instance($collector)->deleteOptions();
93 }
94
95 public static function setConsent(
96 bool $enabled,
97 ?PluginCollectorInterface $collector = null,
98 bool $armNoticeOnDisable = false
99 ): void {
100 self::instance($collector)->applyConsent($enabled, $armNoticeOnDisable);
101 }
102
103 public static function isConsentEnabled(?PluginCollectorInterface $collector = null): bool
104 {
105 return self::instance($collector)->consentManager->isEnabled();
106 }
107
108 /**
109 * @return array<string, mixed>
110 */
111 public static function getSettings(?PluginCollectorInterface $collector = null): array
112 {
113 return [
114 'usageTrackingEnabled' => self::isConsentEnabled($collector),
115 ];
116 }
117
118 /**
119 * Applies all library-managed settings from the incoming array
120 * and removes the handled keys so they don't leak into plugin storage.
121 */
122 /**
123 * @param bool $armNoticeOnDisable When consent is turned off, arm the admin notice (e.g. welcome wizard).
124 * Otherwise the notice is dismissed as a definitive opt-out.
125 */
126 public static function updateSettings(
127 array &$settings,
128 ?PluginCollectorInterface $collector = null,
129 bool $armNoticeOnDisable = false
130 ): void {
131 if (array_key_exists('usageTrackingEnabled', $settings)) {
132 self::setConsent((bool) $settings['usageTrackingEnabled'], $collector, $armNoticeOnDisable);
133 unset($settings['usageTrackingEnabled']);
134 unset($settings['armUsageTrackingNoticeOnDisable']);
135 }
136 }
137
138 public function boot(): void
139 {
140 add_action($this->collector->getCronHookName(), [$this, 'send']);
141
142 if (!$this->consentManager->isConfigured()) {
143 $this->initializeConsentForNewInstallation();
144 }
145
146 if ($this->consentManager->isEnabled()) {
147 $this->enableScheduling();
148 }
149 }
150
151 public function enableScheduling(): void
152 {
153 $hook = $this->collector->getCronHookName();
154
155 if (!wp_next_scheduled($hook)) {
156 $this->send();
157 wp_schedule_event(time(), $this->collector->getCronSchedule(), $hook);
158 }
159 }
160
161 public function send(): void
162 {
163 if (!$this->consentManager->isEnabled()) {
164 return;
165 }
166
167 $payload = $this->anonymizer->anonymize($this->collector->collect());
168
169 $this->httpClient->post($this->endpoint(), $payload);
170 }
171
172 /**
173 * Single source of truth for consent writes and the cron scheduling that follows.
174 *
175 * @param bool $armNoticeOnDisable Arm the admin notice when disabling consent (welcome wizard).
176 * Otherwise disabling dismisses the notice as a definitive opt-out.
177 */
178 public function applyConsent(bool $enabled, bool $armNoticeOnDisable = false): void
179 {
180 if ($enabled === $this->consentManager->isEnabled()
181 && ($enabled || $this->consentManager->isConfigured())) {
182 if ($enabled && $this->consentNoticeService !== null) {
183 $this->consentNoticeService->dismissNoticeIfArmed();
184 }
185
186 return;
187 }
188
189 if ($enabled) {
190 $this->consentManager->enable();
191 $this->enableScheduling();
192 if ($this->consentNoticeService !== null) {
193 $this->consentNoticeService->dismissNoticeIfArmed();
194 }
195 } else {
196 $this->consentManager->disable();
197 $this->unschedule();
198
199 if ($this->consentNoticeService === null) {
200 return;
201 }
202
203 if ($armNoticeOnDisable
204 && $this->collector instanceof ConsentNoticeCollectorInterface
205 && $this->collector->shouldShowAdminNotice()) {
206 $this->consentNoticeService->armNotice();
207 } else {
208 $this->consentNoticeService->dismissNotice();
209 }
210 }
211 }
212
213 /**
214 * Applies the collector's default consent the first time the option is stored,
215 * arming the admin notice when telemetry stays off by default.
216 */
217 private function initializeConsentForNewInstallation(): void
218 {
219 if ($this->consentNoticeService === null) {
220 return;
221 }
222
223 $enabled = $this->consentNoticeService->defaultConsent();
224
225 $this->applyConsent($enabled);
226
227 $this->consentNoticeService->armNoticeForNewInstallation($enabled);
228 }
229
230 /**
231 * Disables consent for eligible users upgrading into the opt-in release and arms the notice.
232 */
233 private function runUpgradeMigration(?string $savedVersion, ?string $currentVersion): void
234 {
235 if ($this->consentNoticeService === null) {
236 return;
237 }
238
239 if (empty($savedVersion) || empty($currentVersion) || $savedVersion === $currentVersion) {
240 return;
241 }
242
243 if (!$this->consentNoticeService->shouldRunUpgradeMigration($savedVersion, $currentVersion)) {
244 return;
245 }
246
247 $this->applyConsent(false);
248 $this->consentNoticeService->armNotice();
249 }
250
251 private function registerLifecycleHooks(string $pluginFile): void
252 {
253 register_deactivation_hook($pluginFile, function (): void {
254 $this->unschedule();
255 });
256 }
257
258 private function registerConsentNoticeHooks(): void
259 {
260 if ($this->consentNoticeService === null
261 || !$this->collector instanceof ConsentNoticeCollectorInterface) {
262 return;
263 }
264
265 add_action('admin_footer', function (): void {
266 $this->renderAdminNotice();
267 });
268
269 $ajaxPrefix = $this->collector->getConsentNoticeAjaxPrefix();
270
271 $nonceAction = $ajaxPrefix . '_usage_tracking_consent';
272
273 add_action(
274 'wp_ajax_' . $ajaxPrefix . '_enable_usage_tracking',
275 function () use ($nonceAction): void {
276 if (!current_user_can('manage_options')) {
277 wp_send_json_error();
278 }
279
280 check_ajax_referer($nonceAction);
281
282 $this->applyConsent(true);
283 wp_send_json_success();
284 }
285 );
286
287 add_action(
288 'wp_ajax_' . $ajaxPrefix . '_dismiss_usage_tracking_notice',
289 function () use ($nonceAction): void {
290 if (!current_user_can('manage_options')) {
291 wp_send_json_error();
292 }
293
294 check_ajax_referer($nonceAction);
295
296 if ($this->consentNoticeService !== null) {
297 $this->consentNoticeService->dismissNotice();
298 }
299 wp_send_json_success();
300 }
301 );
302 }
303
304 private function renderAdminNotice(): void
305 {
306 $service = $this->consentNoticeService;
307 if ($service === null) {
308 return;
309 }
310
311 if (!$service->canCurrentUserSeeAdminNotice() || !$service->shouldShowNotice()) {
312 return;
313 }
314
315 if ($this->collector instanceof ConsentNoticeCollectorInterface) {
316 $this->collector->renderConsentAdminNotice();
317 }
318 }
319
320 private function deleteOptions(): void
321 {
322 $this->consentManager->delete();
323
324 if ($this->collector instanceof ConsentNoticeCollectorInterface) {
325 (new NoticeManager($this->collector->getNoticeOptionName()))->delete();
326 }
327 }
328
329 private function unschedule(): void
330 {
331 wp_clear_scheduled_hook($this->collector->getCronHookName());
332 }
333
334 private function endpoint(): string
335 {
336 if (defined('MELOGRANO_BI_GATE_URL')) {
337 return MELOGRANO_BI_GATE_URL . '/v1/usage';
338 }
339
340 return self::DEFAULT_ENDPOINT;
341 }
342
343 private static function instance(?PluginCollectorInterface $collector = null): self
344 {
345 if ($collector !== null) {
346 $slug = $collector->getPluginSlug();
347
348 if (isset(self::$instances[$slug])) {
349 return self::$instances[$slug];
350 }
351
352 throw new \RuntimeException(
353 'UsageTracker is not initialized for collector slug: ' . $slug
354 );
355 }
356
357 if (count(self::$instances) === 1) {
358 return reset(self::$instances);
359 }
360
361 if (count(self::$instances) === 0) {
362 throw new \RuntimeException('UsageTracker is not initialized.');
363 }
364
365 throw new \RuntimeException(
366 'Multiple usage trackers are registered; pass the collector to resolve the correct instance.'
367 );
368 }
369 }
370