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

/var/www/vhosts/3dshawn.com/repricer.3dshawn.com/_attic/optimization_action.cgi

added on local at 2026-07-01 21:47:34

Added
+647
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 64384f3a29fd
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 - Optimization (A/B + MVT) action handler.
4#
5# POST-only. Single chokepoint for every mutation on an ab_tests row.
6#
7# Actions:
8# act=create build a new draft experiment from the form below
9# act=start flip status from draft|paused -> running
10# act=pause flip status from running -> paused
11# act=archive flip status to aborted (does not delete; preserves history)
12#
13# create form fields:
14# name, storefront_id, test_type (ab|mvt), surface, primary_metric,
15# confidence_threshold, variant_a_text, variant_a_image (file),
16# variant_b_text, variant_b_image (file)
17#======================================================================
18use strict;
19use warnings;
20
21use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com';
22use CGI;
23use MODS::Template;
24use MODS::DBConnect;
25use MODS::Login;
26use MODS::RePricer::Config;
27
28my $q = CGI->new;
29my $form = $q->Vars;
30my $auth = MODS::Login->new;
31my $db = MODS::DBConnect->new;
32my $cfg = MODS::RePricer::Config->new;
33my $DB = $cfg->settings('database_name');
34
35$| = 1;
36
37# POST-only.
38if (($ENV{REQUEST_METHOD} || '') ne 'POST') {
39 print "Status: 405 Method Not Allowed\nContent-Type: text/plain\n\nPOST required\n";
40 exit;
41}
42
43my $userinfo = $auth->login_verify();
44if (!$userinfo) {
45 print "Status: 302 Found\nLocation: /login.cgi\n\n";
46 exit;
47}
48require MODS::RePricer::Permissions;
49MODS::RePricer::Permissions->new->require_feature($userinfo, 'view_reports');
50my $uid = $userinfo->{user_id};
51$uid =~ s/[^0-9]//g;
52
53my $act = lc($form->{act} || '');
54$act =~ s/[^a-z_]//g;
55
56my $dbh = $db->db_connect();
57
58# Make sure ab_tests exists before doing anything.
59my $have_tbl = $db->db_readwrite($dbh, qq~
60 SELECT COUNT(*) AS n FROM information_schema.tables
61 WHERE table_schema='$DB' AND table_name='ab_tests'
62~, $ENV{SCRIPT_NAME}, __LINE__);
63unless ($have_tbl && $have_tbl->{n}) {
64 $db->db_disconnect($dbh);
65 print "Status: 302 Found\nLocation: /optimization.cgi\n\n";
66 exit;
67}
68
69# Surface column may or may not have been added yet. Skip writing it
70# when absent so a fresh DB does not 500.
71my $col = $db->db_readwrite($dbh, qq~
72 SELECT COUNT(*) AS n FROM information_schema.columns
73 WHERE table_schema='$DB' AND table_name='ab_tests' AND column_name='surface'
74~, $ENV{SCRIPT_NAME}, __LINE__);
75my $has_surface = ($col && $col->{n}) ? 1 : 0;
76
77# ---- SAVE: create-or-update from the builder form --------------------
78# Same form posts to act=save with an optional `id` hidden field. When
79# id is missing we INSERT a new draft; when present we UPDATE the row
80# AND wipe any existing ab_test_events for it (editing the variants
81# invalidates collected data so we restart cleanly).
82if ($act eq 'save' || $act eq 'create') {
83 my $name = substr($form->{name} || '', 0, 160);
84 my $storefront_id = $form->{storefront_id} || 0;
85 $storefront_id =~ s/[^0-9]//g;
86
87 # Verify ownership of the storefront.
88 my $sf = $db->db_readwrite($dbh, qq~
89 SELECT id FROM `${DB}`.storefronts
90 WHERE id='$storefront_id' AND user_id='$uid' LIMIT 1
91 ~, $ENV{SCRIPT_NAME}, __LINE__);
92 unless ($sf && $sf->{id}) {
93 $db->db_disconnect($dbh);
94 print "Status: 302 Found\nLocation: /optimization.cgi\n\n";
95 exit;
96 }
97
98 my $test_type = lc($form->{test_type} || 'ab');
99 $test_type = 'ab' unless $test_type =~ /^(ab|mvt|split_url|redirect)$/;
100
101 my $surface = $form->{surface} || '';
102 $surface =~ s/[^a-z0-9_]//g;
103 $surface = substr($surface, 0, 64);
104 # Whitelist the surfaces we understand. Falls back to hero_title
105 # rather than '' so the experiment renders something predictable.
106 my %ALLOWED_SURFACE = map { $_ => 1 } qw(
107 hero_title hero_subtitle hero_image hero_video
108 cta_label product_tile section_heading custom
109 layout theme layout_and_theme
110 storefront_page page_blocks
111 );
112 $surface = 'hero_title' unless $ALLOWED_SURFACE{$surface};
113
114 my $primary_metric = $form->{primary_metric} || 'conversion_rate';
115 $primary_metric =~ s/[^a-z0-9_]//g;
116 $primary_metric = substr($primary_metric, 0, 80);
117
118 my $confidence = $form->{confidence_threshold} || '0.95';
119 $confidence =~ s/[^0-9.]//g;
120 $confidence = '0.95' unless $confidence;
121
122 # When surface=product_tile, the variants test a specific model's
123 # tile content (title + image). Validate the model belongs to the
124 # logged-in user; if it doesn't, zero out target_model_id so the
125 # experiment falls back to a no-op on the storefront render path.
126 my $target_id = 0;
127 if ($surface eq 'product_tile') {
128 my $raw = $form->{target_model_id} || 0;
129 $raw =~ s/[^0-9]//g;
130 if ($raw) {
131 my $owns = $db->db_readwrite($dbh, qq~
132 SELECT id FROM `${DB}`.models
133 WHERE id='$raw' AND user_id='$uid' AND purged_at IS NULL
134 LIMIT 1
135 ~, $ENV{SCRIPT_NAME}, __LINE__);
136 $target_id = ($owns && $owns->{id}) ? $raw : 0;
137 }
138 }
139
140 # Read the per-variant rows from the form. The builder uses single
141 # uppercase letter ids (A..F). For backward compatibility we also
142 # honour variant_a_* / variant_b_* (old form names) when the
143 # numbered fields are absent.
144 my @LETTERS = ('A', 'B', 'C', 'D', 'E', 'F');
145 my @variant_specs;
146 foreach my $vid (@LETTERS) {
147 my $lc = lc $vid; # 'a', 'b', ...
148 my $text = defined $form->{"variant_${vid}_text"}
149 ? $form->{"variant_${vid}_text"}
150 : $form->{"variant_${lc}_text"};
151 my $img_field = "variant_${vid}_image";
152 my $img_url = _upload_image_if_present($q, $img_field);
153 unless (length $img_url) {
154 # Fall back to lowercase legacy field name.
155 $img_url = _upload_image_if_present($q, "variant_${lc}_image");
156 }
157 # Allow caller to pass an explicit image URL for an existing
158 # variant when no new file was uploaded (edit mode preserves
159 # the previously uploaded image).
160 unless (length $img_url) {
161 my $existing = $form->{"variant_${vid}_image_existing"}
162 // $form->{"variant_${lc}_image_existing"}
163 // '';
164 if ($existing =~ m{^/uploads/[A-Za-z0-9_./-]+$}) {
165 $img_url = $existing;
166 }
167 }
168 my $lid = $form->{"variant_${vid}_layout_id"};
169 my $tid = $form->{"variant_${vid}_theme_id"};
170 my $ctid = $form->{"variant_${vid}_custom_theme_id"};
171 my $vpid = $form->{"variant_${vid}_page_id"};
172 for ($lid, $tid, $ctid, $vpid) {
173 $_ = '' unless defined $_;
174 s/[^0-9]//g;
175 $_ = $_ ? ($_ + 0) : 0;
176 }
177 # Per-block MVT override map. Passed in as a JSON-shaped string
178 # the storefront-editor JS built. We don't parse it here -- the
179 # storefront page renderer will. We just need to round-trip it
180 # safely into the variants column.
181 my $block_overrides_json = $form->{"variant_${vid}_block_overrides"};
182 $block_overrides_json = '' unless defined $block_overrides_json;
183 my $name = $form->{"variant_${vid}_name"};
184 $name = '' unless defined $name;
185 $name = substr($name, 0, 120);
186
187 # Decide whether this variant row was filled in by the user.
188 # Non-design surfaces care about text + image; design surfaces
189 # care about layout_id / theme_id / custom_theme_id. Page tests
190 # care about variant_page_id / block_overrides. The first two
191 # rows (A control, B challenger) are always emitted even if
192 # empty so an A/B test has a baseline shape.
193 my $filled = (
194 (defined $text && length $text)
195 || length $img_url
196 || $lid > 0
197 || $tid > 0
198 || $ctid > 0
199 || $vpid > 0
200 || length $block_overrides_json
201 || length $name
202 ) ? 1 : 0;
203 my $is_required = ($vid eq 'A' || $vid eq 'B') ? 1 : 0;
204 next unless $filled || $is_required;
205
206 # Default label when seller didn't type one.
207 unless (length $name) {
208 $name = ($vid eq 'A') ? 'Control'
209 : ($vid eq 'B') ? 'Challenger'
210 : 'Variant ' . $vid;
211 }
212
213 push @variant_specs, {
214 id => $vid,
215 name => $name,
216 text => (defined $text ? $text : ''),
217 image_url => $img_url,
218 target_model_id => $target_id,
219 layout_id => $lid,
220 theme_id => $tid,
221 custom_theme_id => $ctid,
222 variant_page_id => $vpid,
223 block_overrides_json => $block_overrides_json,
224 };
225 }
226 # Always at least 2 (A + B).
227 while (scalar(@variant_specs) < 2) {
228 my $vid = $LETTERS[scalar(@variant_specs)];
229 push @variant_specs, {
230 id => $vid,
231 name => ($vid eq 'A' ? 'Control' : 'Challenger'),
232 text => '',
233 image_url => '',
234 target_model_id => $target_id,
235 layout_id => 0,
236 theme_id => 0,
237 custom_theme_id => 0,
238 variant_page_id => 0,
239 block_overrides_json => '',
240 };
241 }
242
243 # Even traffic split across the variants we wound up with. Integer
244 # percentages so the row totals to <=100 (rounding leftover lands
245 # on the control). 2 variants = 50/50, 3 = 33/33/34, etc.
246 my $n = scalar @variant_specs;
247 my $pct = int(100 / $n);
248 my $rem = 100 - ($pct * $n);
249 for (my $i = 0; $i < $n; $i++) {
250 $variant_specs[$i]->{traffic_pct} = $pct + ($i == 0 ? $rem : 0);
251 }
252
253 my $variants_json = _variants_json(@variant_specs);
254
255 # SQL-escape strings.
256 for ($name, $surface, $primary_metric, $variants_json) {
257 s/\\/\\\\/g;
258 s/'/\\'/g;
259 }
260
261 my $surface_sql = $has_surface ? "surface='$surface'," : '';
262
263 # Edit vs. create: if `id` is present (and the row belongs to us),
264 # UPDATE in place and wipe events; otherwise INSERT a new draft.
265 my $edit_id = $form->{id} || 0;
266 $edit_id =~ s/[^0-9]//g;
267
268 if ($edit_id) {
269 my $own = $db->db_readwrite($dbh, qq~
270 SELECT ab.id
271 FROM `${DB}`.ab_tests ab
272 JOIN `${DB}`.storefronts s ON s.id = ab.storefront_id
273 WHERE ab.id='$edit_id' AND s.user_id='$uid'
274 LIMIT 1
275 ~, $ENV{SCRIPT_NAME}, __LINE__);
276 unless ($own && $own->{id}) {
277 $db->db_disconnect($dbh);
278 print "Status: 302 Found\nLocation: /optimization.cgi\n\n";
279 exit;
280 }
281
282 $db->db_readwrite($dbh, qq~
283 UPDATE `${DB}`.ab_tests
284 SET name='$name',
285 test_type='$test_type',
286 $surface_sql
287 variants='$variants_json',
288 primary_metric='$primary_metric',
289 status='draft',
290 start_date=NULL,
291 end_date=NULL,
292 winner_variant_id=NULL,
293 confidence_score=NULL,
294 traffic_seen=0
295 WHERE id='$edit_id'
296 ~, $ENV{SCRIPT_NAME}, __LINE__);
297
298 # Editing variants invalidates the existing stats. Clear events
299 # so the new run starts from zero -- the previous mix of A/B
300 # exposures cannot fairly be compared to the edited variants.
301 $db->db_readwrite($dbh, qq~
302 DELETE FROM `${DB}`.ab_test_events WHERE ab_test_id='$edit_id'
303 ~, $ENV{SCRIPT_NAME}, __LINE__);
304
305 $db->db_disconnect($dbh);
306 print "Status: 302 Found\nLocation: /optimization.cgi?saved=1\n\n";
307 exit;
308 }
309
310 $db->db_readwrite($dbh, qq~
311 INSERT INTO `${DB}`.ab_tests
312 SET storefront_id='$storefront_id',
313 name='$name',
314 test_type='$test_type',
315 $surface_sql
316 variants='$variants_json',
317 primary_metric='$primary_metric',
318 status='draft',
319 created_at=NOW()
320 ~, $ENV{SCRIPT_NAME}, __LINE__);
321
322 $db->db_disconnect($dbh);
323 print "Status: 302 Found\nLocation: /optimization.cgi?saved=1\n\n";
324 exit;
325}
326
327# All other actions need an experiment id + ownership check.
328my $exp_id = $form->{id} || 0;
329$exp_id =~ s/[^0-9]//g;
330if (!$exp_id) {
331 $db->db_disconnect($dbh);
332 print "Status: 302 Found\nLocation: /optimization.cgi\n\n";
333 exit;
334}
335
336# Verify the experiment belongs to the logged-in user.
337my $own = $db->db_readwrite($dbh, qq~
338 SELECT ab.id, ab.status
339 FROM `${DB}`.ab_tests ab
340 JOIN `${DB}`.storefronts s ON s.id = ab.storefront_id
341 WHERE ab.id='$exp_id' AND s.user_id='$uid'
342 LIMIT 1
343~, $ENV{SCRIPT_NAME}, __LINE__);
344unless ($own && $own->{id}) {
345 $db->db_disconnect($dbh);
346 print "Status: 302 Found\nLocation: /optimization.cgi\n\n";
347 exit;
348}
349
350if ($act eq 'start') {
351 # draft|paused -> running. Set start_date if not already set.
352 $db->db_readwrite($dbh, qq~
353 UPDATE `${DB}`.ab_tests
354 SET status='running',
355 start_date=COALESCE(start_date, NOW())
356 WHERE id='$exp_id'
357 ~, $ENV{SCRIPT_NAME}, __LINE__);
358}
359elsif ($act eq 'pause') {
360 $db->db_readwrite($dbh, qq~
361 UPDATE `${DB}`.ab_tests
362 SET status='paused'
363 WHERE id='$exp_id'
364 ~, $ENV{SCRIPT_NAME}, __LINE__);
365}
366elsif ($act eq 'complete') {
367 # Mark the test complete AND lock in the winning variant based on
368 # the conversion data collected so far. The winner is whichever
369 # variant has the higher conversion rate; ties go to the control
370 # (variant A). If no data has been collected at all, the control
371 # wins by default and the confidence stays NULL.
372 require MODS::RePricer::Experiments;
373 my $exh = MODS::RePricer::Experiments->new;
374 my $stats = $exh->compute_stats($db, $dbh, $DB, $exp_id) || {};
375
376 # Pull the first variant id from the JSON so we have a default
377 # "control" id even when compute_stats returns nothing (no data).
378 my $first_id = '';
379 my $row = $db->db_readwrite($dbh, qq~
380 SELECT variants FROM `${DB}`.ab_tests WHERE id='$exp_id' LIMIT 1
381 ~, $ENV{SCRIPT_NAME}, __LINE__);
382 if ($row && $row->{variants} && $row->{variants} =~ /\{"id":"([^"]+)"/) {
383 $first_id = $1;
384 }
385
386 my $winner_id;
387 if ($stats && exists $stats->{rate_a}) {
388 # Pick the variant with the higher rate. Ties (incl. both 0)
389 # go to the control so we never accidentally promote an
390 # untested challenger.
391 if (($stats->{rate_b} || 0) > ($stats->{rate_a} || 0)) {
392 $winner_id = $stats->{winner_id};
393 } else {
394 $winner_id = $stats->{control_id};
395 }
396 }
397 $winner_id = $first_id unless $winner_id;
398 $winner_id =~ s/[^A-Za-z0-9_\-]//g;
399
400 my $conf_sql = (defined $stats->{confidence})
401 ? sprintf('%.4f', $stats->{confidence})
402 : 'NULL';
403
404 $db->db_readwrite($dbh, qq~
405 UPDATE `${DB}`.ab_tests
406 SET status='complete',
407 end_date=COALESCE(end_date, NOW()),
408 winner_variant_id='$winner_id',
409 confidence_score=$conf_sql
410 WHERE id='$exp_id'
411 ~, $ENV{SCRIPT_NAME}, __LINE__);
412}
413elsif ($act eq 'archive') {
414 # Archive: hide from the main view but keep history. Distinct from
415 # delete (which removes the row entirely). Reachable via the
416 # Archived section.
417 $db->db_readwrite($dbh, qq~
418 UPDATE `${DB}`.ab_tests
419 SET status='aborted',
420 end_date=COALESCE(end_date, NOW())
421 WHERE id='$exp_id'
422 ~, $ENV{SCRIPT_NAME}, __LINE__);
423}
424elsif ($act eq 'unarchive') {
425 # Bring a previously-archived row back to Paused so the user can
426 # decide whether to resume, edit, or re-complete it.
427 $db->db_readwrite($dbh, qq~
428 UPDATE `${DB}`.ab_tests
429 SET status='paused',
430 end_date=NULL
431 WHERE id='$exp_id'
432 ~, $ENV{SCRIPT_NAME}, __LINE__);
433}
434elsif ($act eq 'reset_stats') {
435 # Wipe the events for this experiment and zero out the cached
436 # confidence + traffic counters. The variants and config stay
437 # intact -- only the collected data resets. Status flips back to
438 # draft so the user can deliberately re-Start when ready.
439 $db->db_readwrite($dbh, qq~
440 DELETE FROM `${DB}`.ab_test_events WHERE ab_test_id='$exp_id'
441 ~, $ENV{SCRIPT_NAME}, __LINE__);
442 $db->db_readwrite($dbh, qq~
443 UPDATE `${DB}`.ab_tests
444 SET status='draft',
445 start_date=NULL,
446 end_date=NULL,
447 winner_variant_id=NULL,
448 confidence_score=NULL,
449 traffic_seen=0
450 WHERE id='$exp_id'
451 ~, $ENV{SCRIPT_NAME}, __LINE__);
452}
453elsif ($act eq 'delete') {
454 # Hard delete. ab_test_events cascades via the foreign key.
455 $db->db_readwrite($dbh, qq~
456 DELETE FROM `${DB}`.ab_tests WHERE id='$exp_id'
457 ~, $ENV{SCRIPT_NAME}, __LINE__);
458}
459
460# ---- Price tests + Van Westendorp surveys ---------------------------
461# Separate group of actions delegated to MODS::RePricer::PricingExperiments.
462# These don't touch the ab_tests table; ownership / schema-readiness
463# checks live in the helper. Each returns to /optimization.cgi with a
464# fragment (#price or #survey) so the browser scrolls to the right tab.
465elsif ($act eq 'create_price_test') {
466 require MODS::RePricer::PricingExperiments;
467 my $px = MODS::RePricer::PricingExperiments->new;
468 my $r = $px->create_price_test($db, $dbh, $DB, $userinfo->{user_id},
469 model_id => $form->{model_id},
470 min_price_cents => int(($form->{min_price_dollars} || 0) * 100),
471 max_price_cents => int(($form->{max_price_dollars} || 0) * 100),
472 step_cents => int(($form->{step_dollars} || 1) * 100),
473 trigger => $form->{trigger} || 'conversion',
474 cooldown_hours => $form->{cooldown_hours} || 24,
475 );
476 $db->db_disconnect($dbh);
477 print "Status: 302 Found\nLocation: /optimization.cgi"
478 . ($r->{ok} ? '?ok=test_created#price'
479 : '?err=' . _u($r->{error} || 'failed') . '#price')
480 . "\n\n";
481 exit;
482}
483elsif ($act eq 'set_test_status') {
484 require MODS::RePricer::PricingExperiments;
485 my $px = MODS::RePricer::PricingExperiments->new;
486 my $r = $px->set_test_status($db, $dbh, $DB, $userinfo->{user_id},
487 $form->{test_id}, $form->{status});
488 $db->db_disconnect($dbh);
489 print "Status: 302 Found\nLocation: /optimization.cgi"
490 . ($r->{ok} ? '?ok=test_status#price' : '?err=denied#price')
491 . "\n\n";
492 exit;
493}
494elsif ($act eq 'delete_price_test') {
495 require MODS::RePricer::PricingExperiments;
496 my $px = MODS::RePricer::PricingExperiments->new;
497 my $r = $px->delete_price_test($db, $dbh, $DB, $userinfo->{user_id}, $form->{test_id});
498 $db->db_disconnect($dbh);
499 print "Status: 302 Found\nLocation: /optimization.cgi"
500 . ($r->{ok} ? '?ok=test_deleted#price' : '?err=denied#price')
501 . "\n\n";
502 exit;
503}
504elsif ($act eq 'create_survey') {
505 require MODS::RePricer::PricingExperiments;
506 my $px = MODS::RePricer::PricingExperiments->new;
507 my $r = $px->create_survey($db, $dbh, $DB, $userinfo->{user_id},
508 model_id => $form->{model_id},
509 title => $form->{title},
510 survey_type => $form->{survey_type} || 'van_westendorp',
511 );
512 $db->db_disconnect($dbh);
513 print "Status: 302 Found\nLocation: /optimization.cgi"
514 . ($r->{ok} ? '?ok=survey_created#survey'
515 : '?err=' . _u($r->{error} || 'failed') . '#survey')
516 . "\n\n";
517 exit;
518}
519elsif ($act eq 'close_survey') {
520 require MODS::RePricer::PricingExperiments;
521 my $px = MODS::RePricer::PricingExperiments->new;
522 my $r = $px->close_survey($db, $dbh, $DB, $userinfo->{user_id}, $form->{survey_id});
523 $db->db_disconnect($dbh);
524 print "Status: 302 Found\nLocation: /optimization.cgi"
525 . ($r->{ok} ? '?ok=survey_closed#survey' : '?err=denied#survey')
526 . "\n\n";
527 exit;
528}
529elsif ($act eq 'delete_survey') {
530 require MODS::RePricer::PricingExperiments;
531 my $px = MODS::RePricer::PricingExperiments->new;
532 my $r = $px->delete_survey($db, $dbh, $DB, $userinfo->{user_id}, $form->{survey_id});
533 $db->db_disconnect($dbh);
534 print "Status: 302 Found\nLocation: /optimization.cgi"
535 . ($r->{ok} ? '?ok=survey_deleted#survey' : '?err=denied#survey')
536 . "\n\n";
537 exit;
538}
539
540$db->db_disconnect($dbh);
541print "Status: 302 Found\nLocation: /optimization.cgi\n\n";
542exit;
543
544# URL encoder for redirect query strings.
545sub _u {
546 my $s = shift; $s = '' unless defined $s;
547 $s =~ s/([^A-Za-z0-9\-_.~])/sprintf('%%%02X', ord($1))/eg;
548 return $s;
549}
550
551#======================================================================
552# Helpers
553#======================================================================
554
555# Read an uploaded file from the current CGI request, write it under
556# /uploads/, return the URL. Returns '' if no upload was attached.
557sub _upload_image_if_present {
558 my ($q, $field) = @_;
559 my $fh = $q->upload($field);
560 return '' unless $fh;
561
562 # Read into memory. (Optimization images are typically small -- we
563 # cap reads at ~10MB to avoid runaway memory if a buyer uploads a
564 # huge file.)
565 my $bytes = '';
566 my $cap = 10 * 1024 * 1024;
567 my $buf;
568 while (read($fh, $buf, 8192)) {
569 $bytes .= $buf;
570 last if length($bytes) > $cap;
571 }
572 return '' unless length($bytes);
573
574 # Generate an opaque id + extension. Detect a few common types.
575 my @hex = ('0'..'9', 'a'..'f');
576 my $id = '';
577 $id .= $hex[int(rand(@hex))] for 1..24;
578
579 my $ext = 'bin';
580 if (substr($bytes, 0, 8) eq "\x89PNG\r\n\x1a\n") { $ext = 'png'; }
581 elsif (substr($bytes, 0, 3) eq "\xff\xd8\xff") { $ext = 'jpg'; }
582 elsif (substr($bytes, 0, 4) eq 'GIF8') { $ext = 'gif'; }
583 elsif (substr($bytes, 0, 4) eq 'RIFF' &&
584 substr($bytes, 8, 4) eq 'WEBP') { $ext = 'webp'; }
585
586 my $dir = '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com/uploads';
587 if (-d $dir) {
588 my $path = "$dir/exp_$id.$ext";
589 if (open(my $w, '>', $path)) {
590 binmode $w;
591 print $w $bytes;
592 close $w;
593 return "/uploads/exp_$id.$ext";
594 }
595 }
596 return '';
597}
598
599# Build the variants JSON we store in ab_tests.variants. Each variant
600# has id, name, traffic_pct, changes:{text,image_url}. All string
601# values get JSON-escaped.
602sub _variants_json {
603 my @variants = @_;
604 my @parts;
605 foreach my $v (@variants) {
606 my $text = _json_str($v->{text} // '');
607 my $img = _json_str($v->{image_url} // '');
608 my $name = _json_str($v->{name} // '');
609 my $id = _json_str($v->{id} // '');
610 my $pct = ($v->{traffic_pct} || 0) + 0;
611 # target_model_id is 0 for non-product_tile experiments. Always
612 # written so the parser sees a consistent shape.
613 my $tid_model = ($v->{target_model_id} || 0) + 0;
614 # Design fields. Always written (as 0 when not set) so the
615 # parser in Experiments.pm and optimization.cgi sees a
616 # deterministic shape regardless of surface type.
617 my $lid = ($v->{layout_id} || 0) + 0;
618 my $tid_theme = ($v->{theme_id} || 0) + 0;
619 my $ctid = ($v->{custom_theme_id} || 0) + 0;
620 # storefront_page A/B test pointer.
621 my $vpid = ($v->{variant_page_id} || 0) + 0;
622 # Per-block MVT overrides. Stored as a JSON-escaped string so
623 # the outer parser only has to read it as a single field; the
624 # block content can contain commas, colons, and braces without
625 # breaking the variant boundary.
626 my $blocks_raw = $v->{block_overrides_json} // '';
627 my $blocks = _json_str($blocks_raw);
628 push @parts,
629 qq~{"id":"$id","name":"$name","traffic_pct":$pct,~ .
630 qq~"target_model_id":$tid_model,~ .
631 qq~"changes":{"text":"$text","image_url":"$img",~ .
632 qq~"layout_id":$lid,"theme_id":$tid_theme,"custom_theme_id":$ctid,~ .
633 qq~"variant_page_id":$vpid,"block_overrides":"$blocks"}}~;
634 }
635 return '[' . join(',', @parts) . ']';
636}
637
638sub _json_str {
639 my $s = shift;
640 $s = '' unless defined $s;
641 $s =~ s/\\/\\\\/g;
642 $s =~ s/"/\\"/g;
643 $s =~ s/\r/\\r/g;
644 $s =~ s/\n/\\n/g;
645 $s =~ s/\t/\\t/g;
646 return $s;
647}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help