# Online Store Module: AA to YM Migration Plan

## Background

The `online_store` module was originally built to work with the AA (AssociationAdvance) provider in the `ams` module. When the site moved from AA to YM (YourMembership), a temporary fix was applied:

- The `ams_iframes` module now defines an `ams.store` route at `/store` that renders `store/default.aspx` as a YM iframe.
- The `ym_iframe_redirect` module intercepts ALL iframe renders and does a hard 302 redirect to the external YM URL.
- Result: visiting `/store` redirects to `https://members.asht.org/store/default.aspx`.

The original `online_store` module rendered a categorized product listing as native Drupal content (querying `product` nodes), with "Add to Cart" links going to iframe-based checkout pages. We want to restore that experience but wired up to YM.

---

## Current Architecture

### Files in the module
- `storeListing.php` - Renders `/store` as a categorized, tabbed product listing from Drupal `product` nodes. Currently hidden because the `ams.store` route overrides `/store`.
- `storePage.php` - Created during the iframe switch. Renders `/store/item/{product_id}` and `/store/add-to-cart/{product_id}` as AA iframes (`ecssashop.add_to_shopping_cart`). **Needs full rewrite for YM.**
- `productAdministration.php` - Admin page at `/admin/content/store/products` that lists AMS products and lets editors create/edit product nodes. Uses `AMS_COMMERCE_SERVICE` which already resolves to YM services.
- `online_store.module` - `hook_views_pre_render()` to hide pricing on zero-price products.
- `online_store.routing.yml` - Route definitions.
- `online_store.info.yml` - Module metadata, depends on `ams:ams`.
- Config in `config/install/` and `config/optional/` for the `product` content type, fields, view, taxonomy, etc.

### Product node fields (current)
| Field | Type | Notes |
|---|---|---|
| `title` | string | Product name |
| `field_product_serno` | integer | AA serial number — **will store YM product ID (integer)** |
| `field_product_category` | entity_reference | Taxonomy: `store_product_categories` |
| `field_product_featured` | boolean | Featured flag |
| `field_product_image` | image | Product image |
| `body` | text_with_summary | Description |
| `field_product_footer_description` | text_with_summary | Description 2 |
| `field_product_member_price` | string | Member price |
| `field_product_nonmember_price` | string | Non-member price |
| `field_product_student_member_pri` | string | Student member price |
| `field_product_student_nonmem_pri` | string | Student non-member price |
| `field_product_assh_member_price` | string | ASSH org member price |

### YM product data structure (from `allAmsProducts()`)
```php
[product_id => [
    'id' => (int),           // YM product ID
    'code' => (string),      // SKU e.g. "CLINICALASSESS"
    'name' => (string),      // Product name/description
    'description' => (string),
    'full_details' => [...], // Full API response (see YM API details below)
]]
```

### YM API: What's available in `full_details`

The YM Products API (`/Ams/{ClientID}/Products/{id}`) returns a combined ProductResponse + InvoiceItem structure. Key fields available in `full_details`:

| Field | Type | Notes |
|---|---|---|
| `amount` | double | **Single base price** — YM does NOT expose member/non-member/student price tiers via the API. Tiered pricing is handled internally by YM at checkout. |
| `sku` | string | Product code (e.g. "CLINICALASSESS") — stored as `code` in `allAmsProducts()` |
| `description` | string | Product name |
| `ProductDescription` | string | Full description text |
| `PrimaryCategory` | string | Category name |
| `IsFeatured` | bool | Featured flag |
| `ListInStore` | bool | Whether product is listed in YM store |
| `BuyFromStore` | bool | Whether product can be purchased from store |
| `ProductActive` | int | Active status |
| `ProductGroup` | string | Grouping |
| `TagLine` | string | Tagline |
| `SizeType`, `ColorOptions`, `CustomFieldName`, `CustomFieldOptions` | string | Additional product options |

