added on local at 2026-07-01 13:57:37
| 1 | package MODS::AffSoft::Wrapper; | |
| 2 | #====================================================================== | |
| 3 | # AffSoft — page wrapper. | |
| 4 | # | |
| 5 | # Renders the sidebar + topbar shell around a page's main content. | |
| 6 | # Uses MODS::Template for the shell template (affsoft_wrapper.html | |
| 7 | # for logged-in pages, affsoft_marketing.html for logged-out). | |
| 8 | # | |
| 9 | # Caller pattern: | |
| 10 | # | |
| 11 | # my $body = qq~ ...page body html... ~; | |
| 12 | # $wrap->render({ | |
| 13 | # userinfo => $userinfo, # required for logged-in shell | |
| 14 | # page_key => 'dashboard', # marks the active sidebar item | |
| 15 | # title => 'Dashboard', | |
| 16 | # body => $body, | |
| 17 | # extra_js => ['/assets/javascript/graphs.js'], # optional | |
| 18 | # }); | |
| 19 | #====================================================================== | |
| 20 | ||
| 21 | use strict; | |
| 22 | use warnings; | |
| 23 | ||
| 24 | use MODS::Template; | |
| 25 | use MODS::DBConnect; | |
| 26 | use MODS::AffSoft::Config; | |
| 27 | ||
| 28 | my $tfile = MODS::Template->new; | |
| 29 | my $config = MODS::AffSoft::Config->new; | |
| 30 | my $db = MODS::DBConnect->new; | |
| 31 | ||
| 32 | # Sidebar nav: section headers + entries. Each entry has a key | |
| 33 | # matched against $page_key to set "active", an icon name (resolved | |
| 34 | # to inline SVG below), label, href, and optional badge. | |
| 35 | my @SIDEBAR = ( | |
| 36 | # Sidebar mirrors the profile dropdown in affsoft_wrapper.html -- | |
| 37 | # same sections, same wording, same hrefs, same ordering so the | |
| 38 | # two menus read as one family. Order: Studio > Insights > Account | |
| 39 | # > Admin (admin-only). Admin section contains every platform-wide | |
| 40 | # control surface. | |
| 41 | # | |
| 42 | # Per-row gating attrs (all optional): | |
| 43 | # feature = permission_features.slug -- hide row from team | |
| 44 | # members who lack the slug. Owners always pass. | |
| 45 | # owner_only = 1 -- only the account owner (no owner_user_id) sees | |
| 46 | # this row. Team members never see it regardless of role. | |
| 47 | # Used for billing + team management + "danger zone" entries. | |
| 48 | # admin_only = 1 -- hides from non-admin users entirely. | |
| 49 | { section => 'Workspace' }, | |
| 50 | { key => 'dashboard', label => 'Dashboard', href => '/dashboard.cgi', icon => 'welcome' }, | |
| 51 | { key => 'inbox', label => 'Inbox', href => '/inbox.cgi', icon => 'messages', feature => 'view_inbox' }, | |
| 52 | { key => 'calendar', label => 'Calendar', href => '/calendar.cgi', icon => 'calendar' }, | |
| 53 | ||
| 54 | { section => 'Merchant' }, | |
| 55 | { key => 'programs', label => 'My Programs', href => '/programs.cgi', icon => 'storefront' }, | |
| 56 | { key => 'program_new', label => 'New Program', href => '/program_new.cgi', icon => 'promotions' }, | |
| 57 | { key => 'conversions', label => 'Conversions', href => '/conversions.cgi', icon => 'billing' }, | |
| 58 | { key => 'fraud_review', label => 'Fraud Review', href => '/fraud_review.cgi', icon => 'billing' }, | |
| 59 | { key => 'payouts', label => 'Payouts', href => '/payouts.cgi', icon => 'billing' }, | |
| 60 | { key => 'merchant_announcements', label => 'Announcements', href => '/merchant_announcements.cgi', icon => 'megaphone', feature => 'merchant_announce' }, | |
| 61 | ||
| 62 | { section => 'Affiliate' }, | |
| 63 | { key => 'marketplace', label => 'Affiliate Marketplace', href => '/marketplace.cgi', icon => 'storefront' }, | |
| 64 | { key => 'discover', label => 'Discover Programs', href => '/discover.cgi', icon => 'models' }, | |
| 65 | { key => 'my_links', label => 'My Links', href => '/my_links.cgi', icon => 'analytics' }, | |
| 66 | { key => 'my_earnings', label => 'My Earnings', href => '/my_earnings.cgi', icon => 'dashboard' }, | |
| 67 | ||
| 68 | # Help-section items (Tutorials, A/B & MVT tutorials, Product tour) | |
| 69 | # live in the topbar Help-button dropdown, not the sidebar -- the | |
| 70 | # dropdown is reachable from every page so help is always one click | |
| 71 | # away without taking sidebar real estate. Source: affsoft_wrapper.html. | |
| 72 | ||
| 73 | { section => 'Account' }, | |
| 74 | { key => 'my_profile', label => 'My Profile', href => '/profile.cgi', icon => 'profile' }, | |
| 75 | { key => 'billing', label => 'Billing & Plan', href => '/billing.cgi', icon => 'billing', feature => 'manage_billing' }, | |
| 76 | { key => 'team', label => 'Team Members', href => '/team.cgi', icon => 'team', owner_only => 1 }, | |
| 77 | { key => 'preferences', label => 'Preferences', href => '/preferences.cgi', icon => 'settings' }, | |
| 78 | ||
| 79 | { section => 'Admin', admin_only => 1 }, | |
| 80 | { key => 'admin_earnings', label => 'Earnings & Expenses', href => '/admin_earnings.cgi', icon => 'billing', super_admin_only => 1, tip => 'Revenue from paid invoices + manual expense ledger. Net + margin. Date range with this-month default.' }, | |
| 81 | { key => 'admin_funnels', label => 'Conversion Funnel', href => '/admin_funnels.cgi', icon => 'funnel', | |
| 82 | admin_only => 1 }, | |
| 83 | { key => 'admin_traffic', label => 'Traffic Reports', href => '/admin_traffic.cgi', icon => 'analytics', | |
| 84 | admin_only => 1, tip => 'Where visitors are coming from + globe + per-country drill.' }, | |
| 85 | { key => 'admin_messages', label => 'Support inbox', href => '/admin_messages.cgi', icon => 'messages', | |
| 86 | admin_only => 1, feature => 'admin_view_messages' }, | |
| 87 | { key => 'admin_announcements', label => 'Announcements', href => '/admin_email_announcements.cgi', icon => 'megaphone', | |
| 88 | admin_only => 1, feature => 'admin_announce', tip => 'Compose, schedule, and broadcast announcements + email blasts. Tab of Email Setup.' }, { key => 'admin_companies', label => 'All Companies', href => '/admin_companies.cgi', icon => 'building', admin_only => 1 }, | |
| 89 | ||
| 90 | { key => 'admin_users', label => 'All Users', href => '/admin_users.cgi', icon => 'admin', | |
| 91 | admin_only => 1, feature => 'admin_view_users' }, | |
| 92 | ||
| 93 | { key => 'admin_teams', label => 'Team Members', href => '/admin_teams.cgi', icon => 'team', | |
| 94 | admin_only => 1 }, | |
| 95 | { key => 'admin_billing', label => 'User Billing', href => '/admin_billing.cgi', icon => 'billing', | |
| 96 | admin_only => 1, feature => 'admin_view_billing' }, | |
| 97 | # Pricing & Promotions is one sidebar entry covering BOTH the | |
| 98 | # billing-packages catalogue and the platform-wide promotions list. | |
| 99 | # The two pages share a tab nav at the top so the user can switch | |
| 100 | # between them; both set page_key='admin_packages' so this entry | |
| 101 | # stays highlighted across either URL. | |
| 102 | { key => 'admin_packages', label => 'Pricing & Promotions', href => '/admin_packages.cgi', icon => 'billing', | |
| 103 | admin_only => 1, super_admin_only => 1 }, | |
| 104 | # Tax: rates + marketplace-facilitator collection policy. Visible | |
| 105 | # to all admins, but only super-admins can save changes (the CGI | |
| 106 | # gates the POST handlers). Separate from Pricing & Promotions | |
| 107 | # because tax is platform-wide compliance, not seller-facing | |
| 108 | # pricing. | |
| 109 | { key => 'admin_tax', label => 'Tax', href => '/admin_tax.cgi', icon => 'billing', | |
| 110 | admin_only => 1 }, | |
| 111 | { key => 'admin_seo', label => 'SEO', href => '/admin_seo.cgi', icon => 'seo', | |
| 112 | admin_only => 1, feature => 'admin_view_seo' }, | |
| 113 | # Heatmaps now lives as a tab inside /admin_visitors.cgi (no | |
| 114 | # separate sidebar entry). /admin_heatmap.cgi is still reachable | |
| 115 | # at its direct URL and renders the same tab nav with Heatmaps | |
| 116 | # highlighted. | |
| 117 | { key => 'admin_chat', label => 'Chat', href => '/admin_chat.cgi', icon => 'chat', | |
| 118 | admin_only => 1, feature => 'admin_chat' }, # Sandbox CCs for QA. Super-admin-only because a misused test card | |
| 119 | # could be assigned to a real seller's account and silently | |
| 120 | # "approve" their next charge. Staff with manage-billing get to see | |
| 121 | # tests it; only the platform owner gets to create them. | |
| 122 | { key => 'admin_test_cards', label => 'Test Credit Cards', href => '/admin_test_cards.cgi', icon => 'card', | |
| 123 | admin_only => 1, super_admin_only => 1 }, | |
| 124 | { key => 'email_setup', label => 'Email Setup', href => '/admin_emails.cgi', icon => 'messages', admin_only => 1, super_admin_only => 1 }, | |
| 125 | # Platform-owner-only: edits Stripe keys / branding / email and | |
| 126 | # other DB-backed settings. Regular admins do NOT see this row. | |
| 127 | { key => 'admin_maintenance', label => 'Maintenance', href => '/admin_maintenance.cgi', icon => 'settings', super_admin_only => 1, tip => 'Lock the site for everyone except admins; edit the message users see while locked.' }, { key => 'admin_config', label => 'Configuration', href => '/admin_config.cgi', icon => 'settings', | |
| 128 | admin_only => 1, super_admin_only => 1 }, | |
| 129 | ); | |
| 130 | ||
| 131 | sub new { | |
| 132 | my ($class) = @_; | |
| 133 | return bless({}, $class); | |
| 134 | } | |
| 135 | ||
| 136 | #---------------------------------------------------------------------- | |
| 137 | # render(\%opts) — print the full page (Content-Type already sent | |
| 138 | # by the calling .cgi). | |
| 139 | #---------------------------------------------------------------------- | |
| 140 | sub render { | |
| 141 | my ($self, $opts) = @_; | |
| 142 | $opts ||= {}; | |
| 143 | ||
| 144 | my $userinfo = $opts->{userinfo}; | |
| 145 | my $logged_in = $userinfo && $userinfo->{user_id} ? 1 : 0; | |
| 146 | ||
| 147 | my $tvars = { | |
| 148 | website_title => $config->settings('website_title'), | |
| 149 | brand_name => $config->settings('brand_name'), | |
| 150 | brand_tagline => $config->settings('brand_tagline'), | |
| 151 | assets => $config->settings('assets'), | |
| 152 | assets_css => $config->settings('assets_css'), | |
| 153 | assets_js => $config->settings('assets_js'), | |
| 154 | title => $opts->{title} || 'AffSoft', | |
| 155 | body => $opts->{body} || '', | |
| 156 | extra_js => _format_scripts($opts->{extra_js}), | |
| 157 | extra_css => _format_styles($opts->{extra_css}), | |
| 158 | # SEO meta block (built below). Renders into affsoft_wrapper.html | |
| 159 | # / affsoft_marketing.html at $meta_tags so every page emits a | |
| 160 | # consistent <meta> set without each CGI having to know. | |
| 161 | meta_tags => _build_meta_tags($opts), | |
| 162 | }; | |
| 163 | ||
| 164 | if ($logged_in) { | |
| 165 | $tvars->{user_id} = $userinfo->{user_id}; | |
| 166 | $tvars->{display_name} = $userinfo->{display_name}; | |
| 167 | $tvars->{email} = $userinfo->{email}; | |
| 168 | $tvars->{plan_tier} = ucfirst($userinfo->{plan_tier} || 'free'); | |
| 169 | $tvars->{initials} = _initials($userinfo->{display_name} || $userinfo->{email}); | |
| 170 | ||
| 171 | my $is_admin = $userinfo->{is_admin} || $userinfo->{_admin_is_admin} || 0; | |
| 172 | $tvars->{is_admin} = $is_admin; | |
| 173 | # Unread support-thread count drives the red dot on the bell icon | |
| 174 | # in the topbar. Querying once here so the topbar template + any | |
| 175 | # sidebar consumers read the same number. | |
| 176 | my $unread = _unread_messages_count($userinfo->{user_id}, $is_admin); | |
| 177 | $tvars->{unread_messages_count} = $unread; | |
| 178 | $tvars->{has_unread_messages} = $unread > 0 ? 1 : 0; | |
| 179 | # Where the topbar Messages pill lands. Admins manage every | |
| 180 | # user's support tickets via the admin console; sellers see | |
| 181 | # their own support threads at the customer-facing portal. | |
| 182 | $tvars->{messages_href} = $is_admin ? '/admin_messages.cgi' : '/support.cgi'; | |
| 183 | # Live chats awaiting an admin reply. Drives the topbar pill | |
| 184 | # so admins can't miss a visitor mid-conversation no matter | |
| 185 | # what page they're on. Pill always renders for admins; the | |
| 186 | # idle (blue) vs waiting (green/pulsing) state is gated on | |
| 187 | # count. Clicking the waiting pill jumps straight to the | |
| 188 | # longest-waiting chat so admins skip the console list. | |
| 189 | my $chat_data = $is_admin ? _pending_chats_data() : { n => 0, first_id => 0 }; | |
| 190 | $tvars->{admin_chats_pending} = $chat_data->{n}; | |
| 191 | $tvars->{has_admin_chats_pending} = $chat_data->{n} > 0 ? 1 : 0; | |
| 192 | $tvars->{admin_chats_first_id} = $chat_data->{first_id} || 0; | |
| 193 | $tvars->{admin_chats_href} = $chat_data->{first_id} | |
| 194 | ? "/admin_chat.cgi?chat=$chat_data->{first_id}" | |
| 195 | : '/admin_chat.cgi'; | |
| 196 | # In-app fraud alert: if the merchant has unreviewed suspicious | |
| 197 | # conversions, prepend a banner to body. This is the reliable | |
| 198 | # delivery channel -- email may end up in spam, but this banner | |
| 199 | # shows the moment they log in. | |
| 200 | my $fraud_n = _fraud_unreviewed_count($userinfo->{user_id}); | |
| 201 | if ($fraud_n > 0 && ($opts->{page_key} || '') ne 'fraud_review') { | |
| 202 | my $plur = $fraud_n == 1 ? '' : 's'; | |
| 203 | my $banner = qq~<div style="background:linear-gradient(90deg, rgba(239,68,68,.18), rgba(239,68,68,.08));border:1px solid rgba(239,68,68,.45);border-radius:12px;padding:14px 18px;margin:0 0 24px;display:flex;align-items:center;gap:12px;flex-wrap:wrap"><div style="background:#ef4444;color:#fff;font-weight:700;font-size:12px;padding:4px 10px;border-radius:99px">FRAUD</div><div style="flex:1;font-size:14px;color:#fecaca"><strong>$fraud_n suspicious conversion$plur</strong> on your program$plur waiting for review. Commission is on hold until you decide.</div><a href="/fraud_review.cgi" style="background:#ef4444;color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:8px 16px;border-radius:8px">Review now →</a></div>~; | |
| 204 | $tvars->{body} = $banner . $tvars->{body}; | |
| 205 | } | |
| 206 | $tvars->{sidebar} = _build_sidebar($opts->{page_key} || '', $is_admin, $unread, $userinfo, $fraud_n); | |
| 207 | $tvars->{dropdown_links} = _build_dropdown_links($is_admin, $unread, $userinfo); | |
| 208 | $tvars->{impersonation_banner} = _impersonation_banner($userinfo); | |
| 209 | ||
| 210 | # Per-user sidebar pref: applied as a class on <html> so the | |
| 211 | # final layout is in place on first paint (no flash). Guarded | |
| 212 | # against the user_settings.sidebar_collapsed column not having | |
| 213 | # been added yet -- a missing-column SELECT would 500 the page. | |
| 214 | my ($collapsed, $mobile_open) = _sidebar_pref($userinfo->{user_id}); | |
| 215 | my @cls; | |
| 216 | push @cls, 'sidebar-collapsed' if $collapsed; | |
| 217 | push @cls, 'mobile-nav-open' if $mobile_open; | |
| 218 | $tvars->{html_class} = join(' ', @cls); | |
| 219 | } | |
| 220 | ||
| 221 | # Heatmap-view mode: when /admin_heatmap.cgi loads a page in its | |
| 222 | # preview iframe it appends `heatmap_view=1` to the URL. We render | |
| 223 | # that request as if the visitor were logged OUT so the DOM | |
| 224 | # matches exactly what a real visitor saw -- otherwise the admin | |
| 225 | # session would inject the studio sidebar wrapper and every stored | |
| 226 | # selector would resolve to a different element. We also suppress | |
| 227 | # track.js so the admin's preview clicks don't pollute analytics. | |
| 228 | my $heatmap_view = ($ENV{QUERY_STRING} || '') =~ /(?:^|&)heatmap_view=1(?:&|$)/ ? 1 : 0; | |
| 229 | $tvars->{heatmap_view} = $heatmap_view; | |
| 230 | ||
| 231 | # Tutorial-embed mode: a [frame:/url] block inside a tutorial body | |
| 232 | # loads the target URL in an iframe with ?tut_embed=1 appended. We | |
| 233 | # strip the sidebar/topbar (same as heatmap_view does) so the | |
| 234 | # iframe shows just the page content, not the whole studio shell. | |
| 235 | my $tut_embed = ($ENV{QUERY_STRING} || '') =~ /(?:^|&)tut_embed=1(?:&|$)/ ? 1 : 0; | |
| 236 | $tvars->{tut_embed} = $tut_embed; | |
| 237 | ||
| 238 | my $template_file = ($logged_in && !$heatmap_view && !$tut_embed) | |
| 239 | ? 'affsoft_wrapper.html' | |
| 240 | : 'affsoft_marketing.html'; | |
| 241 | ||
| 242 | my @html = $tfile->template($template_file, $tvars, $userinfo); | |
| 243 | print "@html"; | |
| 244 | return; | |
| 245 | } | |
| 246 | ||
| 247 | #====================================================================== | |
| 248 | # Helpers | |
| 249 | #====================================================================== | |
| 250 | ||
| 251 | sub _initials { | |
| 252 | my ($name) = @_; | |
| 253 | return '?' unless $name; | |
| 254 | my @parts = split /[\s\@\.]+/, $name; | |
| 255 | my $i = ''; | |
| 256 | $i .= uc(substr($parts[0], 0, 1)) if $parts[0]; | |
| 257 | $i .= uc(substr($parts[1], 0, 1)) if $parts[1]; | |
| 258 | return $i || uc(substr($name, 0, 1)); | |
| 259 | } | |
| 260 | ||
| 261 | sub _format_scripts { | |
| 262 | my ($list) = @_; | |
| 263 | return '' unless $list && ref($list) eq 'ARRAY'; | |
| 264 | return join("\n", map { qq~<script src="$_"></script>~ } @$list); | |
| 265 | } | |
| 266 | ||
| 267 | sub _format_styles { | |
| 268 | my ($list) = @_; | |
| 269 | return '' unless $list && ref($list) eq 'ARRAY'; | |
| 270 | return join("\n", map { qq~<link rel="stylesheet" href="$_">~ } @$list); | |
| 271 | } | |
| 272 | ||
| 273 | # Cache the column-existence check so we don't run information_schema | |
| 274 | # on every page load. Under plain CGI this only saves work within a | |
| 275 | # single request; under FCGI/mod_perl it survives across requests. | |
| 276 | our $_sidebar_col_checked; | |
| 277 | our $_sidebar_col_exists; | |
| 278 | ||
| 279 | # Returns ($collapsed, $mobile_open) for the user's sidebar prefs. | |
| 280 | # Both default to 0 (expanded desktop, closed mobile drawer) when no | |
| 281 | # row exists or the migration columns are missing. sidebar_mobile_open | |
| 282 | # was added in a later migration so we guard for its absence. | |
| 283 | sub _sidebar_pref { | |
| 284 | my ($user_id) = @_; | |
| 285 | return (0, 0) unless $user_id; | |
| 286 | $user_id =~ s/[^0-9]//g; | |
| 287 | return (0, 0) unless $user_id; | |
| 288 | ||
| 289 | my $DB = $config->settings('database_name'); | |
| 290 | my $dbh = $db->db_connect(); | |
| 291 | return (0, 0) unless $dbh; | |
| 292 | ||
| 293 | unless ($_sidebar_col_checked) { | |
| 294 | my $r = $db->db_readwrite($dbh, qq~ | |
| 295 | SELECT COUNT(*) AS n FROM information_schema.columns | |
| 296 | WHERE table_schema='$DB' | |
| 297 | AND table_name='user_settings' | |
| 298 | AND column_name='sidebar_collapsed' | |
| 299 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 300 | $_sidebar_col_exists = ($r && $r->{n}) ? 1 : 0; | |
| 301 | $_sidebar_col_checked = 1; | |
| 302 | } | |
| 303 | ||
| 304 | unless ($_sidebar_col_exists) { | |
| 305 | $db->db_disconnect($dbh); | |
| 306 | return (0, 0); | |
| 307 | } | |
| 308 | ||
| 309 | # Probe the optional mobile column (cheap, cached per-request). | |
| 310 | my $mob_check = $db->db_readwrite($dbh, qq~ | |
| 311 | SELECT COUNT(*) AS n FROM information_schema.columns | |
| 312 | WHERE table_schema='$DB' | |
| 313 | AND table_name='user_settings' | |
| 314 | AND column_name='sidebar_mobile_open' | |
| 315 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 316 | my $mob_extra = ($mob_check && $mob_check->{n}) ? ', sidebar_mobile_open' : ''; | |
| 317 | ||
| 318 | my $r = $db->db_readwrite($dbh, qq~ | |
| 319 | SELECT sidebar_collapsed$mob_extra | |
| 320 | FROM `${DB}`.user_settings | |
| 321 | WHERE user_id='$user_id' | |
| 322 | LIMIT 1 | |
| 323 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 324 | $db->db_disconnect($dbh); | |
| 325 | ||
| 326 | my $collapsed = ($r && $r->{sidebar_collapsed}) ? 1 : 0; | |
| 327 | my $mobile_open = ($r && $r->{sidebar_mobile_open}) ? 1 : 0; | |
| 328 | return ($collapsed, $mobile_open); | |
| 329 | } | |
| 330 | ||
| 331 | sub _build_sidebar { | |
| 332 | my ($active_key, $is_admin, $unread_messages, $userinfo, $fraud_count) = @_; | |
| 333 | $unread_messages ||= 0; | |
| 334 | $fraud_count ||= 0; | |
| 335 | my $html = ''; | |
| 336 | my $pending_section; # buffered header; emitted when a child row gets through | |
| 337 | my $is_team_member = ($userinfo && $userinfo->{owner_user_id}) ? 1 : 0; | |
| 338 | ||
| 339 | foreach my $row (@SIDEBAR) { | |
| 340 | # Admin entries (links AND section headers) are gated -- sellers | |
| 341 | # must never see them. Hide the rows entirely (not just disable) | |
| 342 | # so the page keys do not leak in HTML for non-admins to find. | |
| 343 | next if $row->{admin_only} && !$is_admin; | |
| 344 | ||
| 345 | if ($row->{section}) { | |
| 346 | # Buffer the header; we only commit it once a visible child | |
| 347 | # follows. Prevents dangling "Reporting" labels when every | |
| 348 | # child is hidden by role. | |
| 349 | $pending_section = $row->{section}; | |
| 350 | next; | |
| 351 | } | |
| 352 | ||
| 353 | # owner_only rows are visible only to the actual account owner | |
| 354 | # (billing, team management, ...). Team members and impersonated | |
| 355 | # admins are blocked here so the menu cannot leak them. | |
| 356 | next if $row->{owner_only} && $is_team_member; | |
| 357 | ||
| 358 | # super_admin_only rows are visible only to the platform owner | |
| 359 | # (users.is_super_admin=1). Regular admins managing users / billing | |
| 360 | # still see all the other admin rows but never this one. | |
| 361 | if ($row->{super_admin_only}) { | |
| 362 | require MODS::AffSoft::Permissions; | |
| 363 | next unless MODS::AffSoft::Permissions->new->is_super_admin($userinfo); | |
| 364 | } | |
| 365 | ||
| 366 | # feature gate: skip if user lacks the slug. _has_user_feature | |
| 367 | # short-circuits for owners (NULL permission_groups_id). | |
| 368 | next if $row->{feature} && !_has_user_feature($userinfo, $row->{feature}); | |
| 369 | ||
| 370 | if (defined $pending_section) { | |
| 371 | $html .= qq~<div class="sidebar-section-label">$pending_section</div>\n~; | |
| 372 | $pending_section = undef; | |
| 373 | } | |
| 374 | ||
| 375 | my $cls = ($row->{key} eq $active_key) ? 'sidebar-link active' : 'sidebar-link'; | |
| 376 | my $svg = _icon($row->{icon}); | |
| 377 | my $badge = $row->{badge} ? qq~<span class="badge">$row->{badge}</span>~ : ''; | |
| 378 | my $unread_dot = ''; | |
| 379 | # Fraud Review: render count badge (red) when there are unreviewed flags | |
| 380 | my $fraud_badge = ''; | |
| 381 | if ($row->{key} && $row->{key} eq 'fraud_review' && $fraud_count > 0) { | |
| 382 | $fraud_badge = qq~<span style="background:#ef4444;color:#fff;font-size:10px;font-weight:700;padding:2px 7px;border-radius:99px;margin-left:auto" title="$fraud_count unreviewed">$fraud_count</span>~; | |
| 383 | } | |
| 384 | $html .= qq~<a class="$cls" href="$row->{href}" title="$row->{label}">$svg<span>$row->{label}</span>$unread_dot$badge$fraud_badge</a>\n~; | |
| 385 | } | |
| 386 | return $html; | |
| 387 | } | |
| 388 | ||
| 389 | # Build the profile-dropdown links from the SAME @SIDEBAR data the | |
| 390 | # left sidebar uses. This guarantees the two menus stay in lockstep | |
| 391 | # whenever items are added / renamed / removed -- never hand-edit the | |
| 392 | # dropdown HTML directly. Section headers become <div class="profile- | |
| 393 | # dropdown-section">, divider rows are inserted before each section | |
| 394 | # (except the first), and admin-only items get the .profile-admin | |
| 395 | # class so they render in the admin red/orange accent like before. | |
| 396 | sub _build_dropdown_links { | |
| 397 | my ($is_admin, $unread_messages, $userinfo) = @_; | |
| 398 | $unread_messages ||= 0; | |
| 399 | my $html = ''; | |
| 400 | my $emitted_first_section = 0; | |
| 401 | my $pending_section; | |
| 402 | my $pending_divider = 0; | |
| 403 | my $is_team_member = ($userinfo && $userinfo->{owner_user_id}) ? 1 : 0; | |
| 404 | ||
| 405 | foreach my $row (@SIDEBAR) { | |
| 406 | next if $row->{admin_only} && !$is_admin; | |
| 407 | ||
| 408 | if ($row->{section}) { | |
| 409 | # Buffer the header + its divider; emit only when a child | |
| 410 | # row gets through. Otherwise the dropdown shows lone | |
| 411 | # "Reporting" headers with nothing under them. | |
| 412 | $pending_section = $row->{section}; | |
| 413 | $pending_divider = $emitted_first_section ? 1 : 0; | |
| 414 | next; | |
| 415 | } | |
| 416 | ||
| 417 | next if $row->{owner_only} && $is_team_member; | |
| 418 | if ($row->{super_admin_only}) { | |
| 419 | require MODS::AffSoft::Permissions; | |
| 420 | next unless MODS::AffSoft::Permissions->new->is_super_admin($userinfo); | |
| 421 | } | |
| 422 | next if $row->{feature} && !_has_user_feature($userinfo, $row->{feature}); | |
| 423 | ||
| 424 | if (defined $pending_section) { | |
| 425 | $html .= qq~<div class="profile-dropdown-divider"></div>\n~ if $pending_divider; | |
| 426 | $html .= qq~<div class="profile-dropdown-section">$pending_section</div>\n~; | |
| 427 | $emitted_first_section = 1; | |
| 428 | $pending_section = undef; | |
| 429 | $pending_divider = 0; | |
| 430 | } | |
| 431 | ||
| 432 | my $svg = _icon($row->{icon}); | |
| 433 | my $admin_c = $row->{admin_only} ? ' class="profile-admin"' : ''; | |
| 434 | $html .= qq~<a href="$row->{href}"$admin_c><span class="dropdown-icon">$svg</span> $row->{label}</a>\n~; | |
| 435 | } | |
| 436 | return $html; | |
| 437 | } | |
| 438 | ||
| 439 | # Thin wrapper around MODS::AffSoft::Permissions->has_feature so the | |
| 440 | # sidebar builders stay decoupled from the module loader. Loaded once. | |
| 441 | my $_perm_singleton; | |
| 442 | sub _has_user_feature { | |
| 443 | my ($userinfo, $slug) = @_; | |
| 444 | return 1 unless $slug; | |
| 445 | return 0 unless $userinfo; | |
| 446 | # Owner / unconstrained: skip the DB trip | |
| 447 | return 1 if !defined($userinfo->{permission_groups_id}) || $userinfo->{permission_groups_id} eq ''; | |
| 448 | if (!$_perm_singleton) { | |
| 449 | return 1 unless eval { require MODS::AffSoft::Permissions; 1 }; | |
| 450 | $_perm_singleton = MODS::AffSoft::Permissions->new; | |
| 451 | } | |
| 452 | return $_perm_singleton->has_feature($userinfo, $slug); | |
| 453 | } | |
| 454 | ||
| 455 | # Count unread support threads driving the topbar Messages pill. | |
| 456 | # Admins see threads where admin_unread > 0 (any user needs reply); | |
| 457 | # regular users see their own threads where customer_unread > 0 | |
| 458 | # (admin replied to them and they haven't read it yet). Guarded | |
| 459 | # against support_threads not existing yet on early-adopter installs. | |
| 460 | sub _unread_messages_count { | |
| 461 | my ($uid, $is_admin) = @_; | |
| 462 | return 0 unless $uid; | |
| 463 | $uid =~ s/[^0-9]//g; | |
| 464 | return 0 unless $uid; | |
| 465 | ||
| 466 | require MODS::DBConnect; | |
| 467 | my $db = MODS::DBConnect->new; | |
| 468 | my $DB = $config->settings('database_name'); | |
| 469 | my $dbh = $db->db_connect(); | |
| 470 | return 0 unless $dbh; | |
| 471 | ||
| 472 | my $total = 0; | |
| 473 | ||
| 474 | # Support tickets / help threads. Admins count threads where | |
| 475 | # admin_unread > 0 (anyone's ticket needing reply); regular | |
| 476 | # users count their own threads where customer_unread > 0 (a | |
| 477 | # reply from admin they haven't read yet). This is what makes | |
| 478 | # a fresh help request light up the Messages pill. | |
| 479 | my $have_st = $db->db_readwrite($dbh, qq~ | |
| 480 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 481 | WHERE table_schema='$DB' AND table_name='support_threads' | |
| 482 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 483 | if ($have_st && $have_st->{n}) { | |
| 484 | my $sql = $is_admin | |
| 485 | ? qq~SELECT COUNT(*) AS n FROM `${DB}`.support_threads WHERE admin_unread > 0~ | |
| 486 | : qq~SELECT COUNT(*) AS n FROM `${DB}`.support_threads WHERE customer_user_id='$uid' AND customer_unread > 0~; | |
| 487 | my $r = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); | |
| 488 | $total += ($r && $r->{n}) ? $r->{n} : 0; | |
| 489 | } | |
| 490 | ||
| 491 | # DM inbox unreads (added with messaging v1). Counts THREADS, not | |
| 492 | # individual messages, so the badge matches the inbox-sidebar UX | |
| 493 | # (one bumped row per fresh conversation, not per message). | |
| 494 | my $have_dm = $db->db_readwrite($dbh, qq~ | |
| 495 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 496 | WHERE table_schema='$DB' AND table_name='dm_thread_participants' | |
| 497 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 498 | if ($have_dm && $have_dm->{n}) { | |
| 499 | my $r = $db->db_readwrite($dbh, qq~ | |
| 500 | SELECT COUNT(DISTINCT p.thread_id) AS n | |
| 501 | FROM `${DB}`.dm_thread_participants p | |
| 502 | JOIN `${DB}`.dm_messages m | |
| 503 | ON m.thread_id = p.thread_id | |
| 504 | AND m.deleted_at IS NULL | |
| 505 | AND m.sender_user_id <> p.user_id | |
| 506 | AND (p.last_read_at IS NULL OR m.created_at > p.last_read_at) | |
| 507 | WHERE p.user_id='$uid' AND p.removed_at IS NULL | |
| 508 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 509 | $total += ($r && $r->{n}) ? $r->{n} : 0; | |
| 510 | } | |
| 511 | ||
| 512 | $db->db_disconnect($dbh); | |
| 513 | return $total; | |
| 514 | } | |
| 515 | ||
| 516 | # Open visitor chats with at least one unread message for the admin. | |
| 517 | # Drives both the topbar pill state (idle/waiting) and its click | |
| 518 | # target (jump direct to the longest-waiting chat). Table existence | |
| 519 | # is guarded via information_schema so pre-migration deploys can't | |
| 520 | # 500 the wrapper. Returns { n => count, first_id => oldest_pending_chat_id }. | |
| 521 | sub _pending_chats_data { | |
| 522 | require MODS::DBConnect; | |
| 523 | my $db = MODS::DBConnect->new; | |
| 524 | my $DB = $config->settings('database_name'); | |
| 525 | my $dbh = $db->db_connect(); | |
| 526 | return { n => 0, first_id => 0 } unless $dbh; | |
| 527 | my $have = $db->db_readwrite($dbh, qq~ | |
| 528 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 529 | WHERE table_schema='$DB' AND table_name='support_chats' | |
| 530 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 531 | unless ($have && $have->{n}) { | |
| 532 | $db->db_disconnect($dbh); | |
| 533 | return { n => 0, first_id => 0 }; | |
| 534 | } | |
| 535 | # ORDER BY last_message_at ASC -- oldest unread first. Sending an | |
| 536 | # admin to the chat that has been waiting longest is the most | |
| 537 | # courteous default; if they want a different one they can use | |
| 538 | # the console list as normal. | |
| 539 | my @rows = $db->db_readwrite_multiple($dbh, qq~ | |
| 540 | SELECT id FROM `${DB}`.support_chats | |
| 541 | WHERE status = 'open' | |
| 542 | AND unread_for_admin > 0 | |
| 543 | ORDER BY last_message_at ASC | |
| 544 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 545 | $db->db_disconnect($dbh); | |
| 546 | return { | |
| 547 | n => scalar(@rows), | |
| 548 | first_id => scalar(@rows) ? ($rows[0]->{id} + 0) : 0, | |
| 549 | }; | |
| 550 | } | |
| 551 | ||
| 552 | # Banner shown across the top of every authenticated page WHILE an | |
| 553 | # admin is impersonating another user. The "Stop" link is a POST to | |
| 554 | # /admin_action.cgi (form, not GET) so accidental link-followers can't | |
| 555 | # end an impersonation by sharing a URL. | |
| 556 | sub _impersonation_banner { | |
| 557 | my ($userinfo) = @_; | |
| 558 | return '' unless $userinfo && $userinfo->{_impersonating}; | |
| 559 | ||
| 560 | my $target_email = _html_escape($userinfo->{email} || ''); | |
| 561 | my $target_name = _html_escape($userinfo->{display_name} || $target_email); | |
| 562 | my $admin_email = _html_escape($userinfo->{_admin_email} || ''); | |
| 563 | ||
| 564 | return qq~ | |
| 565 | <div class="impersonation-banner"> | |
| 566 | <div class="impersonation-banner-msg"> | |
| 567 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/></svg> | |
| 568 | <span><strong>Acting as</strong> $target_name <$target_email> · admin: $admin_email</span> | |
| 569 | </div> | |
| 570 | <form method="POST" action="/admin_action.cgi" class="impersonation-banner-form"> | |
| 571 | <input type="hidden" name="act" value="stop_impersonation"> | |
| 572 | <button type="submit" class="impersonation-banner-stop">Stop & return to admin</button> | |
| 573 | </form> | |
| 574 | </div> | |
| 575 | ~; | |
| 576 | } | |
| 577 | ||
| 578 | sub _html_escape { | |
| 579 | my ($s) = @_; | |
| 580 | return '' unless defined $s; | |
| 581 | $s =~ s/&/&/g; | |
| 582 | $s =~ s/</</g; | |
| 583 | $s =~ s/>/>/g; | |
| 584 | $s =~ s/"/"/g; | |
| 585 | $s =~ s/'/'/g; | |
| 586 | return $s; | |
| 587 | } | |
| 588 | ||
| 589 | # Inline SVG icon library — keeps the wrapper independent of any | |
| 590 | # external icon font / file. All icons are 24x24, stroke-based. | |
| 591 | my %ICONS = ( | |
| 592 | dashboard => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>', | |
| 593 | models => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z"/><path d="m3 7 9 5 9-5"/><path d="M12 22V12"/></svg>', | |
| 594 | upload => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>', | |
| 595 | storefront => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m3 9 1-5h16l1 5"/><path d="M5 9v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9"/><path d="M9 21V13h6v8"/></svg>', | |
| 596 | audience => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>', | |
| 597 | messages => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>', | |
| 598 | funnel => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/></svg>', | |
| 599 | analytics => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="m7 14 4-4 4 4 5-5"/></svg>', | |
| 600 | optimize => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>', | |
| 601 | settings => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33 1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>', | |
| 602 | building => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="2" width="18" height="20" rx="2"/><path d="M9 22V12h6v10"/><path d="M9 6h.01"/><path d="M9 10h.01"/><path d="M15 6h.01"/><path d="M15 10h.01"/></svg>', | |
| 603 | calendar => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>', | |
| 604 | clock => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>', | |
| 605 | admin => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/></svg>', | |
| 606 | billing => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/></svg>', | |
| 607 | promotions => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>', | |
| 608 | profile => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>', | |
| 609 | seo => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/><path d="M7 11h8"/><path d="M11 7v8"/></svg>', | |
| 610 | visitors => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z"/><circle cx="12" cy="12" r="3"/></svg>', | |
| 611 | chat => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>', | |
| 612 | welcome => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12 12 3l9 9"/><path d="M5 10v10a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V10"/></svg>', | |
| 613 | heatmap => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="8" cy="9" r="3"/><circle cx="16" cy="14" r="4"/><circle cx="10" cy="17" r="2"/><rect x="2" y="2" width="20" height="20" rx="2"/></svg>', | |
| 614 | team => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>', | |
| 615 | card => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/><line x1="14" y1="15" x2="18" y2="15"/></svg>', | |
| 616 | megaphone => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 11v2a1 1 0 0 0 1 1h3l5 4V6l-5 4H4a1 1 0 0 0-1 1Z"/><path d="M16 8a4 4 0 0 1 0 8"/><path d="M19 5a8 8 0 0 1 0 14"/></svg>', | |
| 617 | ); | |
| 618 | ||
| 619 | sub _icon { | |
| 620 | my ($name) = @_; | |
| 621 | return $ICONS{$name} || ''; | |
| 622 | } | |
| 623 | ||
| 624 | # Build the <meta> block injected into every page's <head>. Pulls | |
| 625 | # platform defaults from platform_settings, and if the caller passed | |
| 626 | # opts->{storefront} (e.g. /store.cgi), layers the per-storefront SEO | |
| 627 | # row over the top. Cached per-request via $_meta_cache so repeated | |
| 628 | # calls in one CGI don't re-query. | |
| 629 | our $_meta_cache; | |
| 630 | sub _build_meta_tags { | |
| 631 | my ($opts) = @_; | |
| 632 | return '' unless $opts; | |
| 633 | ||
| 634 | require MODS::DBConnect; | |
| 635 | require MODS::AffSoft::SEO; | |
| 636 | my $db = MODS::DBConnect->new; | |
| 637 | my $seo = MODS::AffSoft::SEO->new; | |
| 638 | my $DB = $config->settings('database_name'); | |
| 639 | my $dbh = $db->db_connect(); | |
| 640 | return '' unless $dbh; | |
| 641 | ||
| 642 | my $platform = $seo->get_platform_settings($db, $dbh, $DB); | |
| 643 | my $store = $opts->{storefront} || {}; | |
| 644 | ||
| 645 | my $tags = $seo->render_tags( | |
| 646 | platform => $platform, | |
| 647 | storefront => $store, | |
| 648 | page_title => $opts->{title} || '', | |
| 649 | request_url => $opts->{canonical_url} || '', | |
| 650 | ); | |
| 651 | ||
| 652 | $db->db_disconnect($dbh); | |
| 653 | return $tags; | |
| 654 | } | |
| 655 | ||
| 656 | # Unreviewed suspicious-fraud-flag count for THIS merchant's programs. | |
| 657 | # Drives the in-app banner that appears on every page (except fraud_review | |
| 658 | # itself). Reliable signal independent of email deliverability. | |
| 659 | sub _fraud_unreviewed_count { | |
| 660 | my $uid = shift; | |
| 661 | return 0 unless $uid && $uid > 0; | |
| 662 | my $db = MODS::DBConnect->new; | |
| 663 | my $DB = $config->settings('database_name'); | |
| 664 | my $dbh = $db->db_connect(); | |
| 665 | return 0 unless $dbh; | |
| 666 | my $row = $db->db_readwrite($dbh, qq~ | |
| 667 | SELECT COUNT(*) AS n | |
| 668 | FROM ${DB}.affiliate_conversions c | |
| 669 | JOIN ${DB}.affiliate_programs p ON p.id = c.program_id | |
| 670 | WHERE p.merchant_user_id = $uid | |
| 671 | AND c.fraud_review_status = 'suspicious' | |
| 672 | AND c.reviewed_at IS NULL | |
| 673 | ~, $0, __LINE__); | |
| 674 | $db->db_disconnect($dbh); | |
| 675 | return ($row && $row->{n}) ? $row->{n} : 0; | |
| 676 | } | |
| 677 | ||
| 678 | 1; |