PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.14
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.14
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
fluentform / app / Modules / MCP / Tools / ReportTools.php

ReportTools.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.14, at app/Modules/MCP/Tools/ReportTools.php

535 lines 23.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentForm\App\Modules\MCP\Tools;
4
5 defined('ABSPATH') || exit;
6
7 use FluentForm\App\Helpers\Helper;
8 use FluentForm\App\Models\Form;
9 use FluentForm\App\Models\Submission;
10 use FluentForm\App\Modules\MCP\Support\ErrorCodes;
11 use FluentForm\App\Modules\MCP\Support\FormAccess;
12 use FluentForm\App\Modules\MCP\Support\MCPHelper;
13 use FluentForm\App\Modules\MCP\Support\PermissionGate;
14 use FluentForm\App\Services\Report\ReportHelper;
15 use FluentForm\Framework\Support\Arr;
16
17 /**
18 * Report tools (read).
19 *
20 * The get-form-stats tool answers "how is this form doing?" — entry counts by
21 * status, total views, and conversion rate. get-submissions-trend gives a daily time
22 * series for charting volume over a window. get-submissions-report ranks forms by
23 * volume and surfaces the busiest hours and weekdays across a date range.
24 * get-payment-summary answers revenue questions with a single server-side SUM
25 * over the transactions table, grouped by type and status — the same figures the
26 * admin Reports page shows. All form-scoped.
27 */
28 class ReportTools
29 {
30 const TREND_MAX_DAYS = 366;
31
32 const TOP_FORMS = 20;
33
34 public static function definitions()
35 {
36 return [
37 'fluentform/get-form-stats' => [
38 'label' => __('Get Form Stats', 'fluentform'),
39 'group' => __('Reports', 'fluentform'),
40 'description' => __('Headline numbers for one form: entry counts by status (unread, read, spam, trashed, favorites, all), total views, and conversion rate (entries ÷ views). Requires form_id.', 'fluentform'),
41 'input_schema' => [
42 'type' => 'object',
43 'properties' => [
44 'form_id' => ['type' => 'integer'],
45 ],
46 'required' => ['form_id'],
47 ],
48 'execute_callback' => [self::class, 'getFormStats'],
49 'capability' => ['fluentform_entries_viewer', 'fluentform_dashboard_access'],
50 'annotations' => ['readonly' => true],
51 ],
52
53 'fluentform/get-submissions-trend' => [
54 'label' => __('Get Submissions Trend', 'fluentform'),
55 'group' => __('Reports', 'fluentform'),
56 'description' => __('Daily entry counts for one form over a date window (defaults to the last 30 days, maximum 366 days). Returns a date→count series for charting submission volume. Requires form_id.', 'fluentform'),
57 'input_schema' => [
58 'type' => 'object',
59 'properties' => [
60 'form_id' => ['type' => 'integer'],
61 'date_from' => ['type' => 'string', 'description' => 'YYYY-MM-DD (site timezone). Defaults to 30 days ago. The window may span at most 366 days.'],
62 'date_to' => ['type' => 'string', 'description' => 'YYYY-MM-DD (site timezone). Defaults to today.'],
63 ],
64 'required' => ['form_id'],
65 ],
66 'execute_callback' => [self::class, 'getTrend'],
67 'capability' => ['fluentform_entries_viewer', 'fluentform_dashboard_access'],
68 'annotations' => ['readonly' => true],
69 ],
70
71 'fluentform/get-payment-summary' => [
72 'label' => __('Get Payment Summary', 'fluentform'),
73 'group' => __('Reports', 'fluentform'),
74 'description' => __('Payment totals computed server-side: amount and count per payment status (paid, pending, refunded, …), split into one-time and subscription transactions, and grouped by currency (amounts in different currencies are reported separately, never summed together). Scope to one form with form_id or omit it to aggregate every form you can access. Defaults to the last 30 days; widen date_from/date_to for lifetime totals.', 'fluentform'),
75 'input_schema' => [
76 'type' => 'object',
77 'properties' => [
78 'form_id' => ['type' => 'integer', 'description' => 'Optional. Omit to aggregate across all forms in your access scope.'],
79 'date_from' => ['type' => 'string', 'description' => 'YYYY-MM-DD (site timezone). Defaults to 30 days ago.'],
80 'date_to' => ['type' => 'string', 'description' => 'YYYY-MM-DD (site timezone). Defaults to today.'],
81 ],
82 ],
83 'execute_callback' => [self::class, 'getPaymentSummary'],
84 'capability' => 'fluentform_view_payments',
85 'annotations' => ['readonly' => true],
86 ],
87
88 'fluentform/get-submissions-report' => [
89 'label' => __('Get Submissions Report', 'fluentform'),
90 'group' => __('Reports', 'fluentform'),
91 'description' => __('Cross-form submission analytics for a date range: total entries, the forms ranked by submission count (top_forms — answers "which form got the most submissions"), and when they arrive — busiest hours (by_hour 0–23 in site time, plus peak_hour) and busiest days (by_weekday Monday–Sunday, plus peak_weekday). Scope to one form with form_id, or omit it to cover every form you can access. Defaults to the last 30 days (max 366).', 'fluentform'),
92 'input_schema' => [
93 'type' => 'object',
94 'properties' => [
95 'form_id' => ['type' => 'integer', 'description' => 'Optional. Omit to cover every form in your access scope.'],
96 'date_from' => ['type' => 'string', 'description' => 'YYYY-MM-DD (site timezone). Defaults to 30 days ago. The window may span at most 366 days.'],
97 'date_to' => ['type' => 'string', 'description' => 'YYYY-MM-DD (site timezone). Defaults to today.'],
98 ],
99 ],
100 'execute_callback' => [self::class, 'getSubmissionsReport'],
101 'capability' => ['fluentform_entries_viewer', 'fluentform_dashboard_access'],
102 'annotations' => ['readonly' => true],
103 ],
104 ];
105 }
106
107 public static function getFormStats($params = [])
108 {
109 $form = FormAccess::resolveForm($params);
110 if (is_wp_error($form)) {
111 return $form;
112 }
113 $formId = (int) $form->id;
114
115 $counts = (new Submission())->countByGroup($formId);
116 $views = (int) Helper::getFormMeta($formId, '_total_views', 0);
117 $all = isset($counts['all']) ? (int) $counts['all'] : 0;
118
119 $conversion = $views > 0 ? round(($all / $views) * 100, 2) : null;
120
121 $data = [
122 'form_id' => $formId,
123 'counts' => $counts,
124 'total_views' => $views,
125 'conversion_rate_pct' => $conversion,
126 ];
127
128 // Entries can exceed tracked views (e.g. a form embedded in a template
129 // with view tracking off), which pushes the rate past 100% — flag it
130 // rather than emit a bogus number the agent would read as real.
131 if (null !== $conversion && $all > $views) {
132 $data['views_note'] = __('Entries exceed tracked views, so the conversion rate is unreliable — view tracking may be disabled or unavailable for this form.', 'fluentform');
133 }
134
135 return MCPHelper::envelope(
136 sprintf(
137 /* translators: 1: form title, 2: entry count, 3: view count */
138 __('"%1$s": %2$d entries from %3$d views.', 'fluentform'),
139 $form->title,
140 $all,
141 $views
142 ),
143 $data
144 );
145 }
146
147 public static function getTrend($params = [])
148 {
149 $form = FormAccess::resolveForm($params);
150 if (is_wp_error($form)) {
151 return $form;
152 }
153 $formId = (int) $form->id;
154
155 $window = self::dateWindow($params, self::TREND_MAX_DAYS);
156 if (is_wp_error($window)) {
157 return $window;
158 }
159 list($from, $to) = $window;
160
161 $rows = Submission::query()
162 ->where('form_id', $formId)
163 ->where('status', '!=', 'trashed')
164 ->where('created_at', '>=', $from . ' 00:00:00')
165 ->where('created_at', '<=', $to . ' 23:59:59')
166 ->selectRaw('DATE(created_at) as day, COUNT(*) as count')
167 ->groupBy('day')
168 ->orderBy('day', 'ASC')
169 ->get();
170
171 $series = [];
172 $total = 0;
173 foreach ($rows as $row) {
174 $count = (int) $row->count;
175 $total += $count;
176 $series[] = ['date' => $row->day, 'count' => $count];
177 }
178
179 return MCPHelper::envelope(
180 sprintf(
181 /* translators: 1: total entries, 2: start date, 3: end date */
182 __('%1$d entries between %2$s and %3$s.', 'fluentform'),
183 $total,
184 $from,
185 $to
186 ),
187 [
188 'form_id' => $formId,
189 'from' => $from,
190 'to' => $to,
191 'total' => $total,
192 'series' => $series,
193 ]
194 );
195 }
196
197 public static function getPaymentSummary($params = [])
198 {
199 $formId = 0;
200 if (!empty($params['form_id'])) {
201 // ReportHelper trusts a non-zero form id without a scope check, so
202 // the access check has to happen here, before it's passed down.
203 $form = FormAccess::resolveForm($params);
204 if (is_wp_error($form)) {
205 return $form;
206 }
207 $formId = (int) $form->id;
208 }
209
210 $settings = get_option('__fluentform_payment_module_settings');
211 if (!$settings || !Arr::isTrue($settings, 'status')) {
212 return MCPHelper::error(ErrorCodes::FEATURE_DISABLED, __('The payment module is disabled, so there is no payment data to summarize.', 'fluentform'));
213 }
214
215 // No span clamp: this is a SUM grouped by status (a handful of rows),
216 // not per-day buckets, and the admin Reports page runs the same query
217 // unbounded — lifetime totals are a legitimate ask.
218 $window = self::dateWindow($params);
219 if (is_wp_error($window)) {
220 return $window;
221 }
222 list($from, $to) = $window;
223
224 $fromDt = $from . ' 00:00:00';
225 $toDt = $to . ' 23:59:59';
226
227 // One canonical scan for both types (admin Reports reads the same helper), so
228 // the agent and the admin page can't drift. by_currency never blends currencies
229 // (a cross-currency SUM is meaningless); full-day bounds keep the window
230 // inclusive of the last day. Form scoping is identical on both sides — both
231 // resolve FormManagerService::getUserAllowedFormsScope().
232 $breakdown = ReportHelper::getPaymentBreakdown($fromDt, $toDt, $formId);
233 $subByCurrency = Arr::get($breakdown, 'subscription.by_currency', []);
234 $oneByCurrency = Arr::get($breakdown, 'onetime.by_currency', []);
235
236 $emptyBlock = ['currency_symbol' => '', 'payment_statuses' => [], 'total_amount' => 0, 'weekly_average' => 0];
237
238 // Currency-first: each currency carries its own onetime + subscription block.
239 $currencies = [];
240 foreach (array_unique(array_merge(array_keys($oneByCurrency), array_keys($subByCurrency))) as $currency) {
241 $onetime = Arr::get($oneByCurrency, $currency, $emptyBlock);
242 $subscription = Arr::get($subByCurrency, $currency, $emptyBlock);
243 $symbol = '' !== $onetime['currency_symbol'] ? $onetime['currency_symbol'] : $subscription['currency_symbol'];
244 // A block empty for this currency still labels itself with the currency's symbol.
245 $onetime['currency_symbol'] = $symbol;
246 $subscription['currency_symbol'] = $symbol;
247 $paid = (float) Arr::get($onetime, 'payment_statuses.paid.amount', 0)
248 + (float) Arr::get($subscription, 'payment_statuses.paid.amount', 0);
249
250 $currencies[] = [
251 'currency' => $currency,
252 'currency_symbol' => $symbol,
253 'total_paid' => round($paid, 2),
254 'onetime' => $onetime,
255 'subscription' => $subscription,
256 ];
257 }
258
259 // Highest paid total first, so the dominant currency leads.
260 usort($currencies, function ($a, $b) {
261 return $b['total_paid'] <=> $a['total_paid'];
262 });
263
264 $data = [
265 'form_id' => $formId ? $formId : null,
266 'from' => $from,
267 'to' => $to,
268 'multi_currency' => count($currencies) > 1,
269 'currencies' => $currencies,
270 ];
271
272 if (empty($currencies)) {
273 $data['total_paid'] = 0;
274
275 return MCPHelper::envelope(
276 sprintf(
277 /* translators: 1: start date, 2: end date */
278 __('No payments between %1$s and %2$s.', 'fluentform'),
279 $from,
280 $to
281 ),
282 $data
283 );
284 }
285
286 // One currency (the common case): surface it at the top level too, so the
287 // answer is one hop away. Only ever for a single currency — surfacing a
288 // top-level total across currencies is exactly the blend this must not do.
289 if (1 === count($currencies)) {
290 $only = $currencies[0];
291 $data['currency'] = $only['currency'];
292 $data['currency_symbol'] = $only['currency_symbol'];
293 $data['total_paid'] = $only['total_paid'];
294 $data['onetime'] = $only['onetime'];
295 $data['subscription'] = $only['subscription'];
296
297 return MCPHelper::envelope(
298 sprintf(
299 /* translators: 1: currency symbol, 2: paid amount, 3: start date, 4: end date */
300 __('%1$s%2$s paid between %3$s and %4$s.', 'fluentform'),
301 $only['currency_symbol'],
302 number_format($only['total_paid'], 2),
303 $from,
304 $to
305 ),
306 $data
307 );
308 }
309
310 $parts = [];
311 foreach ($currencies as $c) {
312 $parts[] = $c['currency_symbol'] . number_format($c['total_paid'], 2) . ' ' . $c['currency'];
313 }
314 $data['note'] = __('Amounts span multiple currencies and are reported separately under "currencies"; they are not summed.', 'fluentform');
315
316 return MCPHelper::envelope(
317 sprintf(
318 /* translators: 1: comma-separated per-currency paid totals, 2: start date, 3: end date */
319 __('Paid between %2$s and %3$s by currency: %1$s.', 'fluentform'),
320 implode('; ', $parts),
321 $from,
322 $to
323 ),
324 $data
325 );
326 }
327
328 public static function getSubmissionsReport($params = [])
329 {
330 // Resolve the form set: a single authorized form, or every form in scope.
331 // null = unrestricted (all forms); [] = restricted to no forms.
332 $formIds = null;
333 if (!empty($params['form_id'])) {
334 $form = FormAccess::resolveForm($params);
335 if (is_wp_error($form)) {
336 return $form;
337 }
338 $formIds = [(int) $form->id];
339 } else {
340 $scope = PermissionGate::formScope();
341 if (is_array($scope)) {
342 $formIds = array_values(array_map('intval', $scope));
343 }
344 }
345
346 $window = self::dateWindow($params, self::TREND_MAX_DAYS);
347 if (is_wp_error($window)) {
348 return $window;
349 }
350 list($from, $to) = $window;
351
352 if (is_array($formIds) && empty($formIds)) {
353 return MCPHelper::envelope(
354 __('No forms are within your access scope, so there is nothing to report.', 'fluentform'),
355 self::emptyReport($from, $to)
356 );
357 }
358
359 $fromDt = $from . ' 00:00:00';
360 $toDt = $to . ' 23:59:59';
361
362 // Rebuilt per aggregate — a query builder is consumed by its terminal call.
363 $base = function () use ($formIds, $fromDt, $toDt) {
364 $q = Submission::query()
365 ->where('status', '!=', 'trashed')
366 ->where('created_at', '>=', $fromDt)
367 ->where('created_at', '<=', $toDt);
368 if (null !== $formIds) {
369 $q->whereIn('form_id', $formIds);
370 }
371 return $q;
372 };
373
374 // Three bounded aggregates instead of one (form_id, hour, weekday) scan that
375 // hydrated forms x 24 x 7 rows into PHP: each groups by a single dimension, so
376 // the result set is capped (<=24 hours, <=7 weekdays, <=TOP_FORMS forms) no
377 // matter how many forms exist. MySQL DAYOFWEEK: 1=Sunday … 7=Saturday.
378 $dowNames = [1 => 'Sunday', 2 => 'Monday', 3 => 'Tuesday', 4 => 'Wednesday', 5 => 'Thursday', 6 => 'Friday', 7 => 'Saturday'];
379 $byHour = array_fill(0, 24, 0);
380 $byWeekday = array_fill_keys(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], 0);
381
382 foreach ($base()->selectRaw('HOUR(created_at) as h, COUNT(*) as c')->groupBy('h')->get() as $r) {
383 $byHour[(int) $r->h] = (int) $r->c;
384 }
385 foreach ($base()->selectRaw('DAYOFWEEK(created_at) as d, COUNT(*) as c')->groupBy('d')->get() as $r) {
386 $name = isset($dowNames[(int) $r->d]) ? $dowNames[(int) $r->d] : '';
387 if ('' !== $name) {
388 $byWeekday[$name] = (int) $r->c;
389 }
390 }
391 // Every entry falls in exactly one hour bucket, so the hour totals sum to the
392 // window total — no separate COUNT scan needed.
393 $total = array_sum($byHour);
394
395 $peakHour = $total > 0 ? self::indexOfMax($byHour) : null;
396 $peakWeekday = $total > 0 ? self::keyOfMax($byWeekday) : null;
397
398 // Leaderboard: SQL ranks + caps the per-form totals (<=TOP_FORMS rows), then a
399 // single batched title lookup — no full per-form hydration in PHP.
400 $rankIds = [];
401 $counts = [];
402 foreach ($base()->selectRaw('form_id, COUNT(*) as c')->groupBy('form_id')->orderByRaw('c DESC')->limit(self::TOP_FORMS)->get() as $r) {
403 $fid = (int) $r->form_id;
404 $rankIds[] = $fid;
405 $counts[$fid] = (int) $r->c;
406 }
407 $titles = [];
408 if ($rankIds) {
409 foreach (Form::query()->whereIn('id', $rankIds)->get(['id', 'title']) as $f) {
410 $titles[(int) $f->id] = $f->title;
411 }
412 }
413 $topForms = [];
414 foreach ($rankIds as $fid) {
415 $topForms[] = [
416 'form_id' => $fid,
417 'title' => isset($titles[$fid]) ? $titles[$fid] : null,
418 'count' => $counts[$fid],
419 ];
420 }
421
422 $summary = ($topForms && null !== $peakHour)
423 ? sprintf(
424 /* translators: 1: total entries, 2: top form title, 3: peak weekday, 4: peak hour */
425 __('%1$d entries. Top form: "%2$s". Busiest: %3$s around %4$02d:00.', 'fluentform'),
426 $total,
427 (string) $topForms[0]['title'],
428 (string) $peakWeekday,
429 (int) $peakHour
430 )
431 : sprintf(
432 /* translators: %d: total entries */
433 __('%d entries in this window.', 'fluentform'),
434 $total
435 );
436
437 return MCPHelper::envelope($summary, [
438 'form_id' => (is_array($formIds) && 1 === count($formIds)) ? $formIds[0] : null,
439 'from' => $from,
440 'to' => $to,
441 'total' => $total,
442 'top_forms' => $topForms,
443 'by_hour' => $byHour,
444 'peak_hour' => $peakHour,
445 'by_weekday' => $byWeekday,
446 'peak_weekday' => $peakWeekday,
447 ]);
448 }
449
450 private static function emptyReport($from, $to)
451 {
452 return [
453 'form_id' => null,
454 'from' => $from,
455 'to' => $to,
456 'total' => 0,
457 'top_forms' => [],
458 'by_hour' => array_fill(0, 24, 0),
459 'peak_hour' => null,
460 'by_weekday' => array_fill_keys(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], 0),
461 'peak_weekday' => null,
462 ];
463 }
464
465 /** Index of the highest value in a 0-based list (first wins on a tie). */
466 private static function indexOfMax(array $vals)
467 {
468 $max = -1;
469 $idx = 0;
470 foreach ($vals as $i => $v) {
471 if ($v > $max) {
472 $max = $v;
473 $idx = $i;
474 }
475 }
476
477 return $idx;
478 }
479
480 /** Key of the highest value in a map (first wins on a tie, insertion order). */
481 private static function keyOfMax(array $map)
482 {
483 $max = -1;
484 $key = null;
485 foreach ($map as $k => $v) {
486 if ($v > $max) {
487 $max = $v;
488 $key = $k;
489 }
490 }
491
492 return $key;
493 }
494
495 /**
496 * Validated Y-m-d [from, to] window from tool params. Defaults to the last
497 * 30 days ending today; a non-zero $maxDays caps the span.
498 *
499 * @return array|\WP_Error
500 */
501 private static function dateWindow($params, $maxDays = 0)
502 {
503 $to = !empty($params['date_to']) ? sanitize_text_field($params['date_to']) : gmdate('Y-m-d', current_time('timestamp'));
504 if (!MCPHelper::isYmd($to)) {
505 return MCPHelper::error(ErrorCodes::INVALID_PARAM, __('date_to must be a valid date in YYYY-MM-DD format.', 'fluentform'), ['fields' => ['date_to']]);
506 }
507
508 $from = !empty($params['date_from']) ? sanitize_text_field($params['date_from']) : gmdate('Y-m-d', strtotime('-30 days', strtotime($to)));
509 if (!MCPHelper::isYmd($from)) {
510 return MCPHelper::error(ErrorCodes::INVALID_PARAM, __('date_from must be a valid date in YYYY-MM-DD format.', 'fluentform'), ['fields' => ['date_from']]);
511 }
512
513 if ($from > $to) {
514 return MCPHelper::error(ErrorCodes::INVALID_PARAM, __('date_from must be on or before date_to.', 'fluentform'), ['fields' => ['date_from', 'date_to']]);
515 }
516
517 if ($maxDays) {
518 $spanDays = (int) (new \DateTime($from))->diff(new \DateTime($to))->days;
519 if ($spanDays > $maxDays) {
520 return MCPHelper::error(
521 ErrorCodes::INVALID_PARAM,
522 sprintf(
523 /* translators: %d: maximum allowed days in the date window */
524 __('The date window may span at most %d days. Narrow date_from/date_to and call again.', 'fluentform'),
525 $maxDays
526 ),
527 ['fields' => ['date_from', 'date_to'], 'max_days' => $maxDays]
528 );
529 }
530 }
531
532 return [$from, $to];
533 }
534 }
535