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

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

added on local at 2026-07-01 15:02:22

Added
+367
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 3cf3aa46a046
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 -- Public REST API entry point
4#
5# Apache rewrites /api/* -> /api.cgi/* (see .htaccess). PATH_INFO is
6# preserved. Auth: Authorization: Bearer cf_<token>
7#
8# Endpoints (v1):
9# GET /me
10# GET /contacts (?q=&limit=&offset=)
11# POST /contacts
12# GET /contacts/:id
13# PATCH /contacts/:id
14# DELETE /contacts/:id (soft-archive)
15# GET /companies, POST, GET/:id, PATCH/:id
16# GET /deals, POST, GET/:id, PATCH/:id
17# GET /activities, POST
18# GET /tasks, POST, PATCH/:id
19# GET /notes, POST
20#
21# JSON in, JSON out. Errors: { "error": "...", "code": "..." }
22#======================================================================
23use strict; use warnings;
24use lib '/var/www/vhosts/3dshawn.com/crm.3dshawn.com';
25use CGI;
26$CGI::POST_MAX = 8 * 1024 * 1024;
27use Digest::SHA qw(sha256_hex);
28use MODS::DBConnect;
29use MODS::ContactForge::Config;
30
31my $q = CGI->new;
32my $db = MODS::DBConnect->new;
33my $cfg = MODS::ContactForge::Config->new;
34my $DB = $cfg->settings('database_name');
35
36sub send_resp {
37 my ($status, $payload) = @_;
38 my $msg = ($status == 200) ? 'OK' : ($status == 201) ? 'Created' :
39 ($status == 400) ? 'Bad Request' : ($status == 401) ? 'Unauthorized' :
40 ($status == 403) ? 'Forbidden' : ($status == 404) ? 'Not Found' :
41 ($status == 422) ? 'Unprocessable Entity' : 'Internal Server Error';
42 print "Status: $status $msg\nContent-Type: application/json; charset=utf-8\nX-CF-API-Version: v1\n\n";
43 print to_json($payload);
44 exit;
45}
46
47sub to_json {
48 my ($v) = @_;
49 return 'null' unless defined $v;
50 if (ref $v eq 'ARRAY') { return '[' . join(',', map { to_json($_) } @$v) . ']'; }
51 if (ref $v eq 'HASH') {
52 my @p; foreach my $k (sort keys %$v) { push @p, '"' . esc($k) . '":' . to_json($v->{$k}); }
53 return '{' . join(',', @p) . '}';
54 }
55 return "$v" if $v =~ /^-?\d+(\.\d+)?$/;
56 return '"' . esc($v) . '"';
57}
58sub esc {
59 my ($s) = @_; $s = '' unless defined $s;
60 $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g; $s =~ s/\n/\\n/g; $s =~ s/\r/\\r/g; $s =~ s/\t/\\t/g;
61 $s =~ s/[\x00-\x1f]//g;
62 return $s;
63}
64
65sub parse_json {
66 my ($s) = @_;
67 return {} unless defined $s && length $s;
68 my %out;
69 while ($s =~ /"([^"\\]*(?:\\.[^"\\]*)*)"\s*:\s*"((?:[^"\\]|\\.)*)"/g) {
70 my ($k, $v) = ($1, $2); $v =~ s/\\"/"/g; $v =~ s/\\n/\n/g; $v =~ s/\\r/\r/g; $v =~ s/\\\\/\\/g;
71 $out{$k} = $v;
72 }
73 while ($s =~ /"([^"\\]*(?:\\.[^"\\]*)*)"\s*:\s*(-?\d+(?:\.\d+)?)/g) { $out{$1} = $2 unless exists $out{$1}; }
74 while ($s =~ /"([^"\\]*(?:\\.[^"\\]*)*)"\s*:\s*(true|false|null)/g) {
75 my ($k, $v) = ($1, $2);
76 $out{$k} = ($v eq 'true' ? 1 : $v eq 'false' ? 0 : undef) unless exists $out{$k};
77 }
78 return \%out;
79}
80
81my $dbh = $db->db_connect();
82send_resp(500, { error => 'db connect failed' }) unless $dbh;
83
84# Auth -----------------------------------------------------------------
85my $hdr = $ENV{HTTP_AUTHORIZATION} || '';
86my $tok; if ($hdr =~ /^Bearer\s+(\S+)/i) { $tok = $1; }
87send_resp(401, { error => 'invalid or missing token', hint => 'Set Authorization: Bearer cf_...' }) unless $tok;
88
89my $hash = sha256_hex($tok);
90my $r = $db->db_readwrite($dbh, qq~
91 SELECT k.id AS key_id, k.user_id, u.team_id
92 FROM `${DB}`.api_keys k
93 LEFT JOIN `${DB}`.users u ON u.id = k.user_id
94 WHERE k.token_hash='$hash' AND k.revoked_at IS NULL
95 LIMIT 1
96~, $ENV{SCRIPT_NAME}, __LINE__);
97send_resp(401, { error => 'invalid token' }) unless $r && $r->{key_id};
98my $uid = $r->{user_id};
99my $tid = $r->{team_id} || 0;
100
101# Touch last_used (best-effort)
102my $ip = $ENV{REMOTE_ADDR} || ''; $ip =~ s/[^0-9a-f:.]//gi;
103eval { $db->db_readwrite($dbh, qq~UPDATE `${DB}`.api_keys SET last_used_at=NOW() WHERE id='$r->{key_id}'~, $ENV{SCRIPT_NAME}, __LINE__); };
104
105# Path + body ----------------------------------------------------------
106my $path = $ENV{PATH_INFO} || $q->url_param('path') || '';
107$path =~ s|^/api||; # tolerate /api prefix
108my $method = uc($ENV{REQUEST_METHOD} || 'GET');
109my $body = {};
110if ($method =~ /^(POST|PATCH|PUT)$/) {
111 my $raw = ''; my $len = $ENV{CONTENT_LENGTH} || 0;
112 if ($len) { read(STDIN, $raw, $len); }
113 $body = parse_json($raw);
114}
115
116# Dispatch -------------------------------------------------------------
117if ($path =~ m~^/me/?$~) { do_me(); }
118elsif ($path =~ m~^/contacts/?$~ && $method eq 'GET') { list_contacts(); }
119elsif ($path =~ m~^/contacts/?$~ && $method eq 'POST') { create_contact($body); }
120elsif ($path =~ m~^/contacts/(\d+)/?$~ && $method eq 'GET') { get_contact($1); }
121elsif ($path =~ m~^/contacts/(\d+)/?$~ && $method eq 'PATCH') { update_contact($1, $body); }
122elsif ($path =~ m~^/contacts/(\d+)/?$~ && $method eq 'DELETE') { archive_contact($1); }
123elsif ($path =~ m~^/companies/?$~ && $method eq 'GET') { list_simple('companies', 'name'); }
124elsif ($path =~ m~^/companies/?$~ && $method eq 'POST') { create_company($body); }
125elsif ($path =~ m~^/companies/(\d+)/?$~ && $method eq 'GET') { get_one('companies', $1); }
126elsif ($path =~ m~^/deals/?$~ && $method eq 'GET') { list_deals(); }
127elsif ($path =~ m~^/deals/?$~ && $method eq 'POST') { create_deal($body); }
128elsif ($path =~ m~^/deals/(\d+)/?$~ && $method eq 'GET') { get_one('deals', $1); }
129elsif ($path =~ m~^/deals/(\d+)/?$~ && $method eq 'PATCH') { update_deal($1, $body); }
130elsif ($path =~ m~^/tasks/?$~ && $method eq 'GET') { list_simple('tasks', 'due_date'); }
131elsif ($path =~ m~^/tasks/?$~ && $method eq 'POST') { create_task($body); }
132elsif ($path =~ m~^/notes/?$~ && $method eq 'POST') { create_note($body); }
133elsif ($path =~ m~^/activities/?$~ && $method eq 'POST') { create_activity($body); }
134elsif ($path =~ m~^/pipelines/?$~ && $method eq 'GET') { list_simple('pipelines', 'name'); }
135else {
136 send_resp(404, { error => 'no such endpoint', path => $path, method => $method });
137}
138
139# ------------------------------------------------------------------ #
140sub do_me {
141 send_resp(200, { user_id => $uid+0, team_id => $tid+0, api_version => 'v1' });
142}
143
144sub _team_clause { return "team_id='$tid'"; }
145
146sub list_contacts {
147 my $f = $q->Vars;
148 my $limit = $f->{limit} || 50; $limit =~ s/[^0-9]//g; $limit ||= 50; $limit = 200 if $limit > 200;
149 my $offset = $f->{offset} || 0; $offset =~ s/[^0-9]//g;
150 my @where = (_team_clause(), 'archived_at IS NULL');
151 if ($f->{q}) {
152 my $qq = $f->{q}; $qq =~ s/'/''/g; $qq =~ s/%/\\%/g;
153 push @where, "(first_name LIKE '%$qq%' OR last_name LIKE '%$qq%' OR email LIKE '%$qq%')";
154 }
155 my $where = join(' AND ', @where);
156 my @rows = $db->db_readwrite_multiple($dbh, qq~
157 SELECT id, first_name, last_name, email, phone, title, company_id, created_at, updated_at
158 FROM `${DB}`.contacts WHERE $where ORDER BY id DESC LIMIT $limit OFFSET $offset
159 ~, $ENV{SCRIPT_NAME}, __LINE__);
160 send_resp(200, { contacts => \@rows, count => scalar(@rows), limit => $limit+0, offset => $offset+0 });
161}
162
163sub create_contact {
164 my ($b) = @_;
165 my $fn = substr($b->{first_name} || '', 0, 120);
166 my $ln = substr($b->{last_name} || '', 0, 120);
167 my $em = lc(substr($b->{email} || '', 0, 255));
168 send_resp(400, { error => 'first_name OR email required' }) unless length($fn) || length($em);
169 my %q;
170 foreach my $k (qw(first_name last_name email phone title)) {
171 my $v = substr($b->{$k} || '', 0, 255); $v =~ s/'/''/g; $q{$k} = $v;
172 }
173 my $co = $b->{company_id}; $co =~ s/[^0-9]//g if defined $co;
174 my $co_sql = (defined $co && length $co) ? "'$co'" : 'NULL';
175 my $r = $db->db_readwrite($dbh, qq~
176 INSERT INTO `${DB}`.contacts
177 SET team_id='$tid',
178 first_name='$q{first_name}', last_name='$q{last_name}',
179 email='$q{email}', phone='$q{phone}', title='$q{title}',
180 company_id=$co_sql, owner_user_id='$uid', source='api'
181 ~, $ENV{SCRIPT_NAME}, __LINE__);
182 my $new_id = ($r && $r->{mysql_insertid}) ? $r->{mysql_insertid} : 0;
183 send_resp(201, { contact_id => $new_id+0 });
184}
185
186sub get_contact {
187 my ($id) = @_;
188 my $row = $db->db_readwrite($dbh, qq~
189 SELECT * FROM `${DB}`.contacts WHERE id='$id' AND ~ . _team_clause() . qq~ LIMIT 1
190 ~, $ENV{SCRIPT_NAME}, __LINE__);
191 send_resp(404, { error => 'contact not found' }) unless $row && $row->{id};
192 send_resp(200, { contact => $row });
193}
194
195sub update_contact {
196 my ($id, $b) = @_;
197 my @sets;
198 foreach my $k (qw(first_name last_name email phone title)) {
199 next unless defined $b->{$k};
200 my $v = substr($b->{$k}, 0, 255); $v =~ s/'/''/g;
201 push @sets, "$k='$v'";
202 }
203 if (defined $b->{company_id}) {
204 my $v = $b->{company_id}; $v =~ s/[^0-9]//g;
205 push @sets, "company_id=" . (length $v ? "'$v'" : 'NULL');
206 }
207 send_resp(400, { error => 'nothing to update' }) unless @sets;
208 my $set = join(',', @sets);
209 my $ok = $db->db_readwrite($dbh, qq~
210 UPDATE `${DB}`.contacts SET $set WHERE id='$id' AND ~ . _team_clause() . qq~
211 ~, $ENV{SCRIPT_NAME}, __LINE__);
212 send_resp(200, { contact_id => $id+0, updated => 1 });
213}
214
215sub archive_contact {
216 my ($id) = @_;
217 $db->db_readwrite($dbh, qq~
218 UPDATE `${DB}`.contacts SET archived_at=NOW() WHERE id='$id' AND ~ . _team_clause() . qq~
219 ~, $ENV{SCRIPT_NAME}, __LINE__);
220 send_resp(200, { contact_id => $id+0, archived => 1 });
221}
222
223sub create_company {
224 my ($b) = @_;
225 my $name = substr($b->{name} || '', 0, 255);
226 send_resp(400, { error => 'name required' }) unless length $name;
227 my %q;
228 foreach my $k (qw(name domain industry size_label phone)) {
229 my $v = substr($b->{$k} || '', 0, 255); $v =~ s/'/''/g; $q{$k} = $v;
230 }
231 my $r = $db->db_readwrite($dbh, qq~
232 INSERT INTO `${DB}`.companies
233 SET team_id='$tid', name='$q{name}', domain='$q{domain}',
234 industry='$q{industry}', size_label='$q{size_label}',
235 phone='$q{phone}', owner_user_id='$uid'
236 ~, $ENV{SCRIPT_NAME}, __LINE__);
237 my $new_id = ($r && $r->{mysql_insertid}) ? $r->{mysql_insertid} : 0;
238 send_resp(201, { company_id => $new_id+0 });
239}
240
241sub get_one {
242 my ($table, $id) = @_;
243 my $row = $db->db_readwrite($dbh, qq~
244 SELECT * FROM `${DB}`.$table WHERE id='$id' AND ~ . _team_clause() . qq~ LIMIT 1
245 ~, $ENV{SCRIPT_NAME}, __LINE__);
246 send_resp(404, { error => 'not found' }) unless $row && $row->{id};
247 send_resp(200, { $table => $row });
248}
249
250sub list_simple {
251 my ($table, $order_col) = @_;
252 my $f = $q->Vars;
253 my $limit = $f->{limit} || 50; $limit =~ s/[^0-9]//g; $limit = 200 if $limit > 200; $limit ||= 50;
254 my $offset = $f->{offset} || 0; $offset =~ s/[^0-9]//g;
255 my @rows = $db->db_readwrite_multiple($dbh, qq~
256 SELECT * FROM `${DB}`.$table WHERE ~ . _team_clause() . qq~
257 ORDER BY $order_col DESC LIMIT $limit OFFSET $offset
258 ~, $ENV{SCRIPT_NAME}, __LINE__);
259 send_resp(200, { $table => \@rows, count => scalar(@rows) });
260}
261
262sub list_deals {
263 my $f = $q->Vars;
264 my $limit = $f->{limit} || 50; $limit =~ s/[^0-9]//g; $limit = 200 if $limit > 200; $limit ||= 50;
265 my @where = (_team_clause());
266 if ($f->{stage_id} && $f->{stage_id} =~ /^\d+$/) {
267 push @where, "deal_stage_id='$f->{stage_id}'";
268 }
269 if ($f->{is_won} && $f->{is_won} eq '1') { push @where, "is_won=1"; }
270 my $where = join(' AND ', @where);
271 my @rows = $db->db_readwrite_multiple($dbh, qq~
272 SELECT * FROM `${DB}`.deals WHERE $where ORDER BY id DESC LIMIT $limit
273 ~, $ENV{SCRIPT_NAME}, __LINE__);
274 send_resp(200, { deals => \@rows, count => scalar(@rows) });
275}
276
277sub create_deal {
278 my ($b) = @_;
279 my $name = substr($b->{name} || '', 0, 255);
280 send_resp(400, { error => 'name required' }) unless length $name;
281 my $amount = $b->{amount_cents} || 0; $amount =~ s/[^0-9]//g; $amount ||= 0;
282 my $stage = $b->{deal_stage_id}; $stage =~ s/[^0-9]//g if defined $stage;
283 my $contact = $b->{contact_id}; $contact =~ s/[^0-9]//g if defined $contact;
284 my $company = $b->{company_id}; $company =~ s/[^0-9]//g if defined $company;
285 my $n_q = $name; $n_q =~ s/'/''/g;
286 my $stage_sql = (length($stage||'') ? "'$stage'" : 'NULL');
287 my $contact_sql = (length($contact||'') ? "'$contact'" : 'NULL');
288 my $company_sql = (length($company||'') ? "'$company'" : 'NULL');
289 my $r = $db->db_readwrite($dbh, qq~
290 INSERT INTO `${DB}`.deals
291 SET team_id='$tid', name='$n_q',
292 amount_cents='$amount', currency='USD',
293 deal_stage_id=$stage_sql, contact_id=$contact_sql, company_id=$company_sql,
294 owner_user_id='$uid'
295 ~, $ENV{SCRIPT_NAME}, __LINE__);
296 my $new_id = ($r && $r->{mysql_insertid}) ? $r->{mysql_insertid} : 0;
297 send_resp(201, { deal_id => $new_id+0 });
298}
299
300sub update_deal {
301 my ($id, $b) = @_;
302 my @sets;
303 if (defined $b->{name}) { my $v = substr($b->{name},0,255); $v =~ s/'/''/g; push @sets, "name='$v'"; }
304 if (defined $b->{amount_cents}){ my $v = $b->{amount_cents}; $v =~ s/[^0-9]//g; push @sets, "amount_cents='$v'"; }
305 if (defined $b->{deal_stage_id}){my $v = $b->{deal_stage_id}; $v =~ s/[^0-9]//g; push @sets, "deal_stage_id=" . (length $v ? "'$v'" : 'NULL'); }
306 if (defined $b->{is_won}) { my $v = $b->{is_won} ? 1 : 0; push @sets, "is_won='$v'"; }
307 send_resp(400, { error => 'nothing to update' }) unless @sets;
308 my $set = join(',', @sets);
309 $db->db_readwrite($dbh, qq~UPDATE `${DB}`.deals SET $set WHERE id='$id' AND ~ . _team_clause() . qq~~, $ENV{SCRIPT_NAME}, __LINE__);
310 send_resp(200, { deal_id => $id+0, updated => 1 });
311}
312
313sub create_task {
314 my ($b) = @_;
315 my $title = substr($b->{title} || '', 0, 280);
316 send_resp(400, { error => 'title required' }) unless length $title;
317 my $t_q = $title; $t_q =~ s/'/''/g;
318 my $due = $b->{due_date} || ''; $due =~ s/[^0-9\-: ]//g;
319 my $due_sql = length($due) ? "'$due'" : 'NULL';
320 my $contact = $b->{contact_id}; $contact =~ s/[^0-9]//g if defined $contact;
321 my $deal = $b->{deal_id}; $deal =~ s/[^0-9]//g if defined $deal;
322 my $r = $db->db_readwrite($dbh, qq~
323 INSERT INTO `${DB}`.tasks
324 SET team_id='$tid', title='$t_q', due_date=$due_sql,
325 contact_id=~ . (length($contact||'') ? "'$contact'" : 'NULL') . qq~,
326 deal_id=~ . (length($deal||'') ? "'$deal'" : 'NULL') . qq~,
327 owner_user_id='$uid', status='open'
328 ~, $ENV{SCRIPT_NAME}, __LINE__);
329 my $new_id = ($r && $r->{mysql_insertid}) ? $r->{mysql_insertid} : 0;
330 send_resp(201, { task_id => $new_id+0 });
331}
332
333sub create_note {
334 my ($b) = @_;
335 my $body_text = substr($b->{body} || '', 0, 64000);
336 send_resp(400, { error => 'body required' }) unless length $body_text;
337 my $bq = $body_text; $bq =~ s/'/''/g;
338 my $contact = $b->{contact_id}; $contact =~ s/[^0-9]//g if defined $contact;
339 my $deal = $b->{deal_id}; $deal =~ s/[^0-9]//g if defined $deal;
340 my $r = $db->db_readwrite($dbh, qq~
341 INSERT INTO `${DB}`.notes
342 SET team_id='$tid', body='$bq', user_id='$uid',
343 contact_id=~ . (length($contact||'') ? "'$contact'" : 'NULL') . qq~,
344 deal_id=~ . (length($deal||'') ? "'$deal'" : 'NULL') . qq~
345 ~, $ENV{SCRIPT_NAME}, __LINE__);
346 my $new_id = ($r && $r->{mysql_insertid}) ? $r->{mysql_insertid} : 0;
347 send_resp(201, { note_id => $new_id+0 });
348}
349
350sub create_activity {
351 my ($b) = @_;
352 my $kind = lc($b->{kind} || 'note');
353 $kind = 'note' unless $kind =~ /^(call|email|meeting|note|other)$/;
354 my $subj = substr($b->{subject} || '', 0, 280); $subj =~ s/'/''/g;
355 my $body_text = substr($b->{body} || '', 0, 32000); $body_text =~ s/'/''/g;
356 my $contact = $b->{contact_id}; $contact =~ s/[^0-9]//g if defined $contact;
357 my $deal = $b->{deal_id}; $deal =~ s/[^0-9]//g if defined $deal;
358 my $r = $db->db_readwrite($dbh, qq~
359 INSERT INTO `${DB}`.activities
360 SET team_id='$tid', kind='$kind', subject='$subj', body='$body_text',
361 user_id='$uid',
362 contact_id=~ . (length($contact||'') ? "'$contact'" : 'NULL') . qq~,
363 deal_id=~ . (length($deal||'') ? "'$deal'" : 'NULL') . qq~
364 ~, $ENV{SCRIPT_NAME}, __LINE__);
365 my $new_id = ($r && $r->{mysql_insertid}) ? $r->{mysql_insertid} : 0;
366 send_resp(201, { activity_id => $new_id+0 });
367}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help