# Shopify URL Handle Sync Fix

## The bug

Changing a product's **URL Handle** field (`products_products.url_key`,
shown in the product Edit form's SEO section) never reached Shopify, and
the existing 301-redirect infrastructure (`ShopifyClient::updateProductHandle()`
with `redirectNewHandle: true`, and the manual `/redirects.json` POST in
`pushListingAttributes()`) never fired — because nothing ever populated a
`handle` value from `url_key` in the first place.

Two separate sync code paths touch Shopify handles, and **both** had the
same class of gap:

1. **`SyncProductToChannels.php`** (single-product sync, used when saving
   the Channel Listings modal) — `title`, `vendor`, `description`, and
   `sub_title` all have a "fall back to the product-level field when no
   channel-level override is set" block. `handle` had no equivalent —
   `$listingAttributes['handle']` was only ever populated if a per-channel
   override happened to include it, which nothing in the UI actually sets.

2. **`ShopifyBatchSyncJob.php`** (bulk sync) — worse: it didn't fall back
   to anything, it **always** computed its own handle as
   `Str::slug($shopifySku . ' ' . $titlePart)` (SKU + title with the SKU
   prefix stripped), unconditionally overwriting whatever was on Shopify
   already, with no path for a manual override at all. `url_key` wasn't
   even in the `products_products` column list this job selects.

## The fix

Both paths now prefer a manual handle — checked in this order — before
falling back to the auto-generated slug:

1. A per-channel override (`channel_listings.attributes.handle`), if the
   Channel Listings modal was used to set one.
2. The product's own `url_key` (`ShopifyBatchSyncJob.php` needed this
   column added to its `products_products` select — it wasn't fetching it
   at all).
3. Otherwise, the existing SKU+title auto-slug (unchanged behaviour for
   products that never had a manual handle set).

Both wrap the manual value in `Str::slug()` before sending it, so a raw
`url_key` value is normalised to a Shopify-safe slug regardless of what a
user actually typed into the field.

## Verified live (Banwells dev store, `banwells1.myshopify.com`)

ERR3340 → set `url_key = "err3340-test-url"` → ran `ShopifyBatchSyncJob`:

- Shopify handle changed from the old auto-slug
  (`err3340-land-rover-oil-filter-from-allmakes`) to `err3340-test-url`.
- A real 301 redirect was created and confirmed via `GET /redirects.json`:
  `/products/err3340-land-rover-oil-filter-from-allmakes` →
  `/products/err3340-test-url`.

Only the `ShopifyBatchSyncJob` path was exercised end-to-end against a
real store; `SyncProductToChannels.php`'s equivalent fallback was written
the same way but not separately live-tested.

## Unrelated thing found along the way

While testing this, hit `Shopify channel '...' has no access token —
reconnect via Channels → OAuth.` for the Banwells channel (id 15) even
right after a reconnect. Traced it: `ShopifyAuthController`'s OAuth
callback only ever updates `access_token`, `status`, and `scopes` — it
never touches the channel's `name` column. The channel's `name` had
recently changed (to "Banwells website"), which is unrelated to the OAuth
flow; likely just a manual rename that happened around the same time as
the reconnect, not a code bug. No fix needed there, but worth knowing the
OAuth callback never renames a channel if this comes up again.