**Pricing limitation**: Because YM only exposes a single `amount` price, the multiple Drupal price fields (member, non-member, student, etc.) cannot be auto-populated from the API. Options:
1. Show only the base `amount` from YM on the listing and let YM handle tiered pricing at checkout.
2. Keep the multi-tier price fields for manual editor entry (display purposes only — actual pricing is determined by YM at checkout).
3. Combination: auto-populate `field_product_member_price` with `amount` as a default, leave others blank unless manually entered.

### YM iframe URL for product pages
`store/ViewProduct.aspx?ID={product_id}` — rendered by `iframeController::productPage()`.

### Reference: LMS module YM integration pattern

The `lms` module (`public_html/modules/custom/lms`) already integrates with YM for education unit purchases and provides a proven pattern with two fields:

| LMS Field | Type | Purpose | Online Store Equivalent |
|---|---|---|---|
| `field_edu_product` | list_string | Stores YM **Product ID** (integer value as string). Used to build iframe URL: `store/ViewProduct.aspx?ID={value}` | `field_product_serno` (integer) |
| `field_edu_product_code` | string | Stores YM **SKU/Product Code** (e.g. "CLINICALASSESS"). Used to check purchases via API: `isPurchased($code, 'product_code')` | New: `field_product_ym_code` (string) |

**Key LMS code patterns to follow** (from `lms/src/Controller/lmsPurchase.php`):

```php
// Rendering the YM purchase iframe (ymPage method):
$url = 'store/ViewProduct.aspx?ID=' . $node->get('field_edu_product')->value;
$iframe_template = $iframe_services->renderIframe($url);

// Clearing purchase caches (resetPurchaseCaches method):
\Drupal::cache('ams')->delete("Purchases:$ams_id");
Cache::invalidateTags(['user_purchase:' . $tag_id]);
$product_code = (string) ($node->get('field_edu_product')->value ?? $node->get('field_edu_product_code')->value ?? '');
\Drupal::cache()->delete($account->id() . '_' . $product_code);
```

**Important**: The LMS uses `$iframe_services->renderIframe($url)` (instance method call), NOT the static `$iframe_services::renderIframe($url)` used in the current `storePage.php`. The YM iframe controller's `renderIframe()` is a non-static method.

---

## Node Field to YM API Field Mapping

| Drupal Node Field | Type | YM API Field | Source | Match? | Notes |
|---|---|---|---|---|---|
| `title` | string | `description` / `name` | `allAmsProducts()['name']` | **Likely** | May need trimming/comparison |
| `field_product_serno` | integer | `id` | `allAmsProducts()['id']` | **New value** | Currently stores AA serno (range 0-1379). AA sernos do NOT exist anywhere in YM data — they were not migrated. Will be updated to YM product ID (integer, e.g. 26659971). Used for iframe URL: `store/ViewProduct.aspx?ID={id}` |
| `field_product_ym_code` | string (NEW) | `sku` / `code` | `allAmsProducts()['code']` | **New field** | YM SKU e.g. "CLINICALASSESS". Used for purchase verification via `isPurchased($code, 'product_code')`. Follows LMS pattern (field_edu_product + field_edu_product_code). Both fields should be stored even if only one is actively used, for forward compatibility. |
| `field_product_category` | entity_ref | `PrimaryCategory` | `full_details['PrimaryCategory']` | **Needs verification** | Drupal taxonomy term names must match YM `PrimaryCategory` values. Export script will reveal actual values for comparison. |
| `field_product_featured` | boolean | `IsFeatured` | `full_details['IsFeatured']` | **Direct** | boolean to boolean |
| `body` | text | `ProductDescription` | `full_details['ProductDescription']` | **Available** | May differ from current node body content |
| `field_product_image` | image | — | Not in API | **Manual** | Images are Drupal-only; no YM API equivalent |
| `field_product_footer_description` | text | — | Not in API | **Manual** | Drupal-only field |
| `field_product_member_price` | string | `amount` | `full_details['amount']` | **Partial** | YM API only exposes a single base price. See pricing section below. |
| `field_product_nonmember_price` | string | — | Not in API | **Unknown** | See pricing section below |
| `field_product_student_member_pri` | string | — | Not in API | **Unknown** | See pricing section below |
| `field_product_student_nonmem_pri` | string | — | Not in API | **Unknown** | See pricing section below |
| `field_product_assh_member_price` | string | — | Not in API | **Unknown** | See pricing section below |

