Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/admin_messages.cgi

O Operator
Diff

/var/www/vhosts/3dshawn.com/crm.3dshawn.com/admin_messages.cgi

added on local at 2026-07-11 18:31:43

Added
+442
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 1bbc5588a128
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2#======================================================================
3# ContactForge -- admin support inbox.
4#
5# Every customer support thread in one place. Sister to support.cgi
6# but viewed from the admin side: same two-pane layout, but the list
7# shows EVERY customer's threads (not just yours), and the right pane
8# has extra controls (invite to chat, mark resolved, change priority).
9#
10# Routes:
11# GET /admin_messages.cgi -- list of all threads
12# GET /admin_messages.cgi?thread_id=N -- list + open thread N
13# GET /admin_messages.cgi?status=resolved -- filter list
14# POST /admin_messages.cgi action=set_status -- mark resolved/closed/etc
15# (Replies go through /support_send.cgi same as the customer side.)
16#
17# Admin-only: non-admin users get a 403.
18#
19# Note: live-chat actions were removed -- async messaging only. The
20# Support.pm helpers for chat (invite_to_chat / end_chat) still
21# exist but are unreachable from the UI.
22#======================================================================
23use strict;
24use warnings;
25
26use lib '/var/www/vhosts/3dshawn.com/crm.3dshawn.com';
27use CGI;
28use MODS::Template;
29use MODS::DBConnect;
30use MODS::Login;
31use MODS::ContactForge::Config;
32use MODS::ContactForge::Wrapper;
33use MODS::ContactForge::Support;
34
35my $q = CGI->new;
36my $form = $q->Vars;
37my $tfile = MODS::Template->new;
38my $db = MODS::DBConnect->new;
39my $auth = MODS::Login->new;
40my $config = MODS::ContactForge::Config->new;
41my $wrap = MODS::ContactForge::Wrapper->new;
42my $sup = MODS::ContactForge::Support->new;
43my $DB = $config->settings('database_name');
44
45$| = 1;
46
47my $userinfo = $auth->login_verify();
48unless ($userinfo) {
49 print "Status: 302 Found\nLocation: /login.cgi?return=/admin_messages.cgi\n\n";
50 exit;
51}
52unless ($userinfo->{is_admin}) {
53 print "Status: 403 Forbidden\nContent-Type: text/plain\n\nAdmin access required.\n";
54 exit;
55}
56require MODS::ContactForge::Permissions;
57MODS::ContactForge::Permissions->new->require_feature($userinfo, 'admin_view_messages');
58
59my $uid = $userinfo->{user_id};
60$uid =~ s/[^0-9]//g;
61
62my $dbh = $db->db_connect();
63
64# ---- POST handlers (PRG pattern) -----------------------------------
65if (($ENV{REQUEST_METHOD} || '') eq 'POST') {
66 my $action = $form->{action} || '';
67 my $tid = $form->{thread_id} || 0; $tid =~ s/[^0-9]//g;
68 if ($action eq 'set_status' && $tid) {
69 my $new = $form->{status} || '';
70 $sup->set_status($db, $dbh, $DB, $tid, $new);
71 $db->db_disconnect($dbh);
72 print "Status: 302 Found\nLocation: /admin_messages.cgi?thread_id=$tid\n\n";
73 exit;
74 }
75}
76
77# ---- GET render ----------------------------------------------------
78my $tab = lc($form->{tab} || 'inbox');
79$tab =~ s/[^a-z]//g;
80$tab = 'inbox' unless $tab =~ /^(inbox|reports)$/;
81
82my $filter = lc($form->{status} || '');
83$filter =~ s/[^a-z_]//g;
84$filter = '' unless $filter =~ /^(open|waiting_admin|waiting_customer|resolved|closed)$/;
85
86my $threads = ($tab eq 'inbox') ? $sup->list_threads_for_admin($db, $dbh, $DB, { status => $filter }) : [];
87
88# Reports-tab metrics: support volume + response time + resolved counts.
89my ($rep_total, $rep_open, $rep_waiting, $rep_resolved_7d, $rep_resolved_30d,
90 $rep_new_7d, $rep_new_30d, $rep_avg_resp_min);
91if ($tab eq 'reports') {
92 sub _n { my ($sql) = @_; my $r; eval { $r = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); 1 }; return ($r && defined $r->{n}) ? $r->{n} : 0; }
93 $rep_total = _n("SELECT COUNT(*) AS n FROM `${DB}`.support_threads");
94 $rep_open = _n("SELECT COUNT(*) AS n FROM `${DB}`.support_threads WHERE status='open' OR status='waiting_admin'");
95 $rep_waiting = _n("SELECT COUNT(*) AS n FROM `${DB}`.support_threads WHERE status='waiting_customer'");
96 $rep_resolved_7d = _n("SELECT COUNT(*) AS n FROM `${DB}`.support_threads WHERE status='resolved' AND resolved_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)");
97 $rep_resolved_30d= _n("SELECT COUNT(*) AS n FROM `${DB}`.support_threads WHERE status='resolved' AND resolved_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)");
98 $rep_new_7d = _n("SELECT COUNT(*) AS n FROM `${DB}`.support_threads WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)");
99 $rep_new_30d = _n("SELECT COUNT(*) AS n FROM `${DB}`.support_threads WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)");
100 # Average first-response time: minutes between thread.created_at
101 # and the first admin reply in that thread. Skips threads where the
102 # admin hasn't replied yet.
103 $rep_avg_resp_min = _n(qq~
104 SELECT COALESCE(AVG(TIMESTAMPDIFF(MINUTE, t.created_at, first_admin.first_at)), 0) AS n
105 FROM `${DB}`.support_threads t
106 JOIN (
107 SELECT thread_id, MIN(created_at) AS first_at
108 FROM `${DB}`.support_messages
109 WHERE sender_role='admin'
110 GROUP BY thread_id
111 ) first_admin ON first_admin.thread_id = t.id
112 WHERE t.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
113 ~);
114 $rep_avg_resp_min = int($rep_avg_resp_min);
115}
116
117# Daily time-series for the Reports tab area charts. Two parallel
118# 30-day arrays: one for new threads, one for resolved threads. SQL
119# returns only days with at least one row; we fill in zeros for
120# missing days so the chart axis is continuous.
121my $chart_new_html = '';
122my $chart_resolved_html = '';
123if ($tab eq 'reports') {
124 my %new_by_day;
125 eval {
126 my @rows = $db->db_readwrite_multiple($dbh, qq~
127 SELECT DATE(created_at) AS d, COUNT(*) AS n
128 FROM `${DB}`.support_threads
129 WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
130 GROUP BY DATE(created_at)
131 ~, $ENV{SCRIPT_NAME}, __LINE__);
132 $new_by_day{$_->{d}} = $_->{n} + 0 for @rows;
133 };
134 my %res_by_day;
135 eval {
136 my @rows = $db->db_readwrite_multiple($dbh, qq~
137 SELECT DATE(resolved_at) AS d, COUNT(*) AS n
138 FROM `${DB}`.support_threads
139 WHERE status='resolved'
140 AND resolved_at IS NOT NULL
141 AND resolved_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
142 GROUP BY DATE(resolved_at)
143 ~, $ENV{SCRIPT_NAME}, __LINE__);
144 $res_by_day{$_->{d}} = $_->{n} + 0 for @rows;
145 };
146
147 require POSIX;
148 my ($s,$m,$h,$mday,$mon,$year) = localtime(time);
149 my $today_e = POSIX::mktime(0,0,12,$mday,$mon,$year);
150 my @months = qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec);
151 my (@new_series, @res_series);
152 for (my $i = 29; $i >= 0; $i--) {
153 my @t = localtime($today_e - $i*86400);
154 my $iso = sprintf('%04d-%02d-%02d', $t[5]+1900, $t[4]+1, $t[3]);
155 my $label = sprintf('%s %d', $months[$t[4]], $t[3]);
156 push @new_series, { iso => $iso, label => $label, value => ($new_by_day{$iso} || 0) };
157 push @res_series, { iso => $iso, label => $label, value => ($res_by_day{$iso} || 0) };
158 }
159 $chart_new_html = _build_area_chart(id => 'new', color => '#3b82f6', data => \@new_series, title => 'New tickets');
160 $chart_resolved_html = _build_area_chart(id => 'res', color => '#10b981', data => \@res_series, title => 'Resolved');
161}
162
163# ----------------------------------------------------------------------
164# Area chart builder. Returns inline SVG with hotspot rects the JS
165# uses for hover tooltip + click drilldown. Vanilla SVG, no libs.
166# ----------------------------------------------------------------------
167sub _build_area_chart {
168 my (%o) = @_;
169 my $id = $o{id};
170 my $color = $o{color} || '#3b82f6';
171 my @data = @{ $o{data} || [] };
172 my $title = $o{title} || '';
173 return '' unless @data;
174
175 my $W = 760; my $H = 200;
176 my ($PT, $PB, $PL, $PR) = (16, 28, 36, 16);
177 my $plot_w = $W - $PL - $PR;
178 my $plot_h = $H - $PT - $PB;
179
180 my $max = 0;
181 for my $d (@data) { $max = $d->{value} if $d->{value} > $max; }
182 my $y_max = $max > 0 ? $max : 1;
183
184 my $n = scalar(@data);
185 my @points;
186 for (my $i = 0; $i < $n; $i++) {
187 my $x = $PL + ($n > 1 ? ($i / ($n - 1)) * $plot_w : 0);
188 my $y = $PT + $plot_h - ($data[$i]->{value} / $y_max) * $plot_h;
189 push @points, { x => sprintf('%.2f', $x), y => sprintf('%.2f', $y), %{ $data[$i] } };
190 }
191
192 my $base_y = $PT + $plot_h;
193 my $fill_d = "M $points[0]->{x} $base_y";
194 $fill_d .= " L $_->{x} $_->{y}" for @points;
195 $fill_d .= " L $points[-1]->{x} $base_y Z";
196
197 my $stroke_d = "M $points[0]->{x} $points[0]->{y}";
198 $stroke_d .= " L $points[$_]->{x} $points[$_]->{y}" for 1..$#points;
199
200 my $axis_html = '';
201 my @idx = (0, int($n*0.25), int($n*0.5), int($n*0.75), $n - 1);
202 my %seen;
203 for my $i (@idx) {
204 next if $seen{$i}++;
205 my $p = $points[$i];
206 my $ty = $H - 8;
207 $axis_html .= qq~<text x="$p->{x}" y="$ty" text-anchor="middle" fill="rgba(255,255,255,0.40)" font-size="10" font-family="ui-monospace,monospace">$p->{label}</text>~;
208 }
209
210 my $hover_html = '';
211 my $col_w = $n > 1 ? $plot_w / ($n - 1) : $plot_w;
212 for my $i (0..$#points) {
213 my $p = $points[$i];
214 my $hx = sprintf('%.2f', $PL + $i*$col_w - $col_w/2);
215 my $hw = sprintf('%.2f', $col_w);
216 $hover_html .= qq~<rect class="ac-hot" x="$hx" y="$PT" width="$hw" height="$plot_h" fill="transparent" data-x="$p->{x}" data-y="$p->{y}" data-iso="$p->{iso}" data-label="$p->{label}" data-value="$p->{value}"></rect>~;
217 }
218
219 my $ymax_y = $PT - 4;
220 my $base_lbl_y = $H - 8;
221 return qq~
222<svg class="ac-svg" viewBox="0 0 $W $H" preserveAspectRatio="none" style="width:100%;height:200px;display:block;overflow:visible">
223 <defs>
224 <linearGradient id="ac-grad-$id" x1="0" x2="0" y1="0" y2="1">
225 <stop offset="0%" stop-color="$color" stop-opacity="0.55"/>
226 <stop offset="100%" stop-color="$color" stop-opacity="0"/>
227 </linearGradient>
228 </defs>
229 <line x1="$PL" y1="$base_y" x2="${\ ($W - $PR)}" y2="$base_y" stroke="rgba(255,255,255,0.12)" stroke-width="1"/>
230 <path d="$fill_d" fill="url(#ac-grad-$id)"/>
231 <path d="$stroke_d" fill="none" stroke="$color" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
232 <line class="ac-guide" x1="0" y1="$PT" x2="0" y2="$base_y" stroke="$color" stroke-width="1" stroke-dasharray="3 3" opacity="0"/>
233 <circle class="ac-dot" cx="0" cy="0" r="4" fill="#fff" stroke="$color" stroke-width="2" opacity="0"/>
234 $axis_html
235 <text x="${\ ($PL - 6)}" y="$ymax_y" text-anchor="end" fill="rgba(255,255,255,0.45)" font-size="10" font-family="ui-monospace,monospace">$y_max</text>
236 $hover_html
237</svg>~;
238}
239
240my $active_tid = $form->{thread_id} || 0;
241$active_tid =~ s/[^0-9]//g;
242my $active_thread;
243my $messages = [];
244if ($active_tid) {
245 $active_thread = $sup->load_thread($db, $dbh, $DB, $active_tid);
246 if ($active_thread) {
247 $messages = $sup->load_messages($db, $dbh, $DB, $active_tid);
248 # Viewing zeros the admin-side unread counter for this thread.
249 $sup->mark_read($db, $dbh, $DB, $active_tid, 'admin') if $active_thread->{admin_unread};
250 }
251}
252
253# Customer email + name for the active thread header.
254my ($active_cust_name, $active_cust_email);
255if ($active_thread) {
256 my $cu = $db->db_readwrite($dbh,
257 qq~SELECT display_name, email FROM `${DB}`.users WHERE id='$active_thread->{customer_user_id}' LIMIT 1~,
258 $ENV{SCRIPT_NAME}, __LINE__);
259 $active_cust_name = $cu->{display_name} if $cu;
260 $active_cust_email = $cu->{email} if $cu;
261}
262
263$db->db_disconnect($dbh);
264
265# ---- Build template vars -------------------------------------------
266my @thread_tiles;
267foreach my $t (@$threads) {
268 my $is_active = ($active_tid && $t->{id} eq $active_tid) ? 1 : 0;
269 push @thread_tiles, {
270 id => $t->{id},
271 subject => _h($t->{subject}),
272 customer_name => _h($t->{customer_name} || $t->{customer_email} || 'Anonymous'),
273 customer_email=> _h($t->{customer_email}),
274 status => $t->{status},
275 status_label => _status_label($t->{status}),
276 status_class => _status_class($t->{status}),
277 priority => $t->{priority},
278 unread => $t->{admin_unread} || 0,
279 has_unread => ($t->{admin_unread} && !$is_active) ? 1 : 0,
280 is_active => $is_active,
281 last_at => _humanize_ts($t->{last_message_at}),
282 last_epoch => _ts_epoch($t->{last_message_at}),
283 };
284}
285
286# Fetch attachments for the active thread in one pass so we can zip
287# them into per-message lists without N+1 lookups. Guarded against
288# the table not yet existing (pre-migration deploys).
289my %atts_by_msg;
290if ($active_thread) {
291 my $have_at = $db->db_readwrite($dbh, qq~
292 SELECT COUNT(*) AS n FROM information_schema.tables
293 WHERE table_schema='$DB' AND table_name='support_attachments'
294 ~, $ENV{SCRIPT_NAME}, __LINE__);
295 if ($have_at && $have_at->{n}) {
296 my @arows = $db->db_readwrite_multiple($dbh, qq~
297 SELECT id, message_id, filename
298 FROM `${DB}`.support_attachments
299 WHERE thread_id='$active_tid'
300 ORDER BY uploaded_at ASC
301 ~, $ENV{SCRIPT_NAME}, __LINE__);
302 for my $a (@arows) {
303 push @{ $atts_by_msg{ $a->{message_id} } }, {
304 id => $a->{id},
305 url => "/support_file.cgi?id=$a->{id}",
306 name => $a->{filename},
307 };
308 }
309 }
310}
311
312my @message_rows;
313if ($active_thread) {
314 foreach my $m (@$messages) {
315 my $role = $m->{sender_role};
316 my $atts = $atts_by_msg{ $m->{id} } || [];
317 push @message_rows, {
318 id => $m->{id},
319 body_html => _body_html($m->{body}),
320 ts => _humanize_ts($m->{created_at}),
321 ts_epoch => _ts_epoch($m->{created_at}),
322 ts_iso => $m->{created_at},
323 sender_name => _h($m->{sender_name} || ($role eq 'system' ? 'ContactForge' : 'Anonymous')),
324 sender_initial => uc(substr($m->{sender_name} || ($role eq 'admin' ? 'W' : 'C'), 0, 1)),
325 is_customer => ($role eq 'customer') ? 1 : 0,
326 is_admin => ($role eq 'admin') ? 1 : 0,
327 is_system => ($role eq 'system') ? 1 : 0,
328 attachments => $atts,
329 has_attach => scalar(@$atts) ? 1 : 0,
330 };
331 }
332}
333
334my $tvars = {
335 tab_inbox => ($tab eq 'inbox') ? 1 : 0,
336 tab_reports => ($tab eq 'reports') ? 1 : 0,
337
338 filter => $filter,
339 threads => \@thread_tiles,
340 has_threads => scalar(@thread_tiles) ? 1 : 0,
341 thread_count => scalar(@thread_tiles),
342
343 # Reports tab metrics
344 rep_total => $rep_total || 0,
345 rep_open => $rep_open || 0,
346 rep_waiting => $rep_waiting || 0,
347 rep_resolved_7d => $rep_resolved_7d || 0,
348 rep_resolved_30d => $rep_resolved_30d || 0,
349 rep_new_7d => $rep_new_7d || 0,
350 rep_new_30d => $rep_new_30d || 0,
351 rep_avg_resp_min => $rep_avg_resp_min || 0,
352 rep_avg_resp_hours=> ($rep_avg_resp_min && $rep_avg_resp_min >= 60) ? sprintf('%.1f', $rep_avg_resp_min/60) : 0,
353 rep_avg_resp_is_minutes => ($rep_avg_resp_min && $rep_avg_resp_min < 60) ? 1 : 0,
354 rep_avg_resp_is_hours => ($rep_avg_resp_min && $rep_avg_resp_min >= 60) ? 1 : 0,
355 rep_avg_resp_unknown => ($rep_avg_resp_min) ? 0 : 1,
356 chart_new_html => $chart_new_html,
357 chart_resolved_html => $chart_resolved_html,
358 has_charts => ($chart_new_html ne '') ? 1 : 0,
359
360 has_active => $active_thread ? 1 : 0,
361 active_id => $active_thread ? $active_thread->{id} : 0,
362 active_subject => $active_thread ? _h($active_thread->{subject}) : '',
363 active_status => $active_thread ? _status_label($active_thread->{status}) : '',
364 active_status_raw => $active_thread ? $active_thread->{status} : '',
365 active_status_class => $active_thread ? _status_class($active_thread->{status}) : '',
366 active_priority => $active_thread ? $active_thread->{priority} : '',
367 active_cust_name => _h($active_cust_name || ''),
368 active_cust_email => _h($active_cust_email || ''),
369 messages => \@message_rows,
370 has_messages => scalar(@message_rows) ? 1 : 0,
371 last_message_id => scalar(@message_rows) ? $message_rows[-1]->{id} : 0,
372};
373
374my $body = join('', $tfile->template('cf_admin_messages.html', $tvars, $userinfo));
375
376print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
377
378$wrap->render({
379 userinfo => $userinfo,
380 page_key => 'admin_messages',
381 title => 'Support inbox',
382 body => $body,
383});
384
385exit;
386
387#======================================================================
388sub _h {
389 my $s = shift; $s = '' unless defined $s;
390 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
391 $s =~ s/"/&quot;/g; $s =~ s/'/&#39;/g;
392 return $s;
393}
394sub _body_html {
395 my $s = shift; $s = '' unless defined $s;
396 $s = _h($s);
397 $s =~ s/\$/\\\$/g;
398 $s =~ s/\@/\\\@/g;
399 $s =~ s/\r//g;
400 $s =~ s/\n/<br>/g;
401 return $s;
402}
403sub _status_label {
404 my $s = shift || '';
405 return 'Open' if $s eq 'open';
406 return 'Needs reply' if $s eq 'waiting_admin';
407 return 'Waiting on contact' if $s eq 'waiting_customer';
408 return 'Resolved' if $s eq 'resolved';
409 return 'Closed' if $s eq 'closed';
410 return $s;
411}
412sub _status_class {
413 my $s = shift || '';
414 return 'st-needs' if $s eq 'waiting_admin';
415 return 'st-open' if $s eq 'open';
416 return 'st-waiting' if $s eq 'waiting_customer';
417 return 'st-resolved' if $s eq 'resolved';
418 return 'st-closed' if $s eq 'closed';
419 return 'st-open';
420}
421sub _ts_epoch {
422 my $t = shift; return 0 unless defined $t;
423 return 0 unless $t =~ /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/;
424 my ($y, $mo, $d, $hh, $mm, $ss) = ($1, $2, $3, $4, $5, $6);
425 require Time::Local;
426 return eval { Time::Local::timelocal($ss, $mm, $hh, $d, $mo - 1, $y - 1900) } || 0;
427}
428sub _humanize_ts {
429 my $t = shift; $t = '' unless defined $t;
430 return '' unless $t =~ /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/;
431 my ($y, $mo, $d, $hh, $mm, $ss) = ($1, $2, $3, $4, $5, $6);
432 require Time::Local;
433 my $then = eval { Time::Local::timelocal($ss, $mm, $hh, $d, $mo - 1, $y - 1900) } || 0;
434 my $diff = time - $then;
435 return 'Just now' if $diff < 60;
436 return int($diff/60) . 'm ago' if $diff < 3600;
437 return int($diff/3600) . 'h ago' if $diff < 86400;
438 return 'Yesterday' if $diff < 172800;
439 return int($diff/86400) . 'd ago' if $diff < 604800;
440 my @mn = ('','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec');
441 return "$mn[int($mo)] $d";
442}