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 +404 -191 1.10.1 → 2.4.5 View file →
@@ -2,24 +2,24 @@
2 2
3 3 namespace FluentSupport\App\Modules\Reporting;
4 4
5 5 use FluentSupport\App\Models\Agent;
6 +use FluentSupport\App\Models\AgentGroup;
6 7 use FluentSupport\App\Models\Conversation;
7 8 use FluentSupport\App\Models\MailBox;
8 -use FluentSupport\App\Models\Meta;
9 9 use FluentSupport\App\Models\Person;
10 10 use FluentSupport\App\Models\Product;
11 +use FluentSupport\App\Models\TagPivot;
11 12 use FluentSupport\App\Models\Ticket;
13 +use FluentSupport\App\Modules\PermissionManager;
12 14 use FluentSupport\App\Services\Helper;
15 +use FluentSupport\App\Services\Tickets\AgentTicketAccess;
13 16 use FluentSupport\Framework\Database\Orm\Builder;
14 17 use FluentSupport\Framework\Support\Arr;
15 18 use FluentSupport\Framework\Support\DateTime;
16 19
17 20 /**
18 - * Reporting class is responsible for getting data related to report
19 - * @package FluentSupport\App\Modules\Reporting
20 - *
21 - * @version 1.0.0
21 + * Ticket, response and agent reports, scoped to the current agent's mailboxes.
22 22 */
23 23 class Reporting
24 24 {
25 25 use ReportingHelperTrait;
@@ -24,27 +24,25 @@
24 24 {
25 25 use ReportingHelperTrait;
26 26
27 27 /**
28 - * getTicketsGrowth will generate tickets statistics and return
29 - * @param false $from
30 - * @param false $to
28 + * New tickets per period.
29 + *
30 + * @param string|false $from
31 + * @param string|false $to
31 32 * @param array $filters
32 33 * @return array
33 34 */
34 35 public function getTicketsGrowth($from = false, $to = false, $filters = [])
35 36 {
36 - //Generate report period
37 37 $period = $this->makeDatePeriod(
38 - $from = $this->makeFromDate($from),//Date from
39 - $to = $this->makeToDate($to),//Date to
40 - $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)
41 41 );
42 42
43 - //Get group by and order by i.e date,week, month
44 43 list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);
45 44
46 - //get all tickets statistics within the date range
47 45 $query = $this->db()->table('fs_tickets')
48 46 ->select($this->prepareSelect($frequency))
49 47 ->whereBetween('created_at', $this->prepareBetween($frequency, $from, $to))
50 48 ->groupBy($groupBy)
@@ -49,9 +47,11 @@
49 47 ->whereBetween('created_at', $this->prepareBetween($frequency, $from, $to))
50 48 ->groupBy($groupBy)
51 49 ->oldest($orderBy);
52 50
53 - //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 +
54 54 if ($filters) {
55 55 if (!empty($filters['statuses'])) {
56 56 $query->whereIn('status', $filters['statuses']);
57 57 }
@@ -66,8 +66,12 @@
66 66
67 67 if (!empty($filters['mailbox_id'])) {
68 68 $query->where('mailbox_id', $filters['mailbox_id']);
69 69 }
70 +
71 + if (!empty($filters['agent_ids'])) {
72 + $query->whereIn('agent_id', $filters['agent_ids']);
73 + }
70 74 }
71 75
72 76 $items = $query->get();
73 77
@@ -74,11 +78,12 @@
74 78 return $this->getResult($period, $items);
75 79 }
76 80
77 81 /**
78 - * getTicketResolveGrowth method will get the statistics for resolved/closed tickets
79 - * @param false $from
80 - * @param false $to
82 + * Closed tickets per period, by resolved_at.
83 + *
84 + * @param string|false $from
85 + * @param string|false $to
81 86 * @param array $filters
82 87 * @return array
83 88 */
84 89 public function getTicketResolveGrowth($from = false, $to = false, $filters = [], $type = '')
@@ -83,18 +88,17 @@
83 88 */
84 89 public function getTicketResolveGrowth($from = false, $to = false, $filters = [], $type = '')
85 90 {
86 91 $period = $this->makeDatePeriod(
87 - $from = $this->makeFromDate($from),//Date from
88 - $to = $this->makeToDate($to),//date to
89 - $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)
90 95 );
91 96
92 - list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);//Get group by and order by i.e date,week, month
97 + list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);
93 98
94 99 $filterColumn = (!empty($type)) ? $type.'_id' : 'id';
95 100
96 - //get the closed ticket statistics within the date range
97 101 $query = $this->db()->table('fs_tickets')
98 102 ->select($this->prepareSelect($frequency, 'resolved_at'))
99 103 ->whereBetween('resolved_at', $this->prepareBetween($frequency, $from, $to))
100 104 ->where('status', 'closed')
@@ -101,9 +105,10 @@
101 105 ->where($filterColumn, '>', 0)
102 106 ->groupBy($groupBy)
103 107 ->oldest($orderBy);
104 108
105 - //If filter by product or agent is selected
109 + (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
110 +
106 111 if ($filters) {
107 112 if (!empty($filters['product_id'])) {
108 113 $query->where('product_id', $filters['product_id']);
109 114 }
@@ -114,8 +119,12 @@
114 119
115 120 if (!empty($filters['mailbox_id'])) {
116 121 $query->where('mailbox_id', $filters['mailbox_id']);
117 122 }
123 +
124 + if (!empty($filters['agent_ids'])) {
125 + $query->whereIn('agent_id', $filters['agent_ids']);
126 + }
118 127 }
119 128
120 129 $items = $query->get();
121 130
@@ -122,11 +131,12 @@
122 131 return $this->getResult($period, $items);
123 132 }
124 133
125 134 /**
126 - * getResponseGrowth method will generate the statistics for response
127 - * @param false $from
128 - * @param false $to
135 + * Responses per period.
136 + *
137 + * @param string|false $from
138 + * @param string|false $to
129 139 * @param array $filters
130 140 * @return array
131 141 */
132 142 public function getResponseGrowth($from = false, $to = false, $filters = [])
@@ -138,17 +148,17 @@
138 148 );
139 149
140 150 list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);
141 151
142 - $query = Conversation::query()
143 - ->select($this->prepareSelect($frequency))
144 - ->whereBetween('created_at', $this->prepareBetween($frequency, $from, $to))
145 - ->where('conversation_type', 'response')
146 - ->whereHas('person', function ($q) {
147 - $q->where('person_type', 'agent');
148 - })
149 - ->groupBy($groupBy)
150 - ->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 + );
151 161
152 162 if ($filters) {
153 163 if (!empty($filters['person_id'])) {
154 164 $query->where('person_id', $filters['person_id']);
@@ -153,8 +163,12 @@
153 163 if (!empty($filters['person_id'])) {
154 164 $query->where('person_id', $filters['person_id']);
155 165 }
156 166
167 + if (!empty($filters['person_ids'])) {
168 + $query->whereIn('person_id', $filters['person_ids']);
169 + }
170 +
157 171 if (!empty($filters['product_id'])) {
158 172 $query->where('product_id', $filters['product_id']);
159 173 }
160 174 }
@@ -173,9 +187,9 @@
173 187 );
174 188
175 189 list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);
176 190
177 - $filterColumn = $type."_id";
191 + $filterColumn = (!empty($type)) ? $type.'_id' : 'id';
178 192
179 193 $query = $this->db()->table('fs_tickets')
180 194 ->select($this->prepareSelect($frequency,'created_at','response_count'))
181 195 ->whereBetween('created_at', $this->prepareBetween($frequency, $from, $to))
@@ -183,8 +197,10 @@
183 197 ->where($filterColumn, '>', 0)
184 198 ->groupBy($groupBy)
185 199 ->oldest($orderBy);
186 200
201 + (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
202 +
187 203 if ($filters) {
188 204 if (!empty($filters['product_id'])) {
189 205 $query->where('product_id', $filters['product_id']);
190 206 }
@@ -198,13 +214,29 @@
198 214
199 215 return $this->getResult($period, $items);
200 216 }
201 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 +
202 233 /**
203 - * agentSummary method will prepare ticket summary with responses by agent
204 - * @param false $from
205 - * @param false $to
206 - * @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.
207 239 * @return mixed
208 240 */
209 241 public function agentSummary($from = false, $to = false, $agent = false)
210 242 {
@@ -219,9 +251,10 @@
219 251 $from .= ' 00:00:00';
220 252 $to .= ' 23:59:59';
221 253 $reports = [];
222 254
223 - //Get tickets statistics that are closed
255 + $access = new AgentTicketAccess();
256 +
224 257 $resolves = $this->db()->table('fs_tickets')
225 258 ->select([
226 259 $this->db()->raw('COUNT(id) AS count'),
227 260 'agent_id',
@@ -227,14 +260,15 @@
227 260 'agent_id',
228 261 ])
229 262 ->groupBy('agent_id')
230 263 ->where('status', 'closed')
231 - ->whereBetween('resolved_at', [$from, $to])
232 - ->get();
264 + ->whereBetween('resolved_at', [$from, $to]);
233 265
266 + $resolves = $access->applyMailboxRestrictionScope($resolves)->get();
267 +
234 268 $reports = $this->pushReportData('closed', $resolves, $reports, 'agent_id');
235 269
236 - //get statistics for all except closed ticket
270 + // Open tickets aren't limited to the date range
237 271 $openTickets = $this->db()->table('fs_tickets')
238 272 ->select([
239 273 $this->db()->raw('COUNT(id) AS count'),
240 274 'agent_id'
@@ -239,35 +273,30 @@
239 273 $this->db()->raw('COUNT(id) AS count'),
240 274 'agent_id'
241 275 ])
242 276 ->groupBy('agent_id')
243 - ->where('status', '!=', 'closed')
244 - ->get();
277 + ->where('status', '!=', 'closed');
245 278
279 + $openTickets = $access->applyMailboxRestrictionScope($openTickets)->get();
280 +
246 281 $reports = $this->pushReportData('opens', $openTickets, $reports, 'agent_id');
247 - //Get response by agent
248 - $responses = Conversation::select([
249 - $this->db()->raw('COUNT(id) AS count'),
250 - $this->db()->raw('person_id as agent_id'),
251 - $this->db()->raw('created_at')
252 - ])
253 - ->whereHas('person', function ($q) {
254 - $q->where('person_type', '=', 'agent');
255 - })
256 - ->whereBetween('created_at', [$from, $to])
257 - ->where('conversation_type', 'response')
258 - ->groupBy('agent_id')
259 - ->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();
260 293
261 294 $reports = $this->pushReportData('responses', $responses, $reports, 'agent_id');
262 - //Get interactions/responses by individual agents
263 - foreach ($responses as $response) {
264 - $reports[$response->agent_id]['interactions'] = Conversation::where('person_id', $response->agent_id)
265 - ->where('conversation_type', 'response')
266 - ->whereBetween('created_at', [$from, $to])
267 - ->groupBy('ticket_id')
268 - ->get()
269 - ->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 + }
270 299 }
271 300
272 301 $agentIds = array_keys($reports);
273 302
@@ -274,72 +303,67 @@
274 303 if ($agent) {
275 304 $agentIds = array_map('intval', explode(',', $agent));
276 305 }
277 306
278 - // get agent feedback statistics
279 307 $agentFeedbackRatingEnabled = Helper::getBusinessSettings('agent_feedback_rating') === 'yes';
280 308 if (defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && $agentFeedbackRatingEnabled) {
281 - $agentConversations = Conversation::select([
282 - $this->db()->raw('person_id as agent_id'),
283 - $this->db()->raw('GROUP_CONCAT(id) as conversation_ids')
284 - ])
285 - ->whereIn('person_id', $agentIds)
286 - ->whereHas('person', function ($q) {
287 - $q->where('person_type', '=', 'agent');
288 - })
289 - ->where('conversation_type', 'response')
290 - ->groupBy('agent_id')
291 - ->get();
309 + global $wpdb;
310 + $prefix = $wpdb->prefix;
292 311
293 - foreach ($agentConversations as $conversation) {
294 - $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');
295 329
296 - $feedbackMeta = Meta::whereIn('object_id', $conversationIds)
297 - ->where('key', 'agent_feedback_ratings')
298 - ->whereBetween('created_at', [$from, $to])
299 - ->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 + }
300 335
301 - $likeCount = 0;
302 - $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']);
303 339
304 - foreach ($feedbackMeta as $feedback) {
305 - $feedbackStatus = $feedback->value;
340 + if ($agent) {
341 + $agentsQuery->whereIn('id', $agentIds);
342 + }
306 343
307 - if ($feedbackStatus === 'like') {
308 - $likeCount++;
309 - } elseif ($feedbackStatus === 'dislike') {
310 - $dislikeCount++;
311 - }
312 - }
344 + $agents = $agentsQuery->get();
313 345
314 - $agentId = $conversation->agent_id;
315 - $reports[$agentId]['likes'] = $likeCount;
316 - $reports[$agentId]['dislikes'] = $dislikeCount;
317 - }
318 - }
346 + $reportFields = [
347 + 'interactions' => 0,
348 + 'responses' => 0,
349 + 'opens' => 0,
350 + 'closed' => 0,
351 + 'waiting_tickets' => 0,
352 + ];
319 353
320 - $agents = Agent::select(['id', 'first_name', 'last_name'])
321 - ->whereIn('id', $agentIds)
322 - ->get();
354 + $additionalFields = defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && $agentFeedbackRatingEnabled ? ['likes' => 0, 'dislikes' => 0] : [];
355 + $reportFields = $reportFields + $additionalFields;
323 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 +
324 361 foreach ($agents as $agent) {
325 - $report = NULL;
326 - if(isset($reports[$agent->id])) {
327 - $reportFields = [
328 - 'interactions' => 0,
329 - 'responses' => 0,
330 - 'opens' => 0,
331 - 'closed' => 0,
332 - 'waiting_tickets' => 0,
333 - ];
334 -
335 - $additionalFields = defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && $agentFeedbackRatingEnabled ? ['likes' => 0, 'dislikes' => 0] : [];
336 - $reportFields = $reportFields + $additionalFields;
337 -
338 - $report = wp_parse_args($reports[$agent->id], $reportFields);
339 - }
340 - $agent->stats = $report;
341 - $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;
342 366 }
343 367 return $agents;
344 368 }
345 369
@@ -361,8 +385,10 @@
361 385 $reports = [];
362 386
363 387 $groupByField = $type == 'product' ? 'product_id' : 'mailbox_id';
364 388
389 + $access = new AgentTicketAccess();
390 +
365 391 $resolves = $this->db()->table('fs_tickets')
366 392 ->select([
367 393 $this->db()->raw('COUNT(id) AS count'),
368 394 $groupByField,
@@ -368,11 +394,12 @@
368 394 $groupByField,
369 395 ])
370 396 ->groupBy($groupByField)
371 397 ->where('status', 'closed')
372 - ->whereBetween('resolved_at', [$from, $to])
373 - ->get();
398 + ->whereBetween('resolved_at', [$from, $to]);
374 399
400 + $resolves = $access->applyMailboxRestrictionScope($resolves)->get();
401 +
375 402 $reports = $this->pushReportData('closed', $resolves, $reports, $groupByField);
376 403
377 404 $openTickets = $this->db()->table('fs_tickets')
378 405 ->select([
@@ -380,11 +407,12 @@
380 407 $groupByField
381 408 ])
382 409 ->groupBy($groupByField)
383 410 ->where('status', '!=', 'closed')
384 - ->whereBetween('created_at', [$from, $to])
385 - ->get();
411 + ->whereBetween('created_at', [$from, $to]);
386 412
413 + $openTickets = $access->applyMailboxRestrictionScope($openTickets)->get();
414 +
387 415 $reports = $this->pushReportData('opens', $openTickets, $reports, $groupByField);
388 416
389 417 $responses = $this->db()->table('fs_conversations')
390 418 ->join('fs_tickets', 'fs_tickets.id', '=', 'fs_conversations.ticket_id')
@@ -392,38 +420,30 @@
392 420 $this->db()->raw('COUNT(' . $tablePrefix . 'fs_conversations.id) AS count'),
393 421 'fs_tickets.' . $groupByField,
394 422 ])
395 423 ->groupBy('fs_tickets.' . $groupByField)
396 - ->whereBetween('fs_conversations.created_at', [$from, $to])
397 - ->get();
424 + ->whereBetween('fs_conversations.created_at', [$from, $to]);
398 425
426 + $responses = $access->applyMailboxRestrictionScope($responses)->get();
427 +
399 428 $reports = $this->pushReportData('responses', $responses, $reports, $groupByField);
400 429
401 - $ticketIds = $this->db()->table('fs_tickets')
402 - ->select([
403 - $groupByField,
404 - $this->db()->raw('GROUP_CONCAT(id) AS ticket_ids'),
405 - ])
406 - ->where($groupByField, '!=', 0)
407 - ->groupBy($groupByField)
408 - ->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);
409 441
410 - $result = [];
411 - foreach ($ticketIds as $item) {
412 - $result[$item->{$groupByField}] = explode(',', $item->ticket_ids);
413 - }
442 + $interactions = $access->applyMailboxRestrictionScope($interactions)->get();
414 443
415 - foreach ($result as $id => $ticketIds) {
416 - $interactions = Conversation::whereIn('ticket_id', $ticketIds)
417 - ->where('conversation_type', 'response')
418 - ->whereBetween('created_at', [$from, $to])
419 - ->groupBy('ticket_id')
420 - ->get()
421 - ->count();
444 + $reports = $this->pushReportData('interactions', $interactions, $reports, $groupByField);
422 445
423 - $reports[$id]['interactions'] = $interactions;
424 - }
425 -
426 446 $ids = array_keys($reports);
427 447
428 448 $types = [
429 449 'product' => [
@@ -459,9 +479,116 @@
459 479 return $items;
460 480 }
461 481
462 482 /**
463 - * pushReportData method will format the ticket summary report
483 + * @param string|false $from
484 + * @param string|false $to
485 + * @return mixed
486 + */
487 + public function agentGroupSummary($from = false, $to = false)
488 + {
489 + if (!$from) {
490 + $from = current_time('Y-m-d');
491 + }
492 +
493 + if (!$to) {
494 + $to = current_time('Y-m-d');
495 + }
496 +
497 + $from .= ' 00:00:00';
498 + $to .= ' 23:59:59';
499 +
500 + // Same per-agent queries as agentSummary()
501 + $reports = [];
502 +
503 + $access = new AgentTicketAccess();
504 +
505 + $resolves = $this->db()->table('fs_tickets')
506 + ->select([
507 + $this->db()->raw('COUNT(id) AS count'),
508 + 'agent_id',
509 + ])
510 + ->groupBy('agent_id')
511 + ->where('status', 'closed')
512 + ->whereBetween('resolved_at', [$from, $to]);
513 +
514 + $resolves = $access->applyMailboxRestrictionScope($resolves)->get();
515 +
516 + $reports = $this->pushReportData('closed', $resolves, $reports, 'agent_id');
517 +
518 + $openTickets = $this->db()->table('fs_tickets')
519 + ->select([
520 + $this->db()->raw('COUNT(id) AS count'),
521 + 'agent_id'
522 + ])
523 + ->groupBy('agent_id')
524 + ->where('status', '!=', 'closed');
525 +
526 + $openTickets = $access->applyMailboxRestrictionScope($openTickets)->get();
527 +
528 + $reports = $this->pushReportData('opens', $openTickets, $reports, 'agent_id');
529 +
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();
540 +
541 + $reports = $this->pushReportData('responses', $responses, $reports, 'agent_id');
542 +
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 + }
547 + }
548 +
549 + $groupAgentMap = TagPivot::where('source_type', 'agent_group')
550 + ->select(['tag_id', 'source_id'])
551 + ->get()
552 + ->groupBy('tag_id');
553 +
554 + $groups = AgentGroup::select(['id', 'title'])->get();
555 +
556 + $defaultStats = [
557 + 'responses' => 0,
558 + 'interactions' => 0,
559 + 'opens' => 0,
560 + 'closed' => 0,
561 + ];
562 +
563 + foreach ($groups as $group) {
564 + $agentIds = isset($groupAgentMap[$group->id])
565 + ? array_map('intval', $groupAgentMap[$group->id]->pluck('source_id')->toArray())
566 + : [];
567 +
568 + $group->agents_count = count($agentIds);
569 +
570 + $groupStats = $defaultStats;
571 +
572 + foreach ($agentIds as $agentId) {
573 + if (isset($reports[$agentId])) {
574 + $agentStats = wp_parse_args($reports[$agentId], $defaultStats);
575 + $groupStats['responses'] += (int) $agentStats['responses'];
576 + $groupStats['interactions'] += (int) $agentStats['interactions'];
577 + $groupStats['opens'] += (int) $agentStats['opens'];
578 + $groupStats['closed'] += (int) $agentStats['closed'];
579 + }
580 + }
581 +
582 + $group->stats = $groupStats;
583 + }
584 +
585 + return $groups;
586 + }
587 +
588 + /**
589 + * Merge grouped counts into $reports[groupKey][$type].
590 + *
464 591 * @param $type
465 592 * @param $tickets
466 593 * @param $reports
467 594 * @param $groupByField
@@ -486,18 +613,26 @@
486 613 return $reports;
487 614 }
488 615
489 616 /**
490 - * getActiveStats method will return the statistics for active tickets
491 - * 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
492 620 * @return array|false
493 621 */
494 - public function getActiveStats()
622 + public function getActiveStats($productId = null)
495 623 {
496 - // We will calculate the wait times for open waiting tickets
497 - $waitStat = Ticket::waitingOnly()
624 + $query = (new AgentTicketAccess())->applyMailboxRestrictionScope(
625 + Ticket::waitingOnly()
626 + )
498 627 ->where('status', '!=', 'closed')
499 - ->whereNotNull('waiting_since')
628 + ->whereNotNull('waiting_since');
629 +
630 + if ($productId) {
631 + $query->where('product_id', $productId);
632 + }
633 +
634 + $waitStat = $query
500 635 ->select([
501 636 $this->db()->raw('avg(UNIX_TIMESTAMP(waiting_since)) as avg_waiting'),
502 637 $this->db()->raw('MIN(UNIX_TIMESTAMP(waiting_since)) as max_waiting'),
503 638 $this->db()->raw('COUNT(*) as total_tickets')
@@ -527,16 +662,16 @@
527 662 ];
528 663 }
529 664
530 665 /**
531 - * getActiveStatByAgent method will return the statistics of active tickets for an agent
532 - * 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 + *
533 668 * @param $agentId
534 669 * @return array|false
535 670 */
536 671 public function getActiveStatByAgent($agentId)
537 672 {
538 - $waitStat = Ticket::waitingOnly()
673 + $waitStat = (new AgentTicketAccess())->applyMailboxRestrictionScope(Ticket::waitingOnly())
539 674 ->where('status', '!=', 'closed')
540 675 ->whereNotNull('waiting_since')
541 676 ->where('agent_id', $agentId)
542 677 ->select([
@@ -549,8 +684,45 @@
549 684 if(!$waitStat) {
550 685 return false;
551 686 }
552 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 + {
553 725 $waitStat->avg_waiting = intval($waitStat->avg_waiting);
554 726 if($waitStat->avg_waiting > 0) {
555 727 $waitSeconds = time() - $waitStat->avg_waiting;
556 728 if( $waitSeconds < 172800 && $waitSeconds > 7200) {
@@ -582,21 +754,39 @@
582 754 return [];
583 755 }
584 756 }
585 757
586 - public function getTicketStats($from, $to)
758 + public function getTicketStats($from, $to, $productId = null)
587 759 {
760 + global $wpdb;
761 +
588 762 $whereClause = '';
763 + $queryParams = [];
764 +
589 765 if ($from && $to) {
590 - // Ensure the dates are in the correct format (Y-m-d H:i:s)
591 - $start_date = date('Y-m-d 00:00:00', strtotime($from));
592 - $end_date = date('Y-m-d 23:59:59', strtotime($to));
593 - $whereClause = "WHERE created_at BETWEEN '$start_date' AND '$end_date'";
766 + $start_date = gmdate('Y-m-d 00:00:00', strtotime($from));
767 + $end_date = gmdate('Y-m-d 23:59:59', strtotime($to));
768 + $whereClause = 'WHERE created_at BETWEEN %s AND %s';
769 + $queryParams[] = $start_date;
770 + $queryParams[] = $end_date;
594 771 }
595 772
596 - global $wpdb;
773 + if ($productId) {
774 + $whereClause .= ($whereClause ? ' AND ' : 'WHERE ') . 'product_id = %d';
775 + $queryParams[] = intval($productId);
776 + }
597 777
598 - // SQL query to count tickets by day of week and hour within the specified date range
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.
788 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
599 789 $query = "SELECT DAYNAME(created_at) AS weekday, HOUR(created_at) AS hour, COUNT(*) AS count
600 790 FROM {$wpdb->prefix}fs_tickets
601 791 {$whereClause}
602 792 GROUP BY DAYNAME(created_at), HOUR(created_at)
@@ -601,19 +791,18 @@
601 791 {$whereClause}
602 792 GROUP BY DAYNAME(created_at), HOUR(created_at)
603 793 ORDER BY FIELD(DAYNAME(created_at), 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'), HOUR(created_at)";
604 794
605 - // Execute the query
606 - $results = $wpdb->get_results($query);
795 + // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $query structure is hardcoded; values are bound via $wpdb->prepare() when $queryParams is non-empty.
796 + $results = $queryParams
797 + ? $wpdb->get_results($wpdb->prepare($query, $queryParams)) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
798 + : $wpdb->get_results($query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
607 799
608 800 $fillData = array_fill(0, 24, 0);
609 - // add :00 on the suffix
610 801 $fillData = array_combine(array_map(function ($hour) {
611 802 return sprintf('%1d:00', $hour);
612 803 }, array_keys($fillData)), $fillData);
613 804
614 - // Prepare the report
615 - // Prepare the report
616 805 $report = [
617 806 'Mon' => $fillData,
618 807 'Tue' => $fillData,
619 808 'Wed' => $fillData,
@@ -633,45 +822,67 @@
633 822
634 823 return $report;
635 824 }
636 825
637 - public function getResponseStats($from, $to, $reportType, $agentId = null)
826 + public function getResponseStats($from, $to, $reportType, $agentId = null, $productId = null)
638 827 {
639 -
640 828 global $wpdb;
641 829
642 - $whereClause = " AND p.person_type = '" . $reportType . "'";
830 + $whereClause = ' AND p.person_type = %s';
831 + $queryParams = [$reportType];
643 832
644 833 if ($from && $to) {
645 - // Ensure the dates are in the correct format (Y-m-d H:i:s)
646 - $start_date = date('Y-m-d 00:00:00', strtotime($from));
647 - $end_date = date('Y-m-d 23:59:59', strtotime($to));
648 - $whereClause .= " AND c.created_at BETWEEN '$start_date' AND '$end_date'";
834 + $start_date = gmdate('Y-m-d 00:00:00', strtotime($from));
835 + $end_date = gmdate('Y-m-d 23:59:59', strtotime($to));
836 + $whereClause .= ' AND c.created_at BETWEEN %s AND %s';
837 + $queryParams[] = $start_date;
838 + $queryParams[] = $end_date;
649 839 }
650 840
651 841 if ($agentId) {
652 - $whereClause .= " AND c.person_id = $agentId";
842 + $whereClause .= ' AND c.person_id = %d';
843 + $queryParams[] = intval($agentId);
653 844 }
654 845
655 - // SQL query to count customer responses by day of week and hour within the specified date range
656 - $query = "SELECT DAYNAME(c.created_at) AS weekday, HOUR(c.created_at) AS hour, COUNT(*) AS count
657 - FROM {$wpdb->prefix}fs_conversations AS c
658 - JOIN {$wpdb->prefix}fs_persons AS p ON c.person_id = p.id
659 - WHERE c.conversation_type = 'response'
660 - {$whereClause}
661 - GROUP BY DAYNAME(c.created_at), HOUR(c.created_at)
662 - ORDER BY FIELD(DAYNAME(c.created_at), 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'), HOUR(c.created_at)";
846 + // Mailbox and product scoping go through the parent ticket.
847 + $ticketConditions = [];
848 + $restrictedMailboxIds = PermissionManager::getRestrictedMailboxIds();
663 849
664 - // Execute the query
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
866 + $query = $wpdb->prepare(
867 + "SELECT DAYNAME(c.created_at) AS weekday, HOUR(c.created_at) AS hour, COUNT(*) AS count
868 + FROM {$wpdb->prefix}fs_conversations AS c
869 + JOIN {$wpdb->prefix}fs_persons AS p ON c.person_id = p.id
870 + WHERE c.conversation_type = 'response'
871 + {$whereClause}
872 + GROUP BY DAYNAME(c.created_at), HOUR(c.created_at)
873 + ORDER BY FIELD(DAYNAME(c.created_at), 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'), HOUR(c.created_at)",
874 + $queryParams
875 + );
876 +
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.
665 878 $results = $wpdb->get_results($query);
666 879
667 880 $fillData = array_fill(0, 24, 0);
668 - // add :00 on the suffix
669 881 $fillData = array_combine(array_map(function ($hour) {
670 882 return sprintf('%1d:00', $hour);
671 883 }, array_keys($fillData)), $fillData);
672 884
673 - // Prepare the report
674 885 $report = [
675 886 'Mon' => $fillData,
676 887 'Tue' => $fillData,
677 888 'Wed' => $fillData,
@@ -693,9 +904,11 @@
693 904 }
694 905
695 906 public function getTicketResponseStats($from, $to, $filter)
696 907 {
697 - $query = Conversation::query()
908 + $query = (new AgentTicketAccess())->applyMailboxRestrictionScopeViaTicket(
909 + Conversation::query()
910 + )
698 911 ->select('id', 'ticket_id', 'person_id', 'created_at', 'content')
699 912 ->addSelect([
700 913 'person_type' => Person::select('person_type')
701 914 ->whereColumn('id', 'fs_conversations.person_id'),