### Pricing fields and YM member types

**The problem**: The current pricing fields map to AA roles: Member, Non-member, Student Member, Student Non-member, ASSH Org Member. In AA, users could have multiple roles simultaneously. In YM, each user has a single **member type** (fetched via `/Ams/{ClientID}/MemberTypes` API, also available via `\Drupal::service(AMS_AUTH_SERVICE)->validRoles()`).

**YM pricing model**: YM handles member-type-based pricing internally at checkout — the API only exposes a single `amount` (base price) per product. The actual price a user pays depends on their YM member type, configured within the YM admin, not exposed via the API.

**What we need to verify** (Phase 0 data export):
1. What YM member types exist for ASHT? (`validRoles()` returns `['member' => 'Member', 'publisher' => 'Publisher', ...member types from API...]`)
2. Do those member types map to the current Drupal pricing tier labels (Member, Non-member, Student Member, Student Non-member, ASSH Org Member)?
3. Are the existing Drupal price values still accurate for the YM products, or did pricing change during the AA-to-YM migration?

**Options**:
- **If pricing tiers match**: Keep the existing multi-tier price fields as display-only values. The actual purchase price is determined by YM at checkout.
- **If pricing tiers don't match**: Update/rename the pricing fields to match YM member types. Or simplify to show only the base `amount` and let YM handle the rest.
- **For converted products**: Keep existing price values if products match (the actual values were likely manually entered and may still be correct). Only auto-populate `field_product_member_price` with `amount` if the field is empty.

### Category taxonomy vs YM PrimaryCategory

The `store_product_categories` taxonomy vocabulary in Drupal must align with the `PrimaryCategory` values from YM products. The Phase 0 export will reveal the actual YM category values so we can compare them to existing taxonomy terms and determine if:
- Terms need to be added/renamed
- Products need category reassignment
- The mapping is already correct

---

## Phase 0: Data Export for Comparison

Before making any code changes, export YM data and existing node data to JSON files for side-by-side comparison. This will answer the key unknowns about product matching, category alignment, pricing tiers, and member types.

### 0.1 Create drush script: `online_store:export-ym-data`

Exports three datasets to JSON files:

**a) YM Products** (`ym_products_{date}.json`):
```php
$commerce = \Drupal::service(AMS_COMMERCE_SERVICE);
$products = $commerce->allAmsProducts();
// Save full product data including full_details
```
Each product includes: `id`, `code`, `name`, `description`, `full_details` (with `amount`, `PrimaryCategory`, `IsFeatured`, `ProductDescription`, `sku`, `ListInStore`, `BuyFromStore`, `ProductActive`, etc.)

**b) YM Member Types** (`ym_member_types_{date}.json`):
```php
$auth = \Drupal::service(AMS_AUTH_SERVICE);
$roles = $auth->validRoles();
// Save the member type to role mapping
```
Returns: `['member' => 'Member', 'publisher' => 'Publisher', ...machine_name => 'Display Name'...]`

**c) Existing Product Nodes** (`drupal_products_{date}.json`):
```php
// Query all product nodes with their field values
// Include: nid, title, field_product_serno, field_product_category (term name),
//          all pricing fields, field_product_featured, body
```

