Diff -- /var/www/vhosts/3dshawn.com/repricer.3dshawn.com/admin.cgi

O Operator
Diff

/var/www/vhosts/3dshawn.com/repricer.3dshawn.com/admin.cgi

added on local at 2026-07-11 18:33:39

Added
+266
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 1c47e5916dbd
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# RePricer - Admin Console (dashboard)
4#
5# Company-staff-only landing page. Lists platform-wide KPIs and offers
6# a user search that drills into /admin_user.cgi for per-account
7# inspection + control. Gated by MODS::RePricer::Admin->require_admin;
8# non-admins land on /dashboard.cgi.
9#======================================================================
10use strict;
11use warnings;
12
13use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com';
14use CGI;
15use MODS::Template;
16use MODS::DBConnect;
17use MODS::Login;
18use MODS::RePricer::Config;
19use MODS::RePricer::Wrapper;
20use MODS::RePricer::Admin;
21
22my $q = CGI->new;
23my $form = $q->Vars;
24my $auth = MODS::Login->new;
25my $wrap = MODS::RePricer::Wrapper->new;
26my $tfile = MODS::Template->new;
27my $db = MODS::DBConnect->new;
28my $cfg = MODS::RePricer::Config->new;
29my $admin = MODS::RePricer::Admin->new;
30my $DB = $cfg->settings('database_name');
31
32$| = 1;
33
34my $userinfo = $auth->login_verify();
35if (!$userinfo) {
36 print "Status: 302 Found\nLocation: /login.cgi\n\n";
37 exit;
38}
39$admin->require_admin($userinfo);
40require MODS::RePricer::Permissions;
41MODS::RePricer::Permissions->new->require_feature($userinfo, 'admin_view_users');
42
43# Tutorial mode: when ?tutorial=1, render an example-data walkthrough
44# instead of live KPIs / search. Same pattern as the dashboard +
45# analytics tutorials. Admins-only page, so the audience here is
46# company staff onboarding into the operations console -- the sample
47# data shows what the page reads like on a populated platform.
48my $tutorial_mode = ($form->{tutorial} && $form->{tutorial} eq '1') ? 1 : 0;
49
50# ---- Search input (optional). Free-text against email, display_name,
51# id, or storefront name. Sanitized before going into SQL.
52my $q_raw = defined $form->{q} ? $form->{q} : '';
53my $q_clean = $q_raw;
54$q_clean =~ s/[^A-Za-z0-9._@\-\s]//g;
55$q_clean =~ s/^\s+|\s+$//g;
56my $q_safe = $q_clean;
57$q_safe =~ s/'/''/g; # SQL-escape any apostrophes
58my $like = '%' . $q_safe . '%';
59
60my $dbh = $db->db_connect();
61
62# ---- KPI tiles -------------------------------------------------------
63my $u_total = _scalar($db, $dbh, "SELECT COUNT(*) AS n FROM `${DB}`.users", 'n');
64my $u_active = _scalar($db, $dbh, "SELECT COUNT(*) AS n FROM `${DB}`.users WHERE account_status='active'", 'n');
65my $u_suspended = _scalar($db, $dbh, "SELECT COUNT(*) AS n FROM `${DB}`.users WHERE account_status='suspended'", 'n');
66my $u_admins = _scalar($db, $dbh, "SELECT COUNT(*) AS n FROM `${DB}`.users WHERE is_admin=1", 'n');
67my $u_new_30d = _scalar($db, $dbh,
68 "SELECT COUNT(*) AS n FROM `${DB}`.users WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)", 'n');
69
70# Storefront + model counts (cheap, full-table scans on small tables).
71my $sf_total = _scalar($db, $dbh, "SELECT COUNT(*) AS n FROM `${DB}`.storefronts", 'n');
72my $m_total = _scalar($db, $dbh,
73 "SELECT COUNT(*) AS n FROM `${DB}`.models WHERE purged_at IS NULL", 'n');
74
75# Platform revenue across every active storefront (orders.status='paid'
76# in the last 30 days).
77my $rev_30d_cents = _scalar($db, $dbh, qq~
78 SELECT COALESCE(SUM(total_amount_cents), 0) AS n
79 FROM `${DB}`.orders
80 WHERE status='paid'
81 AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
82~, 'n');
83
84# ---- User search results --------------------------------------------
85# Default sort: most recently created. With a query, search across
86# email, display_name, numeric id, and the user's storefront name.
87my $where = '';
88if ($q_clean ne '') {
89 $where = qq~
90 WHERE u.email LIKE '$like'
91 OR u.display_name LIKE '$like'
92 OR u.id = '$q_safe'
93 OR s.name LIKE '$like'
94 OR s.subdomain LIKE '$like'
95 ~;
96}
97
98my @users = $db->db_readwrite_multiple($dbh, qq~
99 SELECT u.id,
100 u.email,
101 u.display_name,
102 u.plan_tier,
103 u.account_status,
104 u.is_admin,
105 u.last_login_at,
106 u.created_at,
107 UNIX_TIMESTAMP(u.last_login_at) AS last_login_at_epoch,
108 UNIX_TIMESTAMP(u.created_at) AS created_at_epoch,
109 s.id AS storefront_id,
110 s.name AS storefront_name,
111 s.subdomain AS storefront_subdomain
112 FROM `${DB}`.users u
113 LEFT JOIN `${DB}`.storefronts s ON s.user_id = u.id
114 $where
115 GROUP BY u.id
116 ORDER BY u.created_at DESC
117 LIMIT 100
118~, $ENV{SCRIPT_NAME}, __LINE__);
119
120# Decorate for template.
121foreach my $u (@users) {
122 $u->{plan_tier} = ucfirst($u->{plan_tier} || 'free');
123 $u->{is_admin_flag} = $u->{is_admin} ? 1 : 0;
124 $u->{is_suspended} = (($u->{account_status} || '') eq 'suspended') ? 1 : 0;
125 $u->{is_closed} = (($u->{account_status} || '') eq 'closed') ? 1 : 0;
126 $u->{display_name} = $u->{display_name} || $u->{email} || ('User #' . $u->{id});
127 {
128 my $ep = int($u->{last_login_at_epoch} || 0);
129 my $s = $u->{last_login_at} || '';
130 $u->{last_login_at} = ($ep > 0 && length $s)
131 ? qq~<span class="ts" data-ts="$ep" data-fmt="datetime">$s</span>~
132 : ($s || '-');
133 }
134 {
135 my $ep = int($u->{created_at_epoch} || 0);
136 my $s = $u->{created_at} || '';
137 $u->{created_at} = ($ep > 0 && length $s)
138 ? qq~<span class="ts" data-ts="$ep" data-fmt="datetime">$s</span>~
139 : ($s || '-');
140 }
141 $u->{storefront_name} = $u->{storefront_name} || '-';
142 # TF-parity decoration (single-row layout)
143 my $palette = ['#5aa9ff','#34d399','#fbbf24','#a78bfa','#f87171','#22c55e','#06b6d4','#ec4899','#f59e0b'];
144 $u->{avatar_color} = $palette->[ ($u->{id} || 0) % scalar(@$palette) ];
145 my $dn = $u->{display_name} || $u->{email} || '?';
146 my @parts = split /\s+/, $dn, 2;
147 my $ini = '';
148 $ini .= uc(substr($parts[0], 0, 1)) if defined $parts[0];
149 $ini .= uc(substr($parts[1], 0, 1)) if defined $parts[1] && length $parts[1];
150 $ini = uc(substr($dn, 0, 2)) unless length $ini;
151 $u->{initials} = $ini;
152 $u->{is_self} = ($u->{id} == $userinfo->{user_id}) ? 1 : 0;
153}
154
155$db->db_disconnect($dbh);
156
157my $tvars = {
158 user_id => $userinfo->{user_id},
159
160 # KPIs
161 u_total => $u_total,
162 u_active => $u_active,
163 u_suspended => $u_suspended,
164 u_admins => $u_admins,
165 u_new_30d => $u_new_30d,
166 sf_total => $sf_total,
167 m_total => $m_total,
168 rev_30d => '$' . sprintf('%.2f', ($rev_30d_cents || 0) / 100),
169
170 # Search state
171 q => _esc($q_clean),
172 result_count => scalar(@users),
173 has_results => scalar(@users) ? 1 : 0,
174 has_query => ($q_clean ne '') ? 1 : 0,
175
176 # Rows
177 users => \@users,
178
179 # Tutorial mode flag. When 1, the template renders the example-data
180 # walkthrough; when 0, the KPIs + user table come from the live DB.
181 tutorial_mode => $tutorial_mode,
182 not_tutorial_mode => $tutorial_mode ? 0 : 1,
183};
184
185# ---- Tutorial-mode overlay ------------------------------------------
186# When ?tutorial=1 we paint sample platform-wide KPIs and a sample user
187# list. None of this touches the DB; numbers are illustrative. The
188# audience here is company staff being onboarded into the admin
189# console -- the sample shows what the page reads like on a populated
190# platform with thousands of users.
191if ($tutorial_mode) {
192 $tvars->{u_total} = '1,247';
193 $tvars->{u_active} = '1,089';
194 $tvars->{u_suspended} = 12;
195 $tvars->{u_admins} = 3;
196 $tvars->{u_new_30d} = 86;
197 $tvars->{sf_total} = '1,154';
198 $tvars->{m_total} = '8,372';
199 $tvars->{rev_30d} = '$42,810.00';
200
201 # Sample user list -- a mix of plans, statuses, and join dates so
202 # every pill variant on the page (pro / business / scale,
203 # active / suspended / closed, admin flag) renders for the walker.
204 $tvars->{users} = [
205 { id => 1247, email => 'rune.caster@example.com', display_name => 'Rune Caster',
206 plan_tier => 'Scale', is_admin_flag => 0, is_suspended => 0, is_closed => 0,
207 account_status => 'active', last_login_at => '2026-05-12 09:14',
208 created_at => '2025-08-03', storefront_name => 'rune-demo.studio' },
209 { id => 1192, email => 'maya.r@example.com', display_name => 'Maya R.',
210 plan_tier => 'Business', is_admin_flag => 0, is_suspended => 0, is_closed => 0,
211 account_status => 'active', last_login_at => '2026-05-12 07:42',
212 created_at => '2025-11-19', storefront_name => 'shardworks.studio' },
213 { id => 1088, email => 'bench.hammer@example.com', display_name => 'Bench Hammer',
214 plan_tier => 'Pro', is_admin_flag => 0, is_suspended => 0, is_closed => 0,
215 account_status => 'active', last_login_at => '2026-05-11 22:08',
216 created_at => '2026-01-14', storefront_name => 'benchhammer.print' },
217 { id => '987', email => 'priya.t@example.com', display_name => 'Priya T.',
218 plan_tier => 'Pro', is_admin_flag => 0, is_suspended => 0, is_closed => 0,
219 account_status => 'active', last_login_at => '2026-05-11 15:33',
220 created_at => '2025-09-02', storefront_name => 'bridgeworks.co' },
221 { id => '641', email => 'support@repricer.com', display_name => 'Support Bot',
222 plan_tier => 'Pro', is_admin_flag => 1, is_suspended => 0, is_closed => 0,
223 account_status => 'active', last_login_at => '2026-05-12 11:02',
224 created_at => '2024-03-01', storefront_name => '-' },
225 { id => '312', email => 'kicked@example.com', display_name => 'Suspended Seller',
226 plan_tier => 'Pro', is_admin_flag => 0, is_suspended => 1, is_closed => 0,
227 account_status => 'suspended', last_login_at => '2026-04-28 03:11',
228 created_at => '2024-11-22', storefront_name => 'banned-store' },
229 { id => '204', email => 'old.account@example.com', display_name => 'Closed Account',
230 plan_tier => 'Pro', is_admin_flag => 0, is_suspended => 0, is_closed => 1,
231 account_status => 'closed', last_login_at => '2025-12-30 12:00',
232 created_at => '2024-06-15', storefront_name => '-' },
233 ];
234 $tvars->{has_results} = 1;
235 $tvars->{result_count} = scalar @{ $tvars->{users} };
236 $tvars->{has_query} = 0;
237 $tvars->{q} = '';
238}
239
240print "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";
241
242my $body = join('', $tfile->template('repricer_admin.html', $tvars, $userinfo));
243
244$wrap->render({
245 userinfo => $userinfo,
246 page_key => 'admin',
247 title => 'Admin Console',
248 body => $body,
249 extra_js => ['/assets/javascript/graphs.js'],
250});
251
252sub _scalar {
253 my ($db, $dbh, $sql, $key) = @_;
254 my $r = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__);
255 return ($r && defined $r->{$key}) ? $r->{$key} : 0;
256}
257
258sub _esc {
259 my $s = shift;
260 return '' unless defined $s;
261 $s =~ s/&/&amp;/g;
262 $s =~ s/</&lt;/g;
263 $s =~ s/>/&gt;/g;
264 $s =~ s/"/&quot;/g;
265 return $s;
266}