PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
← All changes | app/Modules/Reporting/Reporting.php +276 -201 2.3.1 → 2.4.5 View file →
@@ -5,23 +5,21 @@
5 5 use FluentSupport\App\Models\Agent;
6 6 use FluentSupport\App\Models\AgentGroup;
7 7 use FluentSupport\App\Models\Conversation;
8 8 use FluentSupport\App\Models\MailBox;
9 -use FluentSupport\App\Models\Meta;
10 9 use FluentSupport\App\Models\Person;
11 10 use FluentSupport\App\Models\Product;
12 11 use FluentSupport\App\Models\TagPivot;
13 12 use FluentSupport\App\Models\Ticket;
13 +use FluentSupport\App\Modules\PermissionManager;
14 14 use FluentSupport\App\Services\Helper;
15 +use FluentSupport\App\Services\Tickets\AgentTicketAccess;
15 16 use FluentSupport\Framework\Database\Orm\Builder;
16 17 use FluentSupport\Framework\Support\Arr;
17 18 use FluentSupport\Framework\Support\DateTime;
18 19
19 20 /**
20 - * Reporting class is responsible for getting data related to report
21 - * @package FluentSupport\App\Modules\Reporting
22 - *
23 - * @version 1.0.0
21 + * Ticket, response and agent reports, scoped to the current agent's mailboxes.
24 22 */
25 23 class Reporting
26 24 {
27 25 use ReportingHelperTrait;
@@ -26,27 +24,25 @@
26 24 {
27 25 use ReportingHelperTrait;
28 26
29 27 /**
30 - * getTicketsGrowth will generate tickets statistics and return
31 - * @param false $from
32 - * @param false $to
28 + * New tickets per period.
29 + *
30 + * @param string|false $from
31 + * @param string|false $to
33 32 * @param array $filters
34 33 * @return array
35 34 */
36 35 public function getTicketsGrowth($from = false, $to = false, $filters = [])
37 36 {
38 - //Generate report period
39 37 $period = $this->makeDatePeriod(
40 - $from = $this->makeFromDate($from),//Date from
41 - $to = $this->makeToDate($to),//Date to
42 - $frequency = $this->getFrequency($from, $to)// frequency P1D, P1W, P1M
38 + $from = $this->makeFromDate($from),
39 + $to = $this->makeToDate($to),
40 + $frequency = $this->getFrequency($from, $to)
43 41 );
44 42
45 - //Get group by and order by i.e date,week, month
46 43 list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);
47 44
48 - //get all tickets statistics within the date range
49 45 $query = $this->db()->table('fs_tickets')
50 46 ->select($this->prepareSelect($frequency))
51 47 ->whereBetween('created_at', $this->prepareBetween($frequency, $from, $to))
52 48 ->groupBy($groupBy)
@@ -51,9 +47,11 @@
51 47 ->whereBetween('created_at', $this->prepareBetween($frequency, $from, $to))
52 48 ->groupBy($groupBy)
53 49 ->oldest($orderBy);
54 50
55 - //If filter by product or agent or status selected
51 + // Scope first. A mailbox_id filter below is not proof of access.
52 + (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
53 +
56 54 if ($filters) {
57 55 if (!empty($filters['statuses'])) {
58 56 $query->whereIn('status', $filters['statuses']);
59 57 }
@@ -80,11 +78,12 @@
80 78 return $this->getResult($period, $items);
81 79 }
82 80
83 81 /**
84 - * getTicketResolveGrowth method will get the statistics for resolved/closed tickets
85 - * @param false $from
86 - * @param false $to
82 + * Closed tickets per period, by resolved_at.
83 + *
84 + * @param string|false $from
85 + * @param string|false $to
87 86 * @param array $filters
88 87 * @return array
89 88 */
90 89 public function getTicketResolveGrowth($from = false, $to = false, $filters = [], $type = '')
@@ -89,18 +88,17 @@
89 88 */
90 89 public function getTicketResolveGrowth($from = false, $to = false, $filters = [], $type = '')
91 90 {
92 91 $period = $this->makeDatePeriod(
93 - $from = $this->makeFromDate($from),//Date from
94 - $to = $this->makeToDate($to),//date to
95 - $frequency = $this->getFrequency($from, $to)// frequency P1D, P1W, P1M
92 + $from = $this->makeFromDate($from),
93 + $to = $this->makeToDate($to),
94 + $frequency = $this->getFrequency($from, $to)
96 95 );
97 96
98 - list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);//Get group by and order by i.e date,week, month
97 + list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);
99 98
100 99 $filterColumn = (!empty($type)) ? $type.'_id' : 'id';
101 100
102 - //get the closed ticket statistics within the date range
103 101 $query = $this->db()->table('fs_tickets')
104 102 ->select($this->prepareSelect($frequency, 'resolved_at'))
105 103 ->whereBetween('resolved_at', $this->prepareBetween($frequency, $from, $to))
106 104 ->where('status', 'closed')
@@ -107,9 +105,10 @@
107 105 ->where($filterColumn, '>', 0)
108 106 ->groupBy($groupBy)
109 107 ->oldest($orderBy);
110 108
111 - //If filter by product or agent is selected
109 + (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
110 +
112 111 if ($filters) {
113 112 if (!empty($filters['product_id'])) {
114 113 $query->where('product_id', $filters['product_id']);
115 114 }
@@ -132,11 +131,12 @@
132 131 return $this->getResult($period, $items);
133 132 }
134 133
135 134 /**
136 - * getResponseGrowth method will generate the statistics for response
137 - * @param false $from
138 - * @param false $to
135 + * Responses per period.
136 + *
137 + * @param string|false $from
138 + * @param string|false $to
139 139 * @param array $filters
140 140 * @return array
141 141 */
142 142 public function getResponseGrowth($from = false, $to = false, $filters = [])
@@ -148,17 +148,17 @@
148 148 );
149 149
150 150 list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);
151 151
152 - $query = Conversation::query()
153 - ->select($this->prepareSelect($frequency))
154 - ->whereBetween('created_at', $this->prepareBetween($frequency, $from, $to))
155 - ->where('conversation_type', 'response')
156 - ->whereHas('person', function ($q) {
157 - $q->where('person_type', 'agent');
158 - })
159 - ->groupBy($groupBy)
160 - ->oldest($orderBy);
152 + $query = (new AgentTicketAccess())->applyMailboxRestrictionScopeViaTicket(
153 + Conversation::query()
154 + ->select($this->prepareSelect($frequency))
155 + ->whereBetween('created_at', $this->prepareBetween($frequency, $from, $to))
156 + ->where('conversation_type', 'response')
157 + ->whereIn('person_id', Agent::getAgentIds())
158 + ->groupBy($groupBy)
159 + ->oldest($orderBy)
160 + );
161 161
162 162 if ($filters) {
163 163 if (!empty($filters['person_id'])) {
164 164 $query->where('person_id', $filters['person_id']);
@@ -187,9 +187,9 @@
187 187 );
188 188
189 189 list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);
190 190
191 - $filterColumn = $type."_id";
191 + $filterColumn = (!empty($type)) ? $type.'_id' : 'id';
192 192
193 193 $query = $this->db()->table('fs_tickets')
194 194 ->select($this->prepareSelect($frequency,'created_at','response_count'))
195 195 ->whereBetween('created_at', $this->prepareBetween($frequency, $from, $to))
@@ -197,8 +197,10 @@
197 197 ->where($filterColumn, '>', 0)
198 198 ->groupBy($groupBy)
199 199 ->oldest($orderBy);
200 200
201 + (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
202 +
201 203 if ($filters) {
202 204 if (!empty($filters['product_id'])) {
203 205 $query->where('product_id', $filters['product_id']);
204 206 }
@@ -212,13 +214,29 @@
212 214
213 215 return $this->getResult($period, $items);
214 216 }
215 217
218 + // One grouped query instead of one per agent; shared by the two summaries.
219 + private function interactionCountsByAgent($from, $to, $access)
220 + {
221 + return $access->applyMailboxRestrictionScopeViaTicket(
222 + Conversation::select([
223 + $this->db()->raw('person_id as agent_id'),
224 + $this->db()->raw('COUNT(DISTINCT ticket_id) as count')
225 + ])
226 + ->whereIn('person_id', Agent::getAgentIds())
227 + ->whereBetween('created_at', [$from, $to])
228 + ->where('conversation_type', 'response')
229 + ->groupBy('person_id')
230 + )->get();
231 + }
232 +
216 233 /**
217 - * agentSummary method will prepare ticket summary with responses by agent
218 - * @param false $from
219 - * @param false $to
220 - * @param false $agent
234 + * Per-agent ticket and response counts for a date range (default today).
235 + *
236 + * @param string|false $from
237 + * @param string|false $to
238 + * @param string|false $agent Comma-separated agent ids.
221 239 * @return mixed
222 240 */
223 241 public function agentSummary($from = false, $to = false, $agent = false)
224 242 {
@@ -233,9 +251,10 @@
233 251 $from .= ' 00:00:00';
234 252 $to .= ' 23:59:59';
235 253 $reports = [];
236 254
237 - //Get tickets statistics that are closed
255 + $access = new AgentTicketAccess();
256 +
238 257 $resolves = $this->db()->table('fs_tickets')
239 258 ->select([
240 259 $this->db()->raw('COUNT(id) AS count'),
241 260 'agent_id',
@@ -241,14 +260,15 @@
241 260 'agent_id',
242 261 ])
243 262 ->groupBy('agent_id')
244 263 ->where('status', 'closed')
245 - ->whereBetween('resolved_at', [$from, $to])
246 - ->get();
264 + ->whereBetween('resolved_at', [$from, $to]);
247 265
266 + $resolves = $access->applyMailboxRestrictionScope($resolves)->get();
267 +
248 268 $reports = $this->pushReportData('closed', $resolves, $reports, 'agent_id');
249 269
250 - //get statistics for all except closed ticket
270 + // Open tickets aren't limited to the date range
251 271 $openTickets = $this->db()->table('fs_tickets')
252 272 ->select([
253 273 $this->db()->raw('COUNT(id) AS count'),
254 274 'agent_id'
@@ -253,35 +273,30 @@
253 273 $this->db()->raw('COUNT(id) AS count'),
254 274 'agent_id'
255 275 ])
256 276 ->groupBy('agent_id')
257 - ->where('status', '!=', 'closed')
258 - ->get();
277 + ->where('status', '!=', 'closed');
259 278
279 + $openTickets = $access->applyMailboxRestrictionScope($openTickets)->get();
280 +
260 281 $reports = $this->pushReportData('opens', $openTickets, $reports, 'agent_id');
261 - //Get response by agent
262 - $responses = Conversation::select([
263 - $this->db()->raw('COUNT(id) AS count'),
264 - $this->db()->raw('person_id as agent_id'),
265 - $this->db()->raw('created_at')
266 - ])
267 - ->whereHas('person', function ($q) {
268 - $q->where('person_type', '=', 'agent');
269 - })
270 - ->whereBetween('created_at', [$from, $to])
271 - ->where('conversation_type', 'response')
272 - ->groupBy('agent_id')
273 - ->get();
282 + $responses = $access->applyMailboxRestrictionScopeViaTicket(
283 + Conversation::select([
284 + $this->db()->raw('COUNT(id) AS count'),
285 + $this->db()->raw('person_id as agent_id'),
286 + $this->db()->raw('created_at')
287 + ])
288 + ->whereIn('person_id', Agent::getAgentIds())
289 + ->whereBetween('created_at', [$from, $to])
290 + ->where('conversation_type', 'response')
291 + ->groupBy('agent_id')
292 + )->get();
274 293
275 294 $reports = $this->pushReportData('responses', $responses, $reports, 'agent_id');
276 - //Get interactions/responses by individual agents
277 - foreach ($responses as $response) {
278 - $reports[$response->agent_id]['interactions'] = Conversation::where('person_id', $response->agent_id)
279 - ->where('conversation_type', 'response')
280 - ->whereBetween('created_at', [$from, $to])
281 - ->groupBy('ticket_id')
282 - ->get()
283 - ->count();
295 + foreach ($this->interactionCountsByAgent($from, $to, $access) as $row) {
296 + if (isset($reports[$row->agent_id])) {
297 + $reports[$row->agent_id]['interactions'] = (int) $row->count;
298 + }
284 299 }
285 300
286 301 $agentIds = array_keys($reports);
287 302
@@ -288,72 +303,67 @@
288 303 if ($agent) {
289 304 $agentIds = array_map('intval', explode(',', $agent));
290 305 }
291 306
292 - // get agent feedback statistics
293 307 $agentFeedbackRatingEnabled = Helper::getBusinessSettings('agent_feedback_rating') === 'yes';
294 308 if (defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && $agentFeedbackRatingEnabled) {
295 - $agentConversations = Conversation::select([
296 - $this->db()->raw('person_id as agent_id'),
297 - $this->db()->raw('GROUP_CONCAT(id) as conversation_ids')
298 - ])
299 - ->whereIn('person_id', $agentIds)
300 - ->whereHas('person', function ($q) {
301 - $q->where('person_type', '=', 'agent');
302 - })
303 - ->where('conversation_type', 'response')
304 - ->groupBy('agent_id')
305 - ->get();
309 + global $wpdb;
310 + $prefix = $wpdb->prefix;
306 311
307 - foreach ($agentConversations as $conversation) {
308 - $conversationIds = array_map('intval', explode(',', $conversation->conversation_ids));
312 + // One grouped join, not GROUP_CONCAT of response ids, which
313 + // truncates at group_concat_max_len (1024 bytes) for busy agents.
314 + $feedbackRows = $this->db()->table('fs_conversations')
315 + ->join('fs_meta', 'fs_meta.object_id', '=', 'fs_conversations.id')
316 + ->join('fs_tickets', 'fs_tickets.id', '=', 'fs_conversations.ticket_id')
317 + ->select([
318 + $this->db()->raw($prefix . 'fs_conversations.person_id AS agent_id'),
319 + $this->db()->raw("SUM(CASE WHEN {$prefix}fs_meta.value = 'like' THEN 1 ELSE 0 END) AS likes"),
320 + $this->db()->raw("SUM(CASE WHEN {$prefix}fs_meta.value = 'dislike' THEN 1 ELSE 0 END) AS dislikes"),
321 + ])
322 + ->where('fs_meta.object_type', 'conversation_meta')
323 + ->where('fs_meta.key', 'agent_feedback_ratings')
324 + ->whereBetween('fs_meta.created_at', [$from, $to])
325 + ->where('fs_conversations.conversation_type', 'response')
326 + ->whereIn('fs_conversations.person_id', $agentIds)
327 + ->whereIn('fs_conversations.person_id', Agent::getAgentIds())
328 + ->groupBy('fs_conversations.person_id');
309 329
310 - $feedbackMeta = Meta::whereIn('object_id', $conversationIds)
311 - ->where('key', 'agent_feedback_ratings')
312 - ->whereBetween('created_at', [$from, $to])
313 - ->get();
330 + foreach ($access->applyMailboxRestrictionScope($feedbackRows)->get() as $row) {
331 + $reports[$row->agent_id]['likes'] = (int) $row->likes;
332 + $reports[$row->agent_id]['dislikes'] = (int) $row->dislikes;
333 + }
334 + }
314 335
315 - $likeCount = 0;
316 - $dislikeCount = 0;
336 + // Without an agent filter, list every agent, including those with no
337 + // activity in the range.
338 + $agentsQuery = Agent::select(['id', 'first_name', 'last_name', 'email']);
317 339
318 - foreach ($feedbackMeta as $feedback) {
319 - $feedbackStatus = $feedback->value;
340 + if ($agent) {
341 + $agentsQuery->whereIn('id', $agentIds);
342 + }
320 343
321 - if ($feedbackStatus === 'like') {
322 - $likeCount++;
323 - } elseif ($feedbackStatus === 'dislike') {
324 - $dislikeCount++;
325 - }
326 - }
344 + $agents = $agentsQuery->get();
327 345
328 - $agentId = $conversation->agent_id;
329 - $reports[$agentId]['likes'] = $likeCount;
330 - $reports[$agentId]['dislikes'] = $dislikeCount;
331 - }
332 - }
346 + $reportFields = [
347 + 'interactions' => 0,
348 + 'responses' => 0,
349 + 'opens' => 0,
350 + 'closed' => 0,
351 + 'waiting_tickets' => 0,
352 + ];
333 353
334 - $agents = Agent::select(['id', 'first_name', 'last_name', 'email'])
335 - ->whereIn('id', $agentIds)
336 - ->get();
354 + $additionalFields = defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && $agentFeedbackRatingEnabled ? ['likes' => 0, 'dislikes' => 0] : [];
355 + $reportFields = $reportFields + $additionalFields;
337 356
357 + // One grouped query for all agents instead of one per agent.
358 + $waitStats = $this->getWaitStatsByAgents($agents->pluck('id')->toArray());
359 + $emptyActiveStat = ['average_waiting' => 0, 'max_waiting' => 0, 'waiting_tickets' => 0];
360 +
338 361 foreach ($agents as $agent) {
339 - $report = NULL;
340 - if(isset($reports[$agent->id])) {
341 - $reportFields = [
342 - 'interactions' => 0,
343 - 'responses' => 0,
344 - 'opens' => 0,
345 - 'closed' => 0,
346 - 'waiting_tickets' => 0,
347 - ];
348 -
349 - $additionalFields = defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && $agentFeedbackRatingEnabled ? ['likes' => 0, 'dislikes' => 0] : [];
350 - $reportFields = $reportFields + $additionalFields;
351 -
352 - $report = wp_parse_args($reports[$agent->id], $reportFields);
353 - }
354 - $agent->stats = $report;
355 - $agent->active_stat = $this->getActiveStatByAgent($agent->id);
362 + $agent->stats = wp_parse_args(isset($reports[$agent->id]) ? $reports[$agent->id] : [], $reportFields);
363 + $agent->active_stat = isset($waitStats[$agent->id])
364 + ? $this->formatActiveStat($waitStats[$agent->id])
365 + : $emptyActiveStat;
356 366 }
357 367 return $agents;
358 368 }
359 369
@@ -375,8 +385,10 @@
375 385 $reports = [];
376 386
377 387 $groupByField = $type == 'product' ? 'product_id' : 'mailbox_id';
378 388
389 + $access = new AgentTicketAccess();
390 +
379 391 $resolves = $this->db()->table('fs_tickets')
380 392 ->select([
381 393 $this->db()->raw('COUNT(id) AS count'),
382 394 $groupByField,
@@ -382,11 +394,12 @@
382 394 $groupByField,
383 395 ])
384 396 ->groupBy($groupByField)
385 397 ->where('status', 'closed')
386 - ->whereBetween('resolved_at', [$from, $to])
387 - ->get();
398 + ->whereBetween('resolved_at', [$from, $to]);
388 399
400 + $resolves = $access->applyMailboxRestrictionScope($resolves)->get();
401 +
389 402 $reports = $this->pushReportData('closed', $resolves, $reports, $groupByField);
390 403
391 404 $openTickets = $this->db()->table('fs_tickets')
392 405 ->select([
@@ -394,11 +407,12 @@
394 407 $groupByField
395 408 ])
396 409 ->groupBy($groupByField)
397 410 ->where('status', '!=', 'closed')
398 - ->whereBetween('created_at', [$from, $to])
399 - ->get();
411 + ->whereBetween('created_at', [$from, $to]);
400 412
413 + $openTickets = $access->applyMailboxRestrictionScope($openTickets)->get();
414 +
401 415 $reports = $this->pushReportData('opens', $openTickets, $reports, $groupByField);
402 416
403 417 $responses = $this->db()->table('fs_conversations')
404 418 ->join('fs_tickets', 'fs_tickets.id', '=', 'fs_conversations.ticket_id')
@@ -406,38 +420,30 @@
406 420 $this->db()->raw('COUNT(' . $tablePrefix . 'fs_conversations.id) AS count'),
407 421 'fs_tickets.' . $groupByField,
408 422 ])
409 423 ->groupBy('fs_tickets.' . $groupByField)
410 - ->whereBetween('fs_conversations.created_at', [$from, $to])
411 - ->get();
424 + ->whereBetween('fs_conversations.created_at', [$from, $to]);
412 425
426 + $responses = $access->applyMailboxRestrictionScope($responses)->get();
427 +
413 428 $reports = $this->pushReportData('responses', $responses, $reports, $groupByField);
414 429
415 - $ticketIds = $this->db()->table('fs_tickets')
416 - ->select([
417 - $groupByField,
418 - $this->db()->raw('GROUP_CONCAT(id) AS ticket_ids'),
419 - ])
420 - ->where($groupByField, '!=', 0)
421 - ->groupBy($groupByField)
422 - ->get();
430 + // Interactions = distinct tickets replied to in the range. Use COUNT(DISTINCT),
431 + // not GROUP_CONCAT, which truncates at group_concat_max_len (1024 bytes).
432 + $interactions = $this->db()->table('fs_conversations')
433 + ->join('fs_tickets', 'fs_tickets.id', '=', 'fs_conversations.ticket_id')
434 + ->select([
435 + 'fs_tickets.' . $groupByField,
436 + $this->db()->raw('COUNT(DISTINCT ' . $tablePrefix . 'fs_conversations.ticket_id) AS count'),
437 + ])
438 + ->where('fs_conversations.conversation_type', 'response')
439 + ->whereBetween('fs_conversations.created_at', [$from, $to])
440 + ->groupBy('fs_tickets.' . $groupByField);
423 441
424 - $result = [];
425 - foreach ($ticketIds as $item) {
426 - $result[$item->{$groupByField}] = explode(',', $item->ticket_ids);
427 - }
442 + $interactions = $access->applyMailboxRestrictionScope($interactions)->get();
428 443
429 - foreach ($result as $id => $ticketIds) {
430 - $interactions = Conversation::whereIn('ticket_id', $ticketIds)
431 - ->where('conversation_type', 'response')
432 - ->whereBetween('created_at', [$from, $to])
433 - ->groupBy('ticket_id')
434 - ->get()
435 - ->count();
444 + $reports = $this->pushReportData('interactions', $interactions, $reports, $groupByField);
436 445
437 - $reports[$id]['interactions'] = $interactions;
438 - }
439 -
440 446 $ids = array_keys($reports);
441 447
442 448 $types = [
443 449 'product' => [
@@ -473,11 +479,10 @@
473 479 return $items;
474 480 }
475 481
476 482 /**
477 - * agentGroupSummary method will prepare ticket summary aggregated by agent group
478 - * @param false $from
479 - * @param false $to
483 + * @param string|false $from
484 + * @param string|false $to
480 485 * @return mixed
481 486 */
482 487 public function agentGroupSummary($from = false, $to = false)
483 488 {
@@ -491,11 +496,13 @@
491 496
492 497 $from .= ' 00:00:00';
493 498 $to .= ' 23:59:59';
494 499
495 - // Get per-agent stats using the same queries as agentSummary
500 + // Same per-agent queries as agentSummary()
496 501 $reports = [];
497 502
503 + $access = new AgentTicketAccess();
504 +
498 505 $resolves = $this->db()->table('fs_tickets')
499 506 ->select([
500 507 $this->db()->raw('COUNT(id) AS count'),
501 508 'agent_id',
@@ -501,11 +508,12 @@
501 508 'agent_id',
502 509 ])
503 510 ->groupBy('agent_id')
504 511 ->where('status', 'closed')
505 - ->whereBetween('resolved_at', [$from, $to])
506 - ->get();
512 + ->whereBetween('resolved_at', [$from, $to]);
507 513
514 + $resolves = $access->applyMailboxRestrictionScope($resolves)->get();
515 +
508 516 $reports = $this->pushReportData('closed', $resolves, $reports, 'agent_id');
509 517
510 518 $openTickets = $this->db()->table('fs_tickets')
511 519 ->select([
@@ -512,43 +520,38 @@
512 520 $this->db()->raw('COUNT(id) AS count'),
513 521 'agent_id'
514 522 ])
515 523 ->groupBy('agent_id')
516 - ->where('status', '!=', 'closed')
517 - ->get();
524 + ->where('status', '!=', 'closed');
518 525
526 + $openTickets = $access->applyMailboxRestrictionScope($openTickets)->get();
527 +
519 528 $reports = $this->pushReportData('opens', $openTickets, $reports, 'agent_id');
520 529
521 - $responses = Conversation::select([
522 - $this->db()->raw('COUNT(id) AS count'),
523 - $this->db()->raw('person_id as agent_id'),
524 - ])
525 - ->whereHas('person', function ($q) {
526 - $q->where('person_type', '=', 'agent');
527 - })
528 - ->whereBetween('created_at', [$from, $to])
529 - ->where('conversation_type', 'response')
530 - ->groupBy('agent_id')
531 - ->get();
530 + $responses = $access->applyMailboxRestrictionScopeViaTicket(
531 + Conversation::select([
532 + $this->db()->raw('COUNT(id) AS count'),
533 + $this->db()->raw('person_id as agent_id'),
534 + ])
535 + ->whereIn('person_id', Agent::getAgentIds())
536 + ->whereBetween('created_at', [$from, $to])
537 + ->where('conversation_type', 'response')
538 + ->groupBy('agent_id')
539 + )->get();
532 540
533 541 $reports = $this->pushReportData('responses', $responses, $reports, 'agent_id');
534 542
535 - foreach ($responses as $response) {
536 - $reports[$response->agent_id]['interactions'] = Conversation::where('person_id', $response->agent_id)
537 - ->where('conversation_type', 'response')
538 - ->whereBetween('created_at', [$from, $to])
539 - ->groupBy('ticket_id')
540 - ->get()
541 - ->count();
543 + foreach ($this->interactionCountsByAgent($from, $to, $access) as $row) {
544 + if (isset($reports[$row->agent_id])) {
545 + $reports[$row->agent_id]['interactions'] = (int) $row->count;
546 + }
542 547 }
543 548
544 - // Get group -> agent_id mappings
545 549 $groupAgentMap = TagPivot::where('source_type', 'agent_group')
546 550 ->select(['tag_id', 'source_id'])
547 551 ->get()
548 552 ->groupBy('tag_id');
549 553
550 - // Get all agent groups
551 554 $groups = AgentGroup::select(['id', 'title'])->get();
552 555
553 556 $defaultStats = [
554 557 'responses' => 0,
@@ -582,9 +585,10 @@
582 585 return $groups;
583 586 }
584 587
585 588 /**
586 - * pushReportData method will format the ticket summary report
589 + * Merge grouped counts into $reports[groupKey][$type].
590 + *
587 591 * @param $type
588 592 * @param $tickets
589 593 * @param $reports
590 594 * @param $groupByField
@@ -609,18 +613,26 @@
609 613 return $reports;
610 614 }
611 615
612 616 /**
613 - * getActiveStats method will return the statistics for active tickets
614 - * This method will get the list of open tickets calculate the wait times and return results
617 + * Wait-time stats for open tickets awaiting an agent reply.
618 + *
619 + * @param int|null $productId Narrow to one product's tickets
615 620 * @return array|false
616 621 */
617 - public function getActiveStats()
622 + public function getActiveStats($productId = null)
618 623 {
619 - // We will calculate the wait times for open waiting tickets
620 - $waitStat = Ticket::waitingOnly()
624 + $query = (new AgentTicketAccess())->applyMailboxRestrictionScope(
625 + Ticket::waitingOnly()
626 + )
621 627 ->where('status', '!=', 'closed')
622 - ->whereNotNull('waiting_since')
628 + ->whereNotNull('waiting_since');
629 +
630 + if ($productId) {
631 + $query->where('product_id', $productId);
632 + }
633 +
634 + $waitStat = $query
623 635 ->select([
624 636 $this->db()->raw('avg(UNIX_TIMESTAMP(waiting_since)) as avg_waiting'),
625 637 $this->db()->raw('MIN(UNIX_TIMESTAMP(waiting_since)) as max_waiting'),
626 638 $this->db()->raw('COUNT(*) as total_tickets')
@@ -650,16 +662,16 @@
650 662 ];
651 663 }
652 664
653 665 /**
654 - * getActiveStatByAgent method will return the statistics of active tickets for an agent
655 - * This method will get agent id as parameter, fetch the list of open tickets by agent id, calculate the wait times and return results
666 + * Same as getActiveStats() for one agent's tickets.
667 + *
656 668 * @param $agentId
657 669 * @return array|false
658 670 */
659 671 public function getActiveStatByAgent($agentId)
660 672 {
661 - $waitStat = Ticket::waitingOnly()
673 + $waitStat = (new AgentTicketAccess())->applyMailboxRestrictionScope(Ticket::waitingOnly())
662 674 ->where('status', '!=', 'closed')
663 675 ->whereNotNull('waiting_since')
664 676 ->where('agent_id', $agentId)
665 677 ->select([
@@ -672,8 +684,45 @@
672 684 if(!$waitStat) {
673 685 return false;
674 686 }
675 687
688 + return $this->formatActiveStat($waitStat);
689 + }
690 +
691 + /**
692 + * Batched getActiveStatByAgent() aggregate, keyed by agent id. Agents with
693 + * no waiting tickets have no row.
694 + * @param array $agentIds
695 + * @return \FluentSupport\Framework\Database\Orm\Collection|array
696 + */
697 + public function getWaitStatsByAgents($agentIds)
698 + {
699 + if (empty($agentIds)) {
700 + return [];
701 + }
702 +
703 + return (new AgentTicketAccess())->applyMailboxRestrictionScope(Ticket::waitingOnly())
704 + ->where('status', '!=', 'closed')
705 + ->whereNotNull('waiting_since')
706 + ->whereIn('agent_id', $agentIds)
707 + ->select([
708 + 'agent_id',
709 + $this->db()->raw('avg(UNIX_TIMESTAMP(waiting_since)) as avg_waiting'),
710 + $this->db()->raw('MIN(UNIX_TIMESTAMP(waiting_since)) as max_waiting'),
711 + $this->db()->raw('COUNT(*) as total_tickets')
712 + ])
713 + ->groupBy('agent_id')
714 + ->get()
715 + ->keyBy('agent_id');
716 + }
717 +
718 + /**
719 + * Format a wait-time aggregate row for display.
720 + * @param $waitStat
721 + * @return array
722 + */
723 + private function formatActiveStat($waitStat)
724 + {
676 725 $waitStat->avg_waiting = intval($waitStat->avg_waiting);
677 726 if($waitStat->avg_waiting > 0) {
678 727 $waitSeconds = time() - $waitStat->avg_waiting;
679 728 if( $waitSeconds < 172800 && $waitSeconds > 7200) {
@@ -705,9 +754,9 @@
705 754 return [];
706 755 }
707 756 }
708 757
709 - public function getTicketStats($from, $to)
758 + public function getTicketStats($from, $to, $productId = null)
710 759 {
711 760 global $wpdb;
712 761
713 762 $whereClause = '';
@@ -720,11 +769,23 @@
720 769 $queryParams[] = $start_date;
721 770 $queryParams[] = $end_date;
722 771 }
723 772
724 - // SQL query to count tickets by day of week and hour within the specified date range.
725 - // $wpdb->prefix is WordPress-internal; $whereClause is built from hardcoded literals
726 - // only — user-supplied date values are bound via %s placeholders in prepare() below.
773 + if ($productId) {
774 + $whereClause .= ($whereClause ? ' AND ' : 'WHERE ') . 'product_id = %d';
775 + $queryParams[] = intval($productId);
776 + }
777 +
778 + // Raw SQL, so the query-builder helper cannot be used.
779 + $restrictedMailboxIds = PermissionManager::getRestrictedMailboxIds();
780 +
781 + if ($restrictedMailboxIds) {
782 + $restrictedList = implode(',', array_map('intval', $restrictedMailboxIds));
783 + $whereClause .= ($whereClause ? ' AND ' : 'WHERE ')
784 + . "(mailbox_id NOT IN ({$restrictedList}) OR mailbox_id IS NULL)";
785 + }
786 +
787 + // $whereClause holds only literals and int ids. Dates are bound in prepare() below.
727 788 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
728 789 $query = "SELECT DAYNAME(created_at) AS weekday, HOUR(created_at) AS hour, COUNT(*) AS count
729 790 FROM {$wpdb->prefix}fs_tickets
730 791 {$whereClause}
@@ -730,9 +791,8 @@
730 791 {$whereClause}
731 792 GROUP BY DAYNAME(created_at), HOUR(created_at)
732 793 ORDER BY FIELD(DAYNAME(created_at), 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'), HOUR(created_at)";
733 794
734 - // Execute the query — prepared when date params are present, plain otherwise.
735 795 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $query structure is hardcoded; values are bound via $wpdb->prepare() when $queryParams is non-empty.
736 796 $results = $queryParams
737 797 ? $wpdb->get_results($wpdb->prepare($query, $queryParams)) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
738 798 : $wpdb->get_results($query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
@@ -737,15 +797,12 @@
737 797 ? $wpdb->get_results($wpdb->prepare($query, $queryParams)) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
738 798 : $wpdb->get_results($query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
739 799
740 800 $fillData = array_fill(0, 24, 0);
741 - // add :00 on the suffix
742 801 $fillData = array_combine(array_map(function ($hour) {
743 802 return sprintf('%1d:00', $hour);
744 803 }, array_keys($fillData)), $fillData);
745 804
746 - // Prepare the report
747 - // Prepare the report
748 805 $report = [
749 806 'Mon' => $fillData,
750 807 'Tue' => $fillData,
751 808 'Wed' => $fillData,
@@ -765,9 +822,9 @@
765 822
766 823 return $report;
767 824 }
768 825
769 - public function getResponseStats($from, $to, $reportType, $agentId = null)
826 + public function getResponseStats($from, $to, $reportType, $agentId = null, $productId = null)
770 827 {
771 828 global $wpdb;
772 829
773 830 $whereClause = ' AND p.person_type = %s';
@@ -785,9 +842,28 @@
785 842 $whereClause .= ' AND c.person_id = %d';
786 843 $queryParams[] = intval($agentId);
787 844 }
788 845
789 - // SQL query to count customer responses by day of week and hour within the specified date range
846 + // Mailbox and product scoping go through the parent ticket.
847 + $ticketConditions = [];
848 + $restrictedMailboxIds = PermissionManager::getRestrictedMailboxIds();
849 +
850 + if ($restrictedMailboxIds) {
851 + $restrictedList = implode(',', array_map('intval', $restrictedMailboxIds));
852 + $ticketConditions[] = "(t.mailbox_id NOT IN ({$restrictedList}) OR t.mailbox_id IS NULL)";
853 + }
854 +
855 + if ($productId) {
856 + $ticketConditions[] = 't.product_id = %d';
857 + $queryParams[] = intval($productId);
858 + }
859 +
860 + if ($ticketConditions) {
861 + $whereClause .= " AND EXISTS (SELECT 1 FROM {$wpdb->prefix}fs_tickets t"
862 + . " WHERE t.id = c.ticket_id AND " . implode(' AND ', $ticketConditions) . ')';
863 + }
864 +
865 + // Responses by weekday and hour, for agents or customers per $reportType
790 866 $query = $wpdb->prepare(
791 867 "SELECT DAYNAME(c.created_at) AS weekday, HOUR(c.created_at) AS hour, COUNT(*) AS count
792 868 FROM {$wpdb->prefix}fs_conversations AS c
793 869 JOIN {$wpdb->prefix}fs_persons AS p ON c.person_id = p.id
@@ -797,19 +873,16 @@
797 873 ORDER BY FIELD(DAYNAME(c.created_at), 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'), HOUR(c.created_at)",
798 874 $queryParams
799 875 );
800 876
801 - // Execute the query
802 877 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.NotPrepared -- $query is the output of $wpdb->prepare() above; checker incorrectly traces back through the prepared parameters.
803 878 $results = $wpdb->get_results($query);
804 879
805 880 $fillData = array_fill(0, 24, 0);
806 - // add :00 on the suffix
807 881 $fillData = array_combine(array_map(function ($hour) {
808 882 return sprintf('%1d:00', $hour);
809 883 }, array_keys($fillData)), $fillData);
810 884
811 - // Prepare the report
812 885 $report = [
813 886 'Mon' => $fillData,
814 887 'Tue' => $fillData,
815 888 'Wed' => $fillData,
@@ -831,9 +904,11 @@
831 904 }
832 905
833 906 public function getTicketResponseStats($from, $to, $filter)
834 907 {
835 - $query = Conversation::query()
908 + $query = (new AgentTicketAccess())->applyMailboxRestrictionScopeViaTicket(
909 + Conversation::query()
910 + )
836 911 ->select('id', 'ticket_id', 'person_id', 'created_at', 'content')
837 912 ->addSelect([
838 913 'person_type' => Person::select('person_type')
839 914 ->whereColumn('id', 'fs_conversations.person_id'),