### 0.2 Output location
Save to `ym_export_data/` in the project root (this directory already exists based on gitignore patterns — there's a `ym_products_2025-12-15_190646.json` in there already).

### 0.3 What to compare
- YM product `name` vs Drupal node `title` — do products match by name?
- YM product `code`/`sku` vs Drupal `field_product_serno` — did AA sernos carry over as YM codes?
- YM `PrimaryCategory` values vs Drupal `store_product_categories` taxonomy terms — do categories align?
- YM member types vs current pricing tier labels (Member, Non-member, Student Member, Student Non-member, ASSH Org Member) — do tiers still make sense?
- YM `amount` vs Drupal `field_product_member_price` — are base prices the same?

### 0.4 Phase 0 Results (2026-04-07)

**Product counts**: 306 YM products, 135 Drupal nodes (39 published, 96 unpublished).

**Matching**: 5 exact title matches, 0 serno-to-code matches. AA sernos (integer range 0-1379) do not exist in any YM field. Fuzzy title matching (>=70%) found 24 additional partial matches, mostly Journal Club quizzes where Drupal uses "2022 June Journal Club – {article title}" and YM uses just the article title.

**Categories do NOT align**:
| Drupal | YM |
|---|---|
| Books | Books (trailing space) |
| CE Supplements | Additional Products |
| CHT Exam | Journal Club Quizzes |
| Journal Club Quizzes | Leadership Development Program |
| Merchandise | Online Courses |
| Virtual Education | Pediatric Case Reviews |
| (54 uncategorized) | Upper Extremity Institute, Webinars |

**Member types vs pricing tiers**: Only "Member" matches exactly. YM has "Non Member" (no hyphen), "Student " (trailing space), "Student Non-Member" (different casing). "ASSH Org Member" does not exist in YM at all.

**Published product breakdown** (39 total):
- 23 Journal Club Quizzes (2022-2025)
- 5 Books
- 5 Virtual Education
- 3 CHT Exam (4th Edition variants)
- 1 Merchandise, 1 CE Supplements, 1 uncategorized

**Key finding**: The 96 unpublished nodes are legacy AA products (pre-2019 Journal Clubs, old Chat Quizzes, discontinued items). The AA `field_product_serno` integer values have no equivalent in YM — they are purely AA identifiers.

### 0.5 Product management considerations (future)

The Phase 0 data raises questions about the best approach for product management going forward:
- **Option A (convert)**: Match the ~39 published products to YM equivalents via fuzzy title matching, update `field_product_serno` to YM `id` and add `field_product_ym_code`. Requires manual verification of matches.
- **Option B (fresh start)**: Improve the admin screen (`productAdministration.php`) to make it easy to create product nodes directly from YM product data. Remove/purge existing AA nodes and start fresh with YM products. This may be simpler given the low match rate and category misalignment.
- **Option C (hybrid)**: Convert what matches cleanly, use the improved admin screen for the rest, and purge unpublished legacy nodes.

Decision deferred — implement the module updates first (routes, iframe, admin), then decide on the data migration approach. The drush export command is available for re-running at any time.

---

## Migration Plan

### Phase 1: Update routes and storeListing

**1.1 Change the store listing path to `/new-store`**
- In `online_store.routing.yml`, change `online_store.listing` path from `/store` to `/new-store`.
- This avoids conflict with the `ams.store` route. Later we can disable `ams.store` and reclaim `/store`.

**1.2 Update store item and add-to-cart routes**
- Change `online_store.item_page` path from `/store/item/{product_id}` to `/new-store/item/{product_id}`.
- Change `online_store.add_to_cart` path from `/store/add-to-cart/{product_id}` to `/new-store/add-to-cart/{product_id}`.

**1.3 Update links in `storeListing.php`**
- Update the "Add to Cart" link from `/store/add-to-cart/{serno}` to `/new-store/add-to-cart/{serno}`.
- Update product detail links if they reference `/store/`.

### Phase 2: Rewrite `storePage.php` for YM

**2.1 Replace AA iframe URL with YM equivalent**
- The current code builds an AA-specific `ecssashop.add_to_shopping_cart` URL.
- Replace with YM approach following the LMS pattern:
  ```php
  $iframe_service = \Drupal::service(AMS_IFRAME_SERVICE);
  $url = 'store/ViewProduct.aspx?ID=' . $product_id;
  return $iframe_service->renderIframe($url);
  ```
- Note: use instance method call (`->renderIframe()`) not static (`::renderIframe()`).

**2.2 Update `storeAddToCartPage()`**
- Same approach as above. The YM store product page includes add-to-cart functionality, so both routes can render the same YM product page iframe.

**2.3 Update `resetPurchases()` — follow LMS pattern**
- Currently clears `\Drupal::cache('ams')` with key `Purchases:{ams_id}`.
- Update to match the LMS `resetPurchaseCaches()` pattern:
  - Clear `\Drupal::cache('ams')->delete("Purchases:$ams_id")`
  - Invalidate cache tags: `Cache::invalidateTags(['user_purchase:' . $tag_id])`
  - Clear product-specific cache: `\Drupal::cache()->delete($uid . '_' . $product_code)`

**2.4 Update cookie constant**
- `AMS_PROD_MNG_COOKIE` (`prodpurmngams`) — evaluate if this is still needed/used. The YM iframe system uses `AMS_IFRAME_RENDER_PAGE_URL` for navigation cookies.

**2.5 Note on iframe redirect**
- If `ym_iframe_redirect` is enabled, the iframe will redirect to the external YM site instead of rendering inline.
- **Future enhancement**: Update `ym_iframe_redirect` to support path-based exceptions via a config form with wildcard support (e.g. `/new-store/*`). This is not required for the initial migration.

### Phase 3: Update `productAdministration.php` for YM

**3.1 Verify `AMS_COMMERCE_SERVICE` integration**
- `manageProducts()` calls `$commerce_services->allAmsProducts()` which already resolves to the YM implementation.
- The YM data structure differs from AA. Update the markup to use YM fields:
  - Product name: `$product['name']`
  - Product ID: `$product['id']`
  - Product code/SKU: `$product['code']`

**3.2 Update `createProduct()`**
- Currently creates a node with `'type' => 'event'` (bug — should be `'product'`).
- Update to use YM product data: set `field_product_serno` to YM `id`, title to `name`, and `field_product_ym_code` to `code`.

**3.3 Update hidden form fields**
- The management page currently passes AA product data as hidden form fields. Update to pass YM-relevant fields (`id`, `code`, `name`).

### Phase 4: Add `field_product_ym_code` field

**4.1 Add a string field for YM product code (SKU)**
- Following the LMS pattern where `field_edu_product` (integer/ID) and `field_edu_product_code` (string/SKU) serve different purposes:
  - `field_product_serno` (integer) → stores YM **Product ID** → used for iframe URLs (`store/ViewProduct.aspx?ID={id}`)
  - New `field_product_ym_code` (string) → stores YM **SKU/Product Code** (e.g. "CLINICALASSESS") → used for purchase verification via API (`isPurchased($code, 'product_code')`)
- Add `config/install/field.storage.node.field_product_ym_code.yml`
- Add `config/install/field.field.node.product.field_product_ym_code.yml`
- Update form display to include the new field in the "Product Details" fieldset.

### Phase 5: Drush command for product conversion

**5.1 Create a drush command: `online_store:convert-products`**
- Fetches all YM products via `AMS_COMMERCE_SERVICE->allAmsProducts()`.
- Loads all existing `product` nodes.
- Attempts to match existing nodes to YM products using multiple strategies:
  1. **By serno/code match**: Check if the existing `field_product_serno` value (AA serno) matches a YM product `code` (SKU). AA may have carried sernos over as YM product codes.
  2. **By title match**: Compare node titles to YM product `name` values (case-insensitive, trimmed).
  3. **By sku/code similarity**: Fuzzy or partial matching on product code fields.
- For each match found:
  - Update `field_product_serno` to the YM product `id` (integer — used for iframe URLs).
  - Populate `field_product_ym_code` with the YM product `code`/SKU (string — used for purchase verification).
  - Optionally update `field_product_member_price` with `full_details['amount']` (base price).
  - Optionally update `field_product_category` if `full_details['PrimaryCategory']` matches a taxonomy term.
  - Optionally update `field_product_featured` from `full_details['IsFeatured']`.
  - Optionally update `body` from `full_details['ProductDescription']`.
- **Interactive mode**: Display matches and ask for confirmation before saving.
- **Dry-run mode** (default): Show what would be matched/updated without making changes.
- **Report**: List unmatched nodes and unmatched YM products so editors know what needs manual attention.

**5.2 Price handling during conversion**
- For matched products: **keep existing Drupal price values** — they were manually entered and may still be correct even after the AA-to-YM move.
- Only auto-populate `field_product_member_price` with `full_details['amount']` if the field is currently empty.
- Leave other price tiers untouched — editors can verify/update after reviewing the Phase 0 export data.

**5.3 Implementation location**
- Add `online_store.services.yml` to register the drush command service (or use a `Drush/Commands/` directory if using Drush 9+ command discovery).
- Create `src/Commands/OnlineStoreCommands.php` with both the export and convert commands.

### Phase 6: Update module metadata

**6.1 `online_store.info.yml`**
- Dependency `'ams:ams'` is correct (YM is a provider within ams).
- Bump version.

**6.2 `online_store.permissions.yml`**
- Review permissions: `access ams store` and `make ams purchase`. Names reference "ams" which is fine since the ams module is the umbrella.

### Phase 7: Product Administration UI Improvements

The current `/admin/content/store/products` page lists all "Create" products in one long table, then all "Edit" products in another. With 300+ YM products, this is unwieldy. This phase improves the admin UX.

**7.1 Tabbed interface for Create / Edit**
- Replace the sequential listing with two client-side tabs: "Create Products" and "Edit Products".
- Use the existing jQuery Responsive Tabs library (`responsive_tabs/jquery.responsiveTabs.js`) already bundled with the module.
- Tabs switch without a full page reload (pure client-side show/hide).
- Show product counts in tab labels (e.g. "Create Products (280)" / "Edit Products (33)").

**7.2 Client-side search/filter**
- Add a search input above each tab's table that filters rows as the user types.
- Pure client-side filtering using jQuery `keyup` handler — no AJAX, no additional library.
- Filters based on product name/title text in each row.
- Instant results with zero network requests.

**7.3 Enhanced table columns**
- **Create tab**: Show product name, YM product ID, YM code/SKU, YM category (`PrimaryCategory`), base price (`amount`), and a "Create" button.
- **Edit tab**: Show product name, YM product ID, YM code, Drupal node link, and an "Edit" button.
- This gives editors more context when deciding which products to add to the site.

**7.4 Implementation**
- Rewrite `productAdministration.php` `manageProducts()` to render the tabbed layout.
- Add a new JS file `js/online_store_admin.js` for tab initialization and search filtering.
- Update `online_store.libraries.yml` to include the admin JS and responsive tabs dependency.

### Phase 8: Product data caching with state storage

The admin page at `/admin/content/store/products` calls `AMS_COMMERCE_SERVICE->allAmsProducts()`, which on cache miss makes N+1 API calls (1 list call + 1 per product for details). At 300 products that's ~301 API calls. The result is cached in `\Drupal::cache('ams')` for 1 hour, but that cache is wiped on `drush cr` (which happens on every code deploy). This means the first admin page load after a deploy is very slow.

**8.1 Problem: `\Drupal::cache()` vs `\Drupal::state()`**
- `\Drupal::cache('ams')` — wiped on `drush cr`, which happens on every deploy.
- `\Drupal::state()` — stored in `key_value` table, survives cache rebuilds.
- Product data changes infrequently (new products added occasionally). A 4-8 hour refresh window is acceptable.

**8.2 Data size**
Based on the Phase 0 export (306 products):
- Full product data with `full_details`: **541 KB** (serialized)
- Without `ProductDescription` HTML: **304 KB**
- `ProductDescription` alone: **226 KB** across 255 products

Both sizes are fine for `\Drupal::state()` (MySQL blob). **Store the full data** — don't slim it down. The `ams` module is shared across repos and sites (ASHT, OARSI, etc.), and different sites may need different fields. Removing `ProductDescription` would save 226 KB but could break other modules that rely on it.

**8.3 Where to implement: `ams` module, not `online_store`**
Since `allAmsProducts()` is in the shared `ams` module (`commerceServices.php`), the caching improvement should live there so all sites benefit. This means changes need to go into:
- **ams 3.2** — used in `ah_multi` (Drupal 9, PHP 7.4)
- **ams 3.3** — used in `lms_v3` (Drupal 10/11, PHP 8.1+)

**Workflow**: Make changes in `ah_multi` first (where we can test with the online store), then copy to the `ams` repo (`/Users/mbowen/cloned/ams`), branch 3.2. After testing, port to 3.3 branch, accounting for PHP 8.1+ syntax differences. Then `composer update` in `lms_v3` to pull the updated module.

**Note**: The `lms` module also uses `AMS_COMMERCE_SERVICE` (for education unit purchase checks), so this change is also relevant to the ongoing LMS work documented in `lms/docs/education-unit-external-access-plan.md`.

**8.4 Planned approach for `commerceServices.php`**

```
allAmsProducts() flow:
  1. Check \Drupal::state('ams_ym_products') for cached data
  2. If found and timestamp < max_age (e.g. 4 hours), return it
  3. If stale or empty, call the YM API (existing N+1 logic)
  4. Store full result + timestamp in state
  5. Keep the existing \Drupal::cache('ams') layer as a fast path
     (state is the fallback when cache is cleared)
```

**8.5 Cron refresh**
- Add `hook_cron` (in `ams` module or `online_store`) to refresh the state data before it goes stale.
- This ensures no user request ever triggers the slow API rebuild path.

**8.6 Admin page "Refresh Products" button**
- Add a button on `/admin/content/store/products` that forces a fresh API pull.
- Useful when an editor knows new products were just added in YM and doesn't want to wait for cron.
- This is an `online_store` feature (not in the shared `ams` module).
- On each admin page load, check the state timestamp — if older than the configured max age, refresh automatically on that request (but not every load).

**8.7 Interim solution (online_store only)**
Until the `ams` module is updated, `online_store` can implement its own state-based wrapper around `allAmsProducts()`:
- Store the result in `\Drupal::state('online_store.ym_products')` after fetching
- Check state first on admin page load — if fresh, skip calling `allAmsProducts()` entirely
- Add a "Refresh Products" button to force a fresh pull
- This doesn't modify the shared `ams` module at all

---

## Future Enhancements (not in scope now)

1. **`ym_iframe_redirect` path exceptions**: Add a config form to `ym_iframe_redirect` with a textarea for path patterns (supporting wildcards like `/new-store/*`) that should render as iframes instead of redirecting. This would allow the store checkout to render inline in Drupal.

2. **Views integration**: Consider whether the store listing should use a Drupal View instead of the custom `storeListing.php` controller for easier maintenance. (There is already a `views.view.store` config in the module.)

---

## Key Risk: Product Matching

The drush conversion command's success depends on whether existing AA product data can be matched to YM products. Possible scenarios:
- **Best case**: AA sernos were imported into YM as product codes — direct match.
- **Good case**: Product titles are similar enough for name-based matching.
- **Worst case**: No correlation — nodes need to be manually re-mapped or recreated.

The dry-run mode will reveal which scenario we're in before any changes are made.

---

## Files to modify/create

| File | Action | Phase | Description |
|---|---|---|---|
| `online_store.services.yml` | Create | 0 | Register drush command services |
| `src/Commands/OnlineStoreCommands.php` | Create | 0, 5 | Drush commands: `export-ym-data` and `convert-products` |
| `online_store.routing.yml` | Modify | 1 | Change paths to `/new-store` prefix |
| `src/Controller/storePage.php` | Rewrite | 2 | Use YM iframe service following LMS pattern |
| `src/Controller/storeListing.php` | Modify | 1 | Update add-to-cart link URLs |
| `src/Controller/productAdministration.php` | Modify | 3 | Update for YM product data structure, fix `createProduct()` bug |
| `config/install/field.storage.node.field_product_ym_code.yml` | Create | 4 | YM product code field storage |
| `config/install/field.field.node.product.field_product_ym_code.yml` | Create | 4 | YM product code field instance |
| `online_store.info.yml` | Modify | 6 | Bump version |
