added on local at 2026-07-01 21:47:31
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # RePricer -- add a model to the cart. | |
| 4 | # | |
| 5 | # POST params: | |
| 6 | # storefront_id = which storefront's cart | |
| 7 | # model_id = which model to add | |
| 8 | # qty = quantity (default 1) | |
| 9 | # | |
| 10 | # Returns JSON { success: 1, count: N } where N is the new total | |
| 11 | # quantity in the cart for that storefront. Sets repricer_cart cookie | |
| 12 | # on first use. | |
| 13 | #====================================================================== | |
| 14 | use strict; | |
| 15 | use warnings; | |
| 16 | ||
| 17 | use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com'; | |
| 18 | use CGI; | |
| 19 | use MODS::DBConnect; | |
| 20 | use MODS::RePricer::Config; | |
| 21 | use MODS::RePricer::Cart; | |
| 22 | use MODS::RePricer::Experiments; | |
| 23 | ||
| 24 | $| = 1; | |
| 25 | ||
| 26 | my $q = CGI->new; | |
| 27 | my $db = MODS::DBConnect->new; | |
| 28 | my $config = MODS::RePricer::Config->new; | |
| 29 | my $cart = MODS::RePricer::Cart->new; | |
| 30 | my $DB = $config->settings('database_name'); | |
| 31 | ||
| 32 | our $DONE = 0; | |
| 33 | END { | |
| 34 | if (!$DONE) { | |
| 35 | print "Content-Type: application/json\nCache-Control: no-cache\n\n"; | |
| 36 | print qq~{"success":0,"error":"server aborted -- check MODS/DB_ERRORS.log"}~; | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | sub fail { | |
| 41 | my ($msg, $cookie_line) = @_; | |
| 42 | $msg =~ s/"/\\"/g; | |
| 43 | print "Content-Type: application/json\nCache-Control: no-cache\n"; | |
| 44 | print "Set-Cookie: $cookie_line\n" if $cookie_line; | |
| 45 | print "\n"; | |
| 46 | print qq~{"success":0,"error":"$msg"}~; | |
| 47 | $DONE = 1; | |
| 48 | exit; | |
| 49 | } | |
| 50 | ||
| 51 | my $sid = $q->param('storefront_id') || 0; | |
| 52 | $sid =~ s/[^0-9]//g; | |
| 53 | fail('missing storefront_id') unless $sid; | |
| 54 | ||
| 55 | my $mid = $q->param('model_id') || 0; | |
| 56 | $mid =~ s/[^0-9]//g; | |
| 57 | my $bundle_id = $q->param('bundle_id') || 0; | |
| 58 | $bundle_id =~ s/[^0-9]//g; | |
| 59 | fail('missing model_id or bundle_id') unless $mid || $bundle_id; | |
| 60 | ||
| 61 | my $qty = $q->param('qty') || 1; | |
| 62 | $qty =~ s/[^0-9]//g; | |
| 63 | $qty = 1 unless $qty; | |
| 64 | $qty = 1 if $qty < 1; | |
| 65 | $qty = 99 if $qty > 99; | |
| 66 | ||
| 67 | # Optional variant params -- buyer chose "Digital file" / "Printed | |
| 68 | # copy" + color + material on the listing-detail page. All optional; | |
| 69 | # missing values resolve to digital + empty strings, which match the | |
| 70 | # default behavior for digital-only listings. | |
| 71 | my $kind = lc($q->param('kind') || ''); | |
| 72 | $kind =~ s/[^a-z]//g; | |
| 73 | $kind = '' unless $kind eq 'digital' || $kind eq 'physical'; | |
| 74 | ||
| 75 | sub _safe_variant { | |
| 76 | my $s = shift; $s = '' unless defined $s; | |
| 77 | $s =~ s/[^A-Za-z0-9 _,.\/\(\)\-]//g; # whitelist; no quotes | |
| 78 | $s =~ s/^\s+|\s+$//g; | |
| 79 | return substr($s, 0, 80); | |
| 80 | } | |
| 81 | my $color = _safe_variant($q->param('color') || ''); | |
| 82 | my $material = _safe_variant($q->param('material') || ''); | |
| 83 | ||
| 84 | my ($token, $cookie_line) = $cart->ensure_token($q); | |
| 85 | ||
| 86 | my $dbh = $db->db_connect(); | |
| 87 | ||
| 88 | # Probe for the variant columns on cart_items -- legacy installs | |
| 89 | # (pre-migration) don't have them yet and we degrade to digital-only | |
| 90 | # behavior instead of crashing add-to-cart. | |
| 91 | my $have_variant_cols = 0; | |
| 92 | { | |
| 93 | my $r = $db->db_readwrite($dbh, qq~ | |
| 94 | SELECT COUNT(*) AS n FROM information_schema.columns | |
| 95 | WHERE table_schema='$DB' AND table_name='cart_items' | |
| 96 | AND column_name='purchase_kind' | |
| 97 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 98 | $have_variant_cols = 1 if $r && $r->{n}; | |
| 99 | } | |
| 100 | my $have_bundle_cols = 0; | |
| 101 | { | |
| 102 | my $r = $db->db_readwrite($dbh, qq~ | |
| 103 | SELECT COUNT(*) AS n FROM information_schema.columns | |
| 104 | WHERE table_schema='$DB' AND table_name='cart_items' | |
| 105 | AND column_name='bundle_id' | |
| 106 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 107 | $have_bundle_cols = 1 if $r && $r->{n}; | |
| 108 | } | |
| 109 | ||
| 110 | # Bundle branch -- if the buyer is adding a bundle, look it up, | |
| 111 | # verify it belongs to this storefront + is published, and insert a | |
| 112 | # bundle-tagged cart row. Skip the per-model logic that follows. | |
| 113 | if ($bundle_id) { | |
| 114 | unless ($have_bundle_cols) { | |
| 115 | $db->db_disconnect($dbh); | |
| 116 | fail('bundles migration not run yet', $cookie_line); | |
| 117 | } | |
| 118 | my $bun = $db->db_readwrite($dbh, qq~ | |
| 119 | SELECT id, title, product_kind, base_price_cents, physical_price_cents, | |
| 120 | inventory_qty, status, storefront_id | |
| 121 | FROM `${DB}`.bundles | |
| 122 | WHERE id='$bundle_id' AND storefront_id='$sid' LIMIT 1 | |
| 123 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 124 | unless ($bun && $bun->{id} && $bun->{status} eq 'published') { | |
| 125 | $db->db_disconnect($dbh); | |
| 126 | fail('bundle not available', $cookie_line); | |
| 127 | } | |
| 128 | my $bun_kind = $bun->{product_kind} || 'digital'; | |
| 129 | my $eff_kind = $kind; | |
| 130 | if (!$eff_kind) { | |
| 131 | $eff_kind = ($bun_kind eq 'physical') ? 'physical' : 'digital'; | |
| 132 | } | |
| 133 | if ($eff_kind eq 'physical' && $bun_kind eq 'digital') { | |
| 134 | $db->db_disconnect($dbh); | |
| 135 | fail('this bundle is digital-only', $cookie_line); | |
| 136 | } | |
| 137 | if ($eff_kind eq 'digital' && $bun_kind eq 'physical') { | |
| 138 | $db->db_disconnect($dbh); | |
| 139 | fail('this bundle is print-only', $cookie_line); | |
| 140 | } | |
| 141 | # Sold-out check for physical bundles with finite stock. | |
| 142 | if ($eff_kind eq 'physical' && defined $bun->{inventory_qty}) { | |
| 143 | my $inv = $bun->{inventory_qty}; | |
| 144 | if ($inv == 0) { | |
| 145 | $db->db_disconnect($dbh); | |
| 146 | fail('this bundle is sold out', $cookie_line); | |
| 147 | } | |
| 148 | $qty = $inv if $inv > 0 && $qty > $inv; | |
| 149 | } | |
| 150 | my $price = ($eff_kind eq 'physical') | |
| 151 | ? ($bun->{physical_price_cents} || 0) | |
| 152 | : ($bun->{base_price_cents} || 0); | |
| 153 | my $color_esc = $color; $color_esc =~ s/'/''/g; | |
| 154 | my $material_esc = $material; $material_esc =~ s/'/''/g; | |
| 155 | # Bundle cart row -- model_id NULL, bundle_id set. The UNIQUE on | |
| 156 | # cart_items (cart_token, storefront_id, model_id, kind, color, mat) | |
| 157 | # ignores NULL, so two distinct bundles in the same cart will both | |
| 158 | # get rows; same bundle twice bumps quantity via ON DUPLICATE for | |
| 159 | # the bundle_id index match... but with NULL model_id, ON DUPLICATE | |
| 160 | # won't fire and we'd get a 2nd row. Pre-check + UPDATE instead. | |
| 161 | my $exist = $db->db_readwrite($dbh, qq~ | |
| 162 | SELECT id FROM `${DB}`.cart_items | |
| 163 | WHERE cart_token='$token' AND storefront_id='$sid' | |
| 164 | AND bundle_id='$bundle_id' AND purchase_kind='$eff_kind' | |
| 165 | AND selected_color='$color_esc' AND selected_material='$material_esc' | |
| 166 | LIMIT 1 | |
| 167 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 168 | if ($exist && $exist->{id}) { | |
| 169 | my $eid = $exist->{id}; | |
| 170 | $db->db_readwrite($dbh, qq~ | |
| 171 | UPDATE `${DB}`.cart_items | |
| 172 | SET quantity = quantity + $qty, unit_price_cents='$price' | |
| 173 | WHERE id='$eid' LIMIT 1 | |
| 174 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 175 | } else { | |
| 176 | $db->db_readwrite($dbh, qq~ | |
| 177 | INSERT INTO `${DB}`.cart_items SET | |
| 178 | cart_token = '$token', | |
| 179 | storefront_id = '$sid', | |
| 180 | model_id = NULL, | |
| 181 | bundle_id = '$bundle_id', | |
| 182 | purchase_kind = '$eff_kind', | |
| 183 | selected_color = '$color_esc', | |
| 184 | selected_material= '$material_esc', | |
| 185 | quantity = '$qty', | |
| 186 | unit_price_cents = '$price', | |
| 187 | added_at = NOW() | |
| 188 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 189 | } | |
| 190 | my $count = $cart->count($db, $dbh, $DB, $token, $sid); | |
| 191 | $db->db_disconnect($dbh); | |
| 192 | print "Content-Type: application/json\nCache-Control: no-cache\n"; | |
| 193 | print "Set-Cookie: $cookie_line\n" if $cookie_line; | |
| 194 | print "\n"; | |
| 195 | print qq~{"success":1,"count":$count,"bundle":1}~; | |
| 196 | $DONE = 1; | |
| 197 | exit; | |
| 198 | } | |
| 199 | my $have_phys_model_cols = 0; | |
| 200 | { | |
| 201 | my $r = $db->db_readwrite($dbh, qq~ | |
| 202 | SELECT COUNT(*) AS n FROM information_schema.columns | |
| 203 | WHERE table_schema='$DB' AND table_name='models' | |
| 204 | AND column_name='product_kind' | |
| 205 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 206 | $have_phys_model_cols = 1 if $r && $r->{n}; | |
| 207 | } | |
| 208 | ||
| 209 | # Verify the model exists, is published, and belongs to a listing on | |
| 210 | # this storefront. Refuse anything else -- buyers shouldn't be able to | |
| 211 | # put drafts or other people's purged models in their cart. | |
| 212 | my $phys_select = $have_phys_model_cols | |
| 213 | ? q~m.product_kind, m.physical_price_cents, m.inventory_qty,~ | |
| 214 | : ''; | |
| 215 | my $row = $db->db_readwrite($dbh, qq~ | |
| 216 | SELECT m.id, m.base_price_cents, m.status, m.purged_at, | |
| 217 | $phys_select | |
| 218 | sl.override_price_cents | |
| 219 | FROM `${DB}`.models m | |
| 220 | JOIN `${DB}`.storefront_listings sl | |
| 221 | ON sl.model_id = m.id AND sl.storefront_id = '$sid' AND sl.visible = 1 | |
| 222 | WHERE m.id = '$mid' | |
| 223 | AND m.status = 'published' | |
| 224 | AND m.purged_at IS NULL | |
| 225 | LIMIT 1 | |
| 226 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 227 | unless ($row && $row->{id}) { | |
| 228 | $db->db_disconnect($dbh); | |
| 229 | fail('model not available', $cookie_line); | |
| 230 | } | |
| 231 | ||
| 232 | # Resolve the effective purchase kind. A buyer who clicks Add to Cart | |
| 233 | # without picking a kind (e.g. from a storefront card on a 'both' | |
| 234 | # listing) gets the cheaper one auto-picked so a click never errors. | |
| 235 | my $product_kind = $have_phys_model_cols | |
| 236 | ? ($row->{product_kind} || 'digital') | |
| 237 | : 'digital'; | |
| 238 | my $base_cents = $row->{override_price_cents} || $row->{base_price_cents} || 0; | |
| 239 | my $phys_cents = $row->{physical_price_cents} || 0; | |
| 240 | my $effective_kind = $kind; | |
| 241 | if (!$effective_kind) { | |
| 242 | if ($product_kind eq 'physical') { $effective_kind = 'physical'; } | |
| 243 | elsif ($product_kind eq 'both') { | |
| 244 | # For 'both' with no chosen kind: prefer digital (lower | |
| 245 | # friction -- no shipping needed). Buyer who wants the | |
| 246 | # printed copy uses the kind toggle on the detail page. | |
| 247 | $effective_kind = 'digital'; | |
| 248 | } else { $effective_kind = 'digital'; } | |
| 249 | } | |
| 250 | # Reject a kind the listing doesn't actually offer. | |
| 251 | if ($effective_kind eq 'physical' && $product_kind eq 'digital') { | |
| 252 | $db->db_disconnect($dbh); | |
| 253 | fail('this listing does not offer a printed copy', $cookie_line); | |
| 254 | } | |
| 255 | if ($effective_kind eq 'digital' && $product_kind eq 'physical') { | |
| 256 | $db->db_disconnect($dbh); | |
| 257 | fail('this listing is print-only', $cookie_line); | |
| 258 | } | |
| 259 | ||
| 260 | # Sold-out check for physical lines. | |
| 261 | if ($effective_kind eq 'physical' && defined $row->{inventory_qty}) { | |
| 262 | my $inv = $row->{inventory_qty}; | |
| 263 | if ($inv == 0) { | |
| 264 | $db->db_disconnect($dbh); | |
| 265 | fail('this item is sold out', $cookie_line); | |
| 266 | } | |
| 267 | # If finite stock and quantity asked exceeds it, clamp. | |
| 268 | if ($inv > 0 && $qty > $inv) { | |
| 269 | $qty = $inv; | |
| 270 | } | |
| 271 | } | |
| 272 | ||
| 273 | my $price = ($effective_kind eq 'physical') ? $phys_cents : $base_cents; | |
| 274 | ||
| 275 | # INSERT ... SET ... ON DUPLICATE KEY UPDATE bumps quantity if the | |
| 276 | # same variant of this model is already in the cart (UNIQUE on | |
| 277 | # cart_token + storefront + model + purchase_kind + color + material). | |
| 278 | if ($have_variant_cols) { | |
| 279 | my $color_esc = $color; $color_esc =~ s/'/''/g; | |
| 280 | my $material_esc = $material; $material_esc =~ s/'/''/g; | |
| 281 | $db->db_readwrite($dbh, qq~ | |
| 282 | INSERT INTO `${DB}`.cart_items SET | |
| 283 | cart_token = '$token', | |
| 284 | storefront_id = '$sid', | |
| 285 | model_id = '$mid', | |
| 286 | purchase_kind = '$effective_kind', | |
| 287 | selected_color = '$color_esc', | |
| 288 | selected_material = '$material_esc', | |
| 289 | quantity = '$qty', | |
| 290 | unit_price_cents = '$price', | |
| 291 | added_at = NOW() | |
| 292 | ON DUPLICATE KEY UPDATE | |
| 293 | quantity = quantity + $qty, | |
| 294 | unit_price_cents = '$price' | |
| 295 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 296 | } else { | |
| 297 | # Legacy install -- variant columns missing. Fall through to the | |
| 298 | # original digital-only behavior so we don't crash; the new fields | |
| 299 | # land once the buyer runs the migration. | |
| 300 | $db->db_readwrite($dbh, qq~ | |
| 301 | INSERT INTO `${DB}`.cart_items SET | |
| 302 | cart_token = '$token', | |
| 303 | storefront_id = '$sid', | |
| 304 | model_id = '$mid', | |
| 305 | quantity = '$qty', | |
| 306 | unit_price_cents = '$price', | |
| 307 | added_at = NOW() | |
| 308 | ON DUPLICATE KEY UPDATE | |
| 309 | quantity = quantity + $qty, | |
| 310 | unit_price_cents = '$price' | |
| 311 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 312 | } | |
| 313 | ||
| 314 | my $count = $cart->count($db, $dbh, $DB, $token, $sid); | |
| 315 | ||
| 316 | # A/B + MVT: log an add_to_cart event for every experiment this | |
| 317 | # visitor is bucketed into. The Experiments helper resolves | |
| 318 | # assignments deterministically from the visitor cookie, so the | |
| 319 | # event lands on the variant the buyer is actually seeing. | |
| 320 | my $ex = MODS::RePricer::Experiments->new; | |
| 321 | my $vtok = $ex->read_visitor($q); | |
| 322 | if ($vtok) { | |
| 323 | my $vh = $ex->visitor_hash($vtok); | |
| 324 | my $cents = $price * $qty; | |
| 325 | $ex->log_event_for_visitor_experiments( | |
| 326 | $db, $dbh, $DB, $sid, $vh, 'add_to_cart', | |
| 327 | sprintf('%.2f', $cents / 100) | |
| 328 | ); | |
| 329 | } | |
| 330 | ||
| 331 | $db->db_disconnect($dbh); | |
| 332 | ||
| 333 | print "Content-Type: application/json\nCache-Control: no-cache\n"; | |
| 334 | print "Set-Cookie: $cookie_line\n" if $cookie_line; | |
| 335 | print "\n"; | |
| 336 | print qq~{"success":1,"count":$count}~; | |
| 337 | $DONE = 1; | |
| 338 | exit; |