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

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

added on local at 2026-07-01 15:03:01

Added
+252
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to e2d7d94bf06c
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 -- Global search
4#
5# Topbar search input. Scoped to the signed-in user via owner_user_id.
6#
7# Searches the natural domain tables:
8# - contacts (first/last name, emails, phone, mobile, title)
9# - companies (name, domain, website, email, industry, description)
10# - deals (title, description)
11# - tasks (title, description)
12# Plus a settings-shortcut hitlist for fast keyboard nav.
13#======================================================================
14use strict;
15use warnings;
16
17use lib '/var/www/vhosts/3dshawn.com/crm.3dshawn.com';
18use CGI;
19use MODS::Login;
20use MODS::DBConnect;
21use MODS::ContactForge::Config;
22use MODS::ContactForge::Wrapper;
23
24my $q = CGI->new;
25my $form = $q->Vars;
26my $auth = MODS::Login->new;
27my $wrap = MODS::ContactForge::Wrapper->new;
28my $db = MODS::DBConnect->new;
29my $cfg = MODS::ContactForge::Config->new;
30my $DB = $cfg->settings('database_name');
31
32$|=1;
33my $userinfo = $auth->login_verify();
34if (!$userinfo) {
35 print "Status: 302 Found\nLocation: /login.cgi\n\n";
36 exit;
37}
38my $uid = $userinfo->{user_id};
39$uid =~ s/[^0-9]//g;
40
41my $raw_q = defined $form->{q} ? $form->{q} : '';
42my $clean = $raw_q;
43$clean =~ s/[^A-Za-z0-9._@\s\-]//g;
44$clean =~ s/^\s+|\s+$//g;
45my @terms = grep { length($_) > 0 } split(/[\s\-_]+/, $clean);
46
47sub _h {
48 my $s = shift; $s //= '';
49 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g;
50 return $s;
51}
52
53sub _term_clause {
54 my (@cols) = @_;
55 return '(1=0)' unless @terms;
56 my @groups;
57 foreach my $col (@cols) {
58 my @parts;
59 foreach my $t (@terms) {
60 my $sql_t = $t; $sql_t =~ s/'/''/g;
61 push @parts, "$col LIKE '%$sql_t%'";
62 }
63 push @groups, '(' . join(' AND ', @parts) . ')';
64 }
65 return '(' . join(' OR ', @groups) . ')';
66}
67
68my $dbh = $db->db_connect();
69
70my (@contacts, @companies, @deals, @tasks);
71if (@terms) {
72 my $c_where = _term_clause(qw(first_name last_name email secondary_email phone mobile title));
73 @contacts = $db->db_readwrite_multiple($dbh, qq~
74 SELECT id, first_name, last_name, email, phone, title, company_id, avatar_url, lifecycle_stage
75 FROM `${DB}`.contacts
76 WHERE owner_user_id='$uid' AND $c_where
77 ORDER BY updated_at DESC LIMIT 25
78 ~, $ENV{SCRIPT_NAME}, __LINE__);
79
80 my $co_where = _term_clause(qw(name domain website email industry description));
81 @companies = $db->db_readwrite_multiple($dbh, qq~
82 SELECT id, name, domain, website, industry, lifecycle_stage
83 FROM `${DB}`.companies
84 WHERE owner_user_id='$uid' AND is_archived=0 AND $co_where
85 ORDER BY updated_at DESC LIMIT 25
86 ~, $ENV{SCRIPT_NAME}, __LINE__);
87
88 my $d_where = _term_clause(qw(title description));
89 @deals = $db->db_readwrite_multiple($dbh, qq~
90 SELECT id, title, description, amount_cents, currency, priority, contact_id, company_id
91 FROM `${DB}`.deals
92 WHERE owner_user_id='$uid' AND is_archived=0 AND $d_where
93 ORDER BY updated_at DESC LIMIT 25
94 ~, $ENV{SCRIPT_NAME}, __LINE__);
95
96 my $t_where = _term_clause(qw(title description));
97 @tasks = $db->db_readwrite_multiple($dbh, qq~
98 SELECT id, title, description, status, priority, due_at
99 FROM `${DB}`.tasks
100 WHERE (owner_user_id='$uid' OR assigned_to_user_id='$uid') AND $t_where
101 ORDER BY due_at IS NULL, due_at ASC, updated_at DESC LIMIT 25
102 ~, $ENV{SCRIPT_NAME}, __LINE__);
103}
104
105my @settings_hits;
106if (length $clean) {
107 my @candidates = (
108 { label => 'Contacts', href => '/contacts.cgi', keywords => 'contact people person lead customer' },
109 { label => 'Companies', href => '/companies.cgi', keywords => 'company account organization business' },
110 { label => 'Deals', href => '/deals.cgi', keywords => 'deal pipeline opportunity revenue' },
111 { label => 'Tasks', href => '/tasks.cgi', keywords => 'task todo reminder followup' },
112 { label => 'Campaigns', href => '/campaigns.cgi', keywords => 'campaign email outbound marketing' },
113 { label => 'Automations', href => '/automations.cgi', keywords => 'automation workflow trigger sequence' },
114 { label => 'Segments', href => '/segments.cgi', keywords => 'segment list filter audience' },
115 { label => 'Tags', href => '/tags.cgi', keywords => 'tag label group category' },
116 { label => 'Reports', href => '/reports.cgi', keywords => 'report analytics dashboard insight' },
117 { label => 'Account profile', href => '/profile.cgi', keywords => 'account profile name email' },
118 { label => 'Billing & Plan', href => '/billing.cgi', keywords => 'billing subscription invoice card payment plan' },
119 { label => 'API keys', href => '/api_keys.cgi', keywords => 'api key token webhook integration' },
120 );
121 my $needle = lc $clean;
122 foreach my $c (@candidates) {
123 my $hay = lc($c->{label} . ' ' . $c->{keywords});
124 push @settings_hits, $c if index($hay, $needle) >= 0;
125 }
126}
127
128# Diagnostic counts for empty state
129my $total_contacts = 0; my $total_deals = 0;
130{
131 my $r = $db->db_readwrite($dbh, qq~SELECT COUNT(*) AS n FROM `${DB}`.contacts WHERE owner_user_id='$uid'~, $ENV{SCRIPT_NAME}, __LINE__);
132 $total_contacts = ($r && $r->{n}) ? $r->{n} : 0;
133}
134{
135 my $r = $db->db_readwrite($dbh, qq~SELECT COUNT(*) AS n FROM `${DB}`.deals WHERE owner_user_id='$uid'~, $ENV{SCRIPT_NAME}, __LINE__);
136 $total_deals = ($r && $r->{n}) ? $r->{n} : 0;
137}
138$db->db_disconnect($dbh);
139
140my $q_h = _h($clean);
141my $accent = '#22d3ee'; # CF cyan -- matches the CF brand chrome
142my $body = '';
143
144$body .= qq~
145<div class="page-pad" style="padding:32px">
146 <div style="color:$accent;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:600;margin-bottom:14px">Search</div>
147 <h1 style="margin:0 0 18px;font-size:32px;letter-spacing:-.02em">~ . (length($clean) ? "Results for &ldquo;$q_h&rdquo;" : "Search") . qq~</h1>
148 <form method="GET" action="/search.cgi" style="margin-bottom:24px">
149 <input type="text" name="q" value="$q_h" placeholder="Search contacts, companies, deals, tasks..." autocomplete="off" autofocus
150 style="width:100%;max-width:560px;padding:10px 14px;font-size:14px;background:#1a1a1f;color:#fff;border:1px solid #333;border-radius:6px">
151 </form>
152~;
153
154if (!length $clean) {
155 $body .= qq~<div style="color:#888;padding:20px;background:rgba(255,255,255,.03);border-radius:6px">Type a search to find contacts, companies, deals, or tasks.</div>~;
156} else {
157 my $total = scalar(@contacts) + scalar(@companies) + scalar(@deals) + scalar(@tasks) + scalar(@settings_hits);
158 if (!$total) {
159 $body .= qq~
160<div style="color:#aaa;padding:24px;background:rgba(255,255,255,.03);border-radius:6px;line-height:1.6">
161 <strong style="color:#fff">No matches for &ldquo;$q_h&rdquo;.</strong><br>
162 You have <strong style="color:#fff">$total_contacts</strong> contact(s) and <strong style="color:#fff">$total_deals</strong> deal(s). Try a shorter or different word.
163</div>~;
164 }
165
166 if (@contacts) {
167 my $n = scalar @contacts;
168 $body .= qq~<div style="margin-bottom:28px"><h2 style="font-size:18px;color:$accent;margin:0 0 12px">Contacts ($n)</h2>~;
169 foreach my $c (@contacts) {
170 my $name = _h(join(' ', grep { defined && length } $c->{first_name}, $c->{last_name})) || '(no name)';
171 my $email = _h($c->{email} || '');
172 my $phone = _h($c->{phone} || '');
173 my $title = _h($c->{title} || '');
174 my $av = _h($c->{avatar_url} || '');
175 my $av_html = $av ? qq~<img src="$av" style="width:32px;height:32px;border-radius:50%;object-fit:cover">~
176 : qq~<div style="width:32px;height:32px;border-radius:50%;background:$accent;color:#000;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px">~ . uc(substr($c->{first_name} // '?', 0, 1)) . qq~</div>~;
177 $body .= qq~<a href="/contact.cgi?id=$c->{id}" style="display:flex;gap:12px;align-items:center;padding:10px 14px;margin-bottom:6px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:6px;text-decoration:none;color:#fff">
178 $av_html
179 <div style="flex:1;min-width:0">
180 <div style="font-weight:600;font-size:14px">$name</div>
181 <div style="color:#888;font-size:12px;margin-top:2px">~ . ($title ? "$title &middot; " : '') . qq~$email~ . ($phone ? " &middot; $phone" : '') . qq~</div>
182 </div>
183 </a>~;
184 }
185 $body .= qq~</div>~;
186 }
187
188 if (@companies) {
189 my $n = scalar @companies;
190 $body .= qq~<div style="margin-bottom:28px"><h2 style="font-size:18px;color:$accent;margin:0 0 12px">Companies ($n)</h2>~;
191 foreach my $co (@companies) {
192 my $name = _h($co->{name} || '(unnamed)');
193 my $dom = _h($co->{domain} || $co->{website} || '');
194 my $ind = _h($co->{industry} || '');
195 $body .= qq~<a href="/company.cgi?id=$co->{id}" style="display:block;padding:10px 14px;margin-bottom:6px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:6px;text-decoration:none;color:#fff">
196 <div style="font-weight:600">$name</div>
197 <div style="color:#888;font-size:12px;margin-top:2px">$dom~ . ($ind ? " &middot; $ind" : '') . qq~</div>
198 </a>~;
199 }
200 $body .= qq~</div>~;
201 }
202
203 if (@deals) {
204 my $n = scalar @deals;
205 $body .= qq~<div style="margin-bottom:28px"><h2 style="font-size:18px;color:$accent;margin:0 0 12px">Deals ($n)</h2>~;
206 foreach my $d (@deals) {
207 my $title = _h($d->{title} || '(untitled)');
208 my $amt = sprintf('%.2f', ($d->{amount_cents} || 0) / 100);
209 my $cur = uc(_h($d->{currency} || 'USD'));
210 $body .= qq~<a href="/deal.cgi?id=$d->{id}" style="display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 14px;margin-bottom:6px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:6px;text-decoration:none;color:#fff">
211 <div style="font-weight:600">$title</div>
212 <div style="color:$accent;font-weight:700;font-size:14px">\$$amt $cur</div>
213 </a>~;
214 }
215 $body .= qq~</div>~;
216 }
217
218 if (@tasks) {
219 my $n = scalar @tasks;
220 $body .= qq~<div style="margin-bottom:28px"><h2 style="font-size:18px;color:$accent;margin:0 0 12px">Tasks ($n)</h2>~;
221 foreach my $t (@tasks) {
222 my $title = _h($t->{title} || '(untitled)');
223 my $status = _h($t->{status} || 'open');
224 my $due = _h($t->{due_at} || '');
225 $body .= qq~<a href="/task.cgi?id=$t->{id}" style="display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 14px;margin-bottom:6px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:6px;text-decoration:none;color:#fff">
226 <div style="font-weight:600">$title</div>
227 <div style="color:#888;font-size:12px">$status~ . ($due ? " &middot; due $due" : '') . qq~</div>
228 </a>~;
229 }
230 $body .= qq~</div>~;
231 }
232
233 if (@settings_hits) {
234 my $n = scalar @settings_hits;
235 $body .= qq~<div style="margin-bottom:28px"><h2 style="font-size:18px;color:$accent;margin:0 0 12px">Pages ($n)</h2>~;
236 foreach my $s (@settings_hits) {
237 my $label = _h($s->{label});
238 my $href = _h($s->{href});
239 $body .= qq~<a href="$href" style="display:inline-block;padding:8px 14px;margin:0 8px 8px 0;background:rgba(34,211,238,.1);border:1px solid rgba(34,211,238,.3);border-radius:6px;text-decoration:none;color:$accent;font-size:13px">$label</a>~;
240 }
241 $body .= qq~</div>~;
242 }
243}
244
245$body .= qq~</div>~;
246
247print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
248$wrap->render({
249 userinfo => $userinfo,
250 title => length($clean) ? "Search: $q_h" : 'Search',
251 body => $body,
252});
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help