added on WebSTLs (webstls.com) at 2026-07-01 22:27:09
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # WebSTLs -- JSON micro-endpoints for in-place editors. | |
| 4 | # | |
| 5 | # One CGI, three entry points (dispatched on SCRIPT_NAME): | |
| 6 | # /update_model_field.cgi -- update a single field on a models row | |
| 7 | # /update_storefront_field.cgi -- update a single field on a storefronts row | |
| 8 | # /update_listing.cgi -- update a storefront_listings row (action-based) | |
| 9 | # | |
| 10 | # The .cgi files for the other two entries are 3-line wrappers that | |
| 11 | # `do` this script. All three share the same JSON response shape, the | |
| 12 | # same END-block safety net, and the same auth pattern. | |
| 13 | # | |
| 14 | # Response shape: | |
| 15 | # { "success": 1, ... } on success | |
| 16 | # { "success": 0, "error": ... } on failure | |
| 17 | #====================================================================== | |
| 18 | use strict; | |
| 19 | use warnings; | |
| 20 | ||
| 21 | use lib '/var/www/vhosts/webstls.com/httpdocs'; | |
| 22 | use CGI; | |
| 23 | use MODS::DBConnect; | |
| 24 | use MODS::Login; | |
| 25 | use MODS::WebSTLs::Config; | |
| 26 | ||
| 27 | $| = 1; | |
| 28 | ||
| 29 | my $q = CGI->new; | |
| 30 | my $db = MODS::DBConnect->new; | |
| 31 | my $auth = MODS::Login->new; | |
| 32 | my $config = MODS::WebSTLs::Config->new; | |
| 33 | my $DB = $config->settings('database_name'); | |
| 34 | ||
| 35 | print "Content-Type: application/json\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 36 | ||
| 37 | # DBConnect's error() helper calls exit() on a failing SQL call, which | |
| 38 | # bypasses any eval{} and leaves the response body empty. END block | |
| 39 | # emits a JSON message if that happens so the editor never gets a | |
| 40 | # silent 200/empty. | |
| 41 | our $SAVE_DONE = 0; | |
| 42 | END { | |
| 43 | if (!$SAVE_DONE) { | |
| 44 | print qq~{"success":0,"error":"server aborted mid-update -- check MODS/DB_ERRORS.log for the failing SQL and DBI message"}~; | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | sub _upd_fail { | |
| 49 | my ($msg) = @_; | |
| 50 | $msg =~ s/"/\\"/g; | |
| 51 | print qq~{"success":0,"error":"$msg"}~; | |
| 52 | $SAVE_DONE = 1; | |
| 53 | exit; | |
| 54 | } | |
| 55 | ||
| 56 | my $userinfo = $auth->login_verify(); | |
| 57 | _upd_fail('not authenticated') unless $userinfo && $userinfo->{user_id}; | |
| 58 | require MODS::WebSTLs::Permissions; | |
| 59 | ||
| 60 | my $uid = $userinfo->{user_id}; | |
| 61 | $uid =~ s/[^0-9]//g; | |
| 62 | _upd_fail('bad user id') unless $uid; | |
| 63 | ||
| 64 | # Dispatch on SCRIPT_NAME. Each entry gates on the appropriate | |
| 65 | # permission feature *inside* its handler so we don't reject a caller | |
| 66 | # that hits us via the "wrong" script name accidentally. | |
| 67 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'update_model_field'; | |
| 68 | ||
| 69 | if ($entry eq 'update_storefront_field') { _upd_handle_storefront_field(); } | |
| 70 | elsif ($entry eq 'update_listing') { _upd_handle_listing(); } | |
| 71 | else { _upd_handle_model_field(); } | |
| 72 | exit; | |
| 73 | ||
| 74 | #====================================================================== | |
| 75 | # update_model_field entry: update a single field on a models row. | |
| 76 | # | |
| 77 | # POST params: | |
| 78 | # field = one of: title, price, tagline, description, | |
| 79 | # hero_image_url, hero_image_pos, category, license_type, | |
| 80 | # status, visibility | |
| 81 | # value = new value (string) | |
| 82 | # model_id = which model to update | |
| 83 | # | |
| 84 | # Used by the in-place storefront editor for the FEATURED product | |
| 85 | # (model-level fields like title and price). Authorization: the model | |
| 86 | # must belong to the logged-in user. | |
| 87 | #====================================================================== | |
| 88 | sub _upd_handle_model_field { | |
| 89 | _upd_fail('not authorized') unless MODS::WebSTLs::Permissions->new->has_feature($userinfo, 'edit_models'); | |
| 90 | ||
| 91 | # Whitelist: client field name => SQL column. The "price" alias accepts | |
| 92 | # a user-typed dollar amount and we convert it to base_price_cents. | |
| 93 | my %ALLOWED = ( | |
| 94 | title => 'title', | |
| 95 | price => 'base_price_cents', | |
| 96 | tagline => 'tagline', | |
| 97 | description => 'description', | |
| 98 | category => 'category', | |
| 99 | license_type => 'license_type', | |
| 100 | status => 'status', | |
| 101 | visibility => 'visibility', | |
| 102 | hero_image_url => 'hero_image_url', | |
| 103 | hero_image_pos => 'hero_image_pos', | |
| 104 | ); | |
| 105 | ||
| 106 | my %MAX_LEN = ( | |
| 107 | title => 200, | |
| 108 | tagline => 180, | |
| 109 | description => 32000, | |
| 110 | category => 80, | |
| 111 | license_type => 80, | |
| 112 | hero_image_url => 255, | |
| 113 | hero_image_pos => 20, | |
| 114 | ); | |
| 115 | ||
| 116 | # Enum whitelists for fields that have constrained DB values. | |
| 117 | my %ENUM = ( | |
| 118 | status => { map { $_ => 1 } qw(draft published unlisted archived removed) }, | |
| 119 | visibility => { map { $_ => 1 } qw(public unlisted private) }, | |
| 120 | ); | |
| 121 | ||
| 122 | my $field = $q->param('field') || ''; | |
| 123 | _upd_fail('missing field') unless $field; | |
| 124 | _upd_fail("field '$field' is not editable") unless exists $ALLOWED{$field}; | |
| 125 | my $column = $ALLOWED{$field}; | |
| 126 | ||
| 127 | my $model_id = $q->param('model_id') || ''; | |
| 128 | $model_id =~ s/[^0-9]//g; | |
| 129 | _upd_fail('missing or bad model_id') unless $model_id; | |
| 130 | ||
| 131 | my $value = $q->param('value'); | |
| 132 | $value = '' unless defined $value; | |
| 133 | ||
| 134 | # Field-specific parsing. | |
| 135 | my $sql_value; | |
| 136 | if ($field eq 'price') { | |
| 137 | # Accept "$15", "15.00", "15.5" -> 1500 cents. Strip non-numeric | |
| 138 | # except for one decimal point. | |
| 139 | my $raw = $value; | |
| 140 | $raw =~ s/[^0-9.]//g; | |
| 141 | my $dollars = $raw + 0; | |
| 142 | if ($dollars < 0) { $dollars = 0; } | |
| 143 | my $cents = int($dollars * 100 + 0.5); | |
| 144 | $sql_value = "'$cents'"; | |
| 145 | } elsif ($ENUM{$field}) { | |
| 146 | # Constrained-value column. Reject anything not in the whitelist. | |
| 147 | my $v = lc($value); $v =~ s/[^a-z]//g; | |
| 148 | _upd_fail("invalid $field value") unless $ENUM{$field}{$v}; | |
| 149 | $sql_value = "'$v'"; | |
| 150 | } else { | |
| 151 | my $cap = $MAX_LEN{$field} || 255; | |
| 152 | $value = substr($value, 0, $cap) if length($value) > $cap; | |
| 153 | my $safe = $value; | |
| 154 | $safe =~ s/\\/\\\\/g; | |
| 155 | $safe =~ s/'/\\'/g; | |
| 156 | $sql_value = "'$safe'"; | |
| 157 | } | |
| 158 | ||
| 159 | # Verify the model belongs to this user before writing. Reject writes | |
| 160 | # to permanently-deleted (purged) models -- those are visible only to | |
| 161 | # admin tooling; the user can't reach them anymore. | |
| 162 | my $dbh = $db->db_connect(); | |
| 163 | my $owner = $db->db_readwrite($dbh, | |
| 164 | qq~SELECT user_id, purged_at FROM `${DB}`.models WHERE id='$model_id' LIMIT 1~, | |
| 165 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 166 | unless ($owner && $owner->{user_id} && $owner->{user_id} eq $uid) { | |
| 167 | $db->db_disconnect($dbh); | |
| 168 | _upd_fail('model not found or not yours'); | |
| 169 | } | |
| 170 | if ($owner->{purged_at}) { | |
| 171 | $db->db_disconnect($dbh); | |
| 172 | _upd_fail('model has been permanently deleted'); | |
| 173 | } | |
| 174 | ||
| 175 | # Wrap UPDATE so a missing column gets reported back via the END block. | |
| 176 | my $err; | |
| 177 | eval { | |
| 178 | $db->db_readwrite($dbh, qq~ | |
| 179 | UPDATE `${DB}`.models | |
| 180 | SET `$column` = $sql_value, updated_at = NOW() | |
| 181 | WHERE id = '$model_id' LIMIT 1 | |
| 182 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 183 | 1; | |
| 184 | } or do { $err = $@ || 'unknown SQL error'; }; | |
| 185 | ||
| 186 | # When the seller flips status to 'published' from this endpoint | |
| 187 | # (the My Listings inline PUBLISH button), we also have to insert a | |
| 188 | # storefront_listings row -- otherwise the model is "published" in | |
| 189 | # the DB but never shows on the seller's storefront because the | |
| 190 | # storefront query joins storefront_listings WHERE visible=1. | |
| 191 | # upload.cgi has the equivalent auto-list block for the form-driven | |
| 192 | # publish path; this mirrors it for the AJAX path so the two flows | |
| 193 | # stay in sync. INSERT IGNORE makes it safe to call repeatedly -- | |
| 194 | # the unique key on (storefront_id, model_id) absorbs duplicates, | |
| 195 | # so an already-listed model just gets a no-op. | |
| 196 | if (!$err && $field eq 'status' && $value && lc($value) eq 'published') { | |
| 197 | eval { | |
| 198 | my $sf = $db->db_readwrite($dbh, | |
| 199 | qq~SELECT id FROM `${DB}`.storefronts WHERE user_id='$uid' LIMIT 1~, | |
| 200 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 201 | if ($sf && $sf->{id}) { | |
| 202 | my $sid = $sf->{id}; $sid =~ s/[^0-9]//g; | |
| 203 | # Put the newly-published model at the top of the | |
| 204 | # storefront order. Mirrors upload.cgi -- new published | |
| 205 | # work is what the seller wants the buyer to see first. | |
| 206 | my $top = $db->db_readwrite($dbh, qq~ | |
| 207 | SELECT COALESCE(MIN(sort_order), 0) - 1 AS new_top | |
| 208 | FROM `${DB}`.storefront_listings | |
| 209 | WHERE storefront_id='$sid' | |
| 210 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 211 | my $top_sort = ($top && defined $top->{new_top}) ? $top->{new_top} : 0; | |
| 212 | $db->db_readwrite($dbh, qq~ | |
| 213 | INSERT IGNORE INTO `${DB}`.storefront_listings SET | |
| 214 | storefront_id = '$sid', | |
| 215 | model_id = '$model_id', | |
| 216 | visible = 1, | |
| 217 | override_price_cents = NULL, | |
| 218 | sort_order = '$top_sort', | |
| 219 | added_at = NOW() | |
| 220 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 221 | } | |
| 222 | 1; | |
| 223 | } or do { | |
| 224 | # Auto-listing failed but the status update itself succeeded. | |
| 225 | # Don't surface the secondary failure -- the seller can still | |
| 226 | # toggle visibility manually from My Listings. Just log via | |
| 227 | # the END block path so we know what happened. | |
| 228 | $err = "status updated but auto-list failed: " . ($@ || 'unknown'); | |
| 229 | }; | |
| 230 | } | |
| 231 | ||
| 232 | $db->db_disconnect($dbh); | |
| 233 | ||
| 234 | if ($err) { | |
| 235 | my $msg = $err; | |
| 236 | $msg =~ s/[\r\n]+/ /g; | |
| 237 | $msg =~ s/"/\\"/g; | |
| 238 | my $hint = ''; | |
| 239 | if ($msg =~ /Unknown column/i) { | |
| 240 | $hint = " (the column may not exist yet -- run the models ALTER from installation_instructions.html)"; | |
| 241 | } | |
| 242 | print qq~{"success":0,"error":"SQL error: $msg$hint"}~; | |
| 243 | $SAVE_DONE = 1; | |
| 244 | return; | |
| 245 | } | |
| 246 | ||
| 247 | print qq~{"success":1,"field":"$field","model_id":"$model_id"}~; | |
| 248 | $SAVE_DONE = 1; | |
| 249 | return; | |
| 250 | } | |
| 251 | ||
| 252 | #====================================================================== | |
| 253 | # update_storefront_field entry: update a single storefront field. | |
| 254 | # | |
| 255 | # POST params: | |
| 256 | # field = one of: name, tagline, about_text, hero_image_1/2/3, ... | |
| 257 | # value = new value (string) | |
| 258 | # | |
| 259 | # Used by the page editor's in-place WYSIWYG. On blur of an editable | |
| 260 | # element (or after an image is uploaded and selected) the iframe POSTs | |
| 261 | # here. We auth the user, verify they own the storefront, whitelist | |
| 262 | # the field, and UPDATE the row. | |
| 263 | #====================================================================== | |
| 264 | sub _upd_handle_storefront_field { | |
| 265 | _upd_fail('not authorized') unless MODS::WebSTLs::Permissions->new->has_feature($userinfo, 'edit_storefront'); | |
| 266 | ||
| 267 | # Whitelist of fields that can be updated through this endpoint. | |
| 268 | # Keys are the public field name (what the client sends); the value | |
| 269 | # is the SQL column to write (defense against column-injection even | |
| 270 | # though we sanitize separately). | |
| 271 | my %ALLOWED = ( | |
| 272 | name => 'name', | |
| 273 | tagline => 'tagline', | |
| 274 | about_text => 'about_text', | |
| 275 | hero_image_1 => 'hero_image_1', | |
| 276 | hero_image_2 => 'hero_image_2', | |
| 277 | hero_image_3 => 'hero_image_3', | |
| 278 | hero_image_1_pos => 'hero_image_1_pos', | |
| 279 | hero_image_2_pos => 'hero_image_2_pos', | |
| 280 | hero_image_3_pos => 'hero_image_3_pos', | |
| 281 | cta_primary_label => 'cta_primary_label', | |
| 282 | cta_secondary_label => 'cta_secondary_label', | |
| 283 | featured_badge_label => 'featured_badge_label', | |
| 284 | section_heading => 'section_heading', | |
| 285 | footer_byline => 'footer_byline', | |
| 286 | featured_title => 'featured_title', | |
| 287 | featured_price => 'featured_price', | |
| 288 | featured_hero_image => 'featured_hero_image', | |
| 289 | featured_hero_pos => 'featured_hero_pos', | |
| 290 | # Catalog-layout hero chrome -- added with the | |
| 291 | # "edit everything on the page" pass. Schema-guarded in store.cgi | |
| 292 | # (NULL falls back to the layout default until the seller overrides). | |
| 293 | hero_title => 'hero_title', | |
| 294 | hero_pill_label => 'hero_pill_label', | |
| 295 | hero_cta_primary_label => 'hero_cta_primary_label', | |
| 296 | hero_cta_secondary_label => 'hero_cta_secondary_label', | |
| 297 | section_eyebrow => 'section_eyebrow', | |
| 298 | header_signin_label => 'header_signin_label', | |
| 299 | header_cart_label => 'header_cart_label', | |
| 300 | # About-section + footer trust strip -- editable on every layout. | |
| 301 | about_image => 'about_image', | |
| 302 | about_image_pos => 'about_image_pos', | |
| 303 | about_eyebrow => 'about_eyebrow', | |
| 304 | about_heading => 'about_heading', | |
| 305 | footer_trust => 'footer_trust', | |
| 306 | ); | |
| 307 | ||
| 308 | my $field = $q->param('field') || ''; | |
| 309 | _upd_fail('missing field') unless $field; | |
| 310 | _upd_fail("field '$field' is not editable") unless exists $ALLOWED{$field}; | |
| 311 | my $column = $ALLOWED{$field}; | |
| 312 | ||
| 313 | my $value = $q->param('value'); | |
| 314 | $value = '' unless defined $value; | |
| 315 | ||
| 316 | # Per-field size caps and basic sanitation. | |
| 317 | my %MAX_LEN = ( | |
| 318 | name => 120, | |
| 319 | tagline => 255, | |
| 320 | about_text => 4000, | |
| 321 | hero_image_1 => 255, | |
| 322 | hero_image_2 => 255, | |
| 323 | hero_image_3 => 255, | |
| 324 | hero_image_1_pos => 20, | |
| 325 | hero_image_2_pos => 20, | |
| 326 | hero_image_3_pos => 20, | |
| 327 | cta_primary_label => 60, | |
| 328 | cta_secondary_label => 60, | |
| 329 | featured_badge_label => 60, | |
| 330 | section_heading => 120, | |
| 331 | footer_byline => 160, | |
| 332 | featured_title => 200, | |
| 333 | featured_price => 40, | |
| 334 | featured_hero_image => 255, | |
| 335 | featured_hero_pos => 20, | |
| 336 | # Catalog-layout hero chrome -- added with the | |
| 337 | # "edit everything on the page" pass. Must mirror %ALLOWED above; | |
| 338 | # missing entries here cause substr() to clamp the value to '' and | |
| 339 | # silently wipe whatever the seller typed. | |
| 340 | hero_title => 200, | |
| 341 | hero_pill_label => 60, | |
| 342 | hero_cta_primary_label => 60, | |
| 343 | hero_cta_secondary_label => 60, | |
| 344 | section_eyebrow => 60, | |
| 345 | header_signin_label => 40, | |
| 346 | header_cart_label => 40, | |
| 347 | about_image => 255, | |
| 348 | about_image_pos => 20, | |
| 349 | about_eyebrow => 60, | |
| 350 | about_heading => 120, | |
| 351 | footer_trust => 255, | |
| 352 | ); | |
| 353 | $value = substr($value, 0, $MAX_LEN{$field}) if defined $MAX_LEN{$field} && length($value) > $MAX_LEN{$field}; | |
| 354 | ||
| 355 | # Find the user's storefront (single-storefront-per-user for now). | |
| 356 | my $dbh = $db->db_connect(); | |
| 357 | my $store = $db->db_readwrite($dbh, | |
| 358 | qq~SELECT id FROM `${DB}`.storefronts WHERE user_id='$uid' ORDER BY id LIMIT 1~, | |
| 359 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 360 | unless ($store && $store->{id}) { | |
| 361 | $db->db_disconnect($dbh); | |
| 362 | _upd_fail('no storefront for this user'); | |
| 363 | } | |
| 364 | my $sid = $store->{id}; | |
| 365 | ||
| 366 | # Escape ' and \ for the UPDATE. Other chars (HTML tags, line breaks, | |
| 367 | # etc.) are stored as-is -- the field rendering layer escapes for HTML. | |
| 368 | my $safe = $value; | |
| 369 | $safe =~ s/\\/\\\\/g; | |
| 370 | $safe =~ s/'/\\'/g; | |
| 371 | ||
| 372 | # Wrap the UPDATE so a missing column (or any other SQL error) gets | |
| 373 | # reported back to the editor as JSON instead of dying half-way and | |
| 374 | # producing the dreaded "Unexpected end of JSON input" on the client. | |
| 375 | my $err; | |
| 376 | eval { | |
| 377 | $db->db_readwrite($dbh, | |
| 378 | qq~UPDATE `${DB}`.storefronts | |
| 379 | SET `$column` = '$safe' | |
| 380 | WHERE id = '$sid' LIMIT 1~, | |
| 381 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 382 | 1; | |
| 383 | } or do { $err = $@ || 'unknown SQL error'; }; | |
| 384 | ||
| 385 | $db->db_disconnect($dbh); | |
| 386 | ||
| 387 | if ($err) { | |
| 388 | # Most likely cause: the storefronts table doesn't have the column | |
| 389 | # yet -- the schema migration hasn't been run on this database. | |
| 390 | my $msg = $err; | |
| 391 | $msg =~ s/[\r\n]+/ /g; | |
| 392 | $msg =~ s/"/\\"/g; | |
| 393 | my $hint = ''; | |
| 394 | if ($msg =~ /Unknown column/i) { | |
| 395 | $hint = " (the column may not exist yet -- run the storefronts ALTER from installation_instructions.html)"; | |
| 396 | } | |
| 397 | print qq~{"success":0,"error":"SQL error: $msg$hint"}~; | |
| 398 | $SAVE_DONE = 1; | |
| 399 | return; | |
| 400 | } | |
| 401 | ||
| 402 | print qq~{"success":1,"field":"$field"}~; | |
| 403 | $SAVE_DONE = 1; | |
| 404 | return; | |
| 405 | } | |
| 406 | ||
| 407 | #====================================================================== | |
| 408 | # update_listing entry: update a storefront_listings row. | |
| 409 | # | |
| 410 | # POST params: | |
| 411 | # action = feature | hide | show | toggle_visible | move_up | move_down | set_sort | |
| 412 | # model_id = which model's listing to update | |
| 413 | # value = (only used by set_sort) the new sort_order integer | |
| 414 | # | |
| 415 | # Authorization: the model must belong to the logged-in user AND the | |
| 416 | # user's storefront. Both are verified before any write. | |
| 417 | #====================================================================== | |
| 418 | sub _upd_handle_listing { | |
| 419 | _upd_fail('not authorized') unless MODS::WebSTLs::Permissions->new->has_feature($userinfo, 'edit_models'); | |
| 420 | ||
| 421 | my $action = lc($q->param('action') || ''); | |
| 422 | $action =~ s/[^a-z_]//g; | |
| 423 | _upd_fail('missing action') unless $action; | |
| 424 | ||
| 425 | my %ALLOWED_ACTIONS = map { $_ => 1 } qw(feature hide show toggle_visible move_up move_down set_sort); | |
| 426 | _upd_fail("unknown action '$action'") unless $ALLOWED_ACTIONS{$action}; | |
| 427 | ||
| 428 | my $model_id = $q->param('model_id') || ''; | |
| 429 | $model_id =~ s/[^0-9]//g; | |
| 430 | _upd_fail('missing or bad model_id') unless $model_id; | |
| 431 | ||
| 432 | my $dbh = $db->db_connect(); | |
| 433 | ||
| 434 | # Resolve the user's storefront. Single-storefront-per-user for now. | |
| 435 | my $store = $db->db_readwrite($dbh, | |
| 436 | qq~SELECT id FROM `${DB}`.storefronts WHERE user_id='$uid' ORDER BY id LIMIT 1~, | |
| 437 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 438 | unless ($store && $store->{id}) { | |
| 439 | $db->db_disconnect($dbh); | |
| 440 | _upd_fail('no storefront for this user'); | |
| 441 | } | |
| 442 | my $sid = $store->{id}; | |
| 443 | ||
| 444 | # Verify the model belongs to this user and isn't purged. | |
| 445 | my $owner = $db->db_readwrite($dbh, | |
| 446 | qq~SELECT user_id, purged_at FROM `${DB}`.models WHERE id='$model_id' LIMIT 1~, | |
| 447 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 448 | unless ($owner && $owner->{user_id} && $owner->{user_id} eq $uid) { | |
| 449 | $db->db_disconnect($dbh); | |
| 450 | _upd_fail('model not found or not yours'); | |
| 451 | } | |
| 452 | if ($owner->{purged_at}) { | |
| 453 | $db->db_disconnect($dbh); | |
| 454 | _upd_fail('model has been permanently deleted'); | |
| 455 | } | |
| 456 | ||
| 457 | # Make sure a listing row exists. Some actions (feature, show) should | |
| 458 | # auto-create the listing if it isn't there yet; "hide" on a missing | |
| 459 | # row is a no-op success. | |
| 460 | my $listing = $db->db_readwrite($dbh, | |
| 461 | qq~SELECT visible, sort_order FROM `${DB}`.storefront_listings | |
| 462 | WHERE storefront_id='$sid' AND model_id='$model_id' LIMIT 1~, | |
| 463 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 464 | ||
| 465 | if (!$listing || !defined $listing->{visible}) { | |
| 466 | if ($action eq 'hide') { | |
| 467 | $db->db_disconnect($dbh); | |
| 468 | print qq~{"success":1,"action":"$action","noop":1}~; | |
| 469 | $SAVE_DONE = 1; | |
| 470 | return; | |
| 471 | } | |
| 472 | # Create the row with a sensible default; feature/show will fix it up. | |
| 473 | $db->db_readwrite($dbh, qq~ | |
| 474 | INSERT IGNORE INTO `${DB}`.storefront_listings SET | |
| 475 | storefront_id = '$sid', | |
| 476 | model_id = '$model_id', | |
| 477 | visible = 1, | |
| 478 | override_price_cents = NULL, | |
| 479 | sort_order = 0, | |
| 480 | added_at = NOW() | |
| 481 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 482 | } | |
| 483 | ||
| 484 | #------------------------------------------------------------------ | |
| 485 | # Action dispatch | |
| 486 | #------------------------------------------------------------------ | |
| 487 | if ($action eq 'feature') { | |
| 488 | # Promote to the top: new sort_order = MIN(existing) - 1. | |
| 489 | my $r = $db->db_readwrite($dbh, | |
| 490 | qq~SELECT COALESCE(MIN(sort_order), 0) - 1 AS new_top | |
| 491 | FROM `${DB}`.storefront_listings WHERE storefront_id='$sid'~, | |
| 492 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 493 | my $new_top = (defined $r->{new_top}) ? $r->{new_top} : -1; | |
| 494 | $db->db_readwrite($dbh, qq~ | |
| 495 | UPDATE `${DB}`.storefront_listings | |
| 496 | SET sort_order = '$new_top', visible = 1 | |
| 497 | WHERE storefront_id='$sid' AND model_id='$model_id' LIMIT 1 | |
| 498 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 499 | } | |
| 500 | elsif ($action eq 'hide') { | |
| 501 | $db->db_readwrite($dbh, qq~ | |
| 502 | UPDATE `${DB}`.storefront_listings SET visible = 0 | |
| 503 | WHERE storefront_id='$sid' AND model_id='$model_id' LIMIT 1 | |
| 504 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 505 | } | |
| 506 | elsif ($action eq 'show') { | |
| 507 | $db->db_readwrite($dbh, qq~ | |
| 508 | UPDATE `${DB}`.storefront_listings SET visible = 1 | |
| 509 | WHERE storefront_id='$sid' AND model_id='$model_id' LIMIT 1 | |
| 510 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 511 | } | |
| 512 | elsif ($action eq 'toggle_visible') { | |
| 513 | my $newv = $listing && $listing->{visible} ? 0 : 1; | |
| 514 | $db->db_readwrite($dbh, qq~ | |
| 515 | UPDATE `${DB}`.storefront_listings SET visible = '$newv' | |
| 516 | WHERE storefront_id='$sid' AND model_id='$model_id' LIMIT 1 | |
| 517 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 518 | } | |
| 519 | elsif ($action eq 'move_up' || $action eq 'move_down') { | |
| 520 | # Find the neighbor in the chosen direction and swap sort_orders. | |
| 521 | my $cur_sort = $listing->{sort_order} || 0; | |
| 522 | my $cmp = ($action eq 'move_up') ? '<' : '>'; | |
| 523 | my $ord = ($action eq 'move_up') ? 'DESC' : 'ASC'; | |
| 524 | my $neighbor = $db->db_readwrite($dbh, qq~ | |
| 525 | SELECT model_id, sort_order FROM `${DB}`.storefront_listings | |
| 526 | WHERE storefront_id='$sid' AND sort_order $cmp '$cur_sort' | |
| 527 | ORDER BY sort_order $ord LIMIT 1 | |
| 528 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 529 | if ($neighbor && defined $neighbor->{model_id}) { | |
| 530 | my $n_mid = $neighbor->{model_id}; | |
| 531 | my $n_sort = $neighbor->{sort_order}; | |
| 532 | $db->db_readwrite($dbh, qq~ | |
| 533 | UPDATE `${DB}`.storefront_listings SET sort_order='$n_sort' | |
| 534 | WHERE storefront_id='$sid' AND model_id='$model_id' LIMIT 1 | |
| 535 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 536 | $db->db_readwrite($dbh, qq~ | |
| 537 | UPDATE `${DB}`.storefront_listings SET sort_order='$cur_sort' | |
| 538 | WHERE storefront_id='$sid' AND model_id='$n_mid' LIMIT 1 | |
| 539 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 540 | } | |
| 541 | } | |
| 542 | elsif ($action eq 'set_sort') { | |
| 543 | my $value = $q->param('value'); | |
| 544 | $value = 0 unless defined $value && length $value; | |
| 545 | $value =~ s/[^0-9-]//g; | |
| 546 | $value = int($value || 0); | |
| 547 | $db->db_readwrite($dbh, qq~ | |
| 548 | UPDATE `${DB}`.storefront_listings SET sort_order='$value' | |
| 549 | WHERE storefront_id='$sid' AND model_id='$model_id' LIMIT 1 | |
| 550 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 551 | } | |
| 552 | ||
| 553 | $db->db_disconnect($dbh); | |
| 554 | ||
| 555 | print qq~{"success":1,"action":"$action","model_id":"$model_id"}~; | |
| 556 | $SAVE_DONE = 1; | |
| 557 | return; | |
| 558 | } |