# Config-Driven Feed Mapper Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task.

**Goal:** Refactor the feed mapper and GraphQL query so all client-specific rules live in `clients/<name>/config.php` — new clients can be onboarded without code changes.

**Architecture:** Replace the hardcoded mapper constructor with a single `feedMappingConfig` array from client config. The mapper reads this config to resolve attribute sources (metafields → selectedOptions → fallback), title/description strip patterns, and custom label mappings. The GraphQL query is built dynamically from the config's declared metafield list rather than hardcoded aliases.

**Tech Stack:** PHP 8.x, Shopify GraphQL Admin API, existing test harness (`tests/run.php`)

**Feature Branch:** `config-driven-mapper`

---

## What's hardcoded today (rooflights-specific)

| Thing | Where | What it does |
|---|---|---|
| `(Increased Lead Time)` strip | Mapper line 54 | Regex on title |
| `Related Categories:` strip | Mapper `sanitizeDescription()` | Regex on description |
| Color fallback: `Outside Colour`, `Finish` | Mapper lines 130-133 | selectedOptions names |
| Size fallback: `Overall Kerb Size`, `Roof Opening Size`, `Size` | Mapper lines 136-141 | selectedOptions names |
| Product type from `custom_label_1` | Mapper lines 116-117 | metafield as product_type source |
| Glass type → `custom_label_2` | Mapper line 166 | metafield → specific custom label |
| MPN from `metaMpn` | Mapper lines 108-110 | metafield alias |
| 7 variant metafield aliases | GraphQL query lines 41-47 | hardcoded namespace/key pairs |
| 2 product metafield aliases | GraphQL query lines 61-62 | hardcoded namespace/key pairs |
| SEO hidden source | Mapper line 125 | hardcoded metafield alias |

## What's already configurable (stays as-is)

- `google_product_category` mapping (config `by_product_type`)
- Currency, condition, fallback brand, storefront URL (constructor params)
- Shopify credentials (env vars per client)
- Feed channel metadata (title, link, description)
- Output paths

---

## Target config shape

```php
// clients/rooflights/config.php — new 'mapping' key under 'feed'
'mapping' => [
    // Metafields to fetch — drives the GraphQL query dynamically.
    // Each entry becomes a GraphQL alias: alias: metafield(namespace: "x", key: "y") { value }
    'variant_metafields' => [
        'colour'        => ['namespace' => 'custom', 'key' => 'colour'],
        'glass_type'    => ['namespace' => 'custom', 'key' => 'glass_type'],
        'material'      => ['namespace' => 'custom', 'key' => 'material'],
        'size'          => ['namespace' => 'custom', 'key' => 'size'],
        'custom_label_0' => ['namespace' => 'mm-google-shopping', 'key' => 'custom_label_0'],
        'custom_label_1' => ['namespace' => 'mm-google-shopping', 'key' => 'custom_label_1'],
        'mpn'           => ['namespace' => 'mm-google-shopping', 'key' => 'mpn'],
    ],
    'product_metafields' => [
        'seo_hidden'              => ['namespace' => 'seo', 'key' => 'hidden'],
        'google_product_category' => ['namespace' => 'mm-google-shopping', 'key' => 'google_product_category'],
    ],

    // Regex patterns to strip from titles (applied in order)
    'title_strip_patterns' => [
        '/\s*\(Increased Lead Time\)/i',
    ],

    // Regex patterns to strip from descriptions (applied in order, after HTML→text)
    'description_strip_patterns' => [
        '/\s*Related Categories:.*$/ui',
    ],

    // Attribute source resolution — each GMC attribute has an ordered list of sources.
    // Types: 'variant_metafield', 'selected_option', 'product_field'
    // The first non-empty value wins.
    'attributes' => [
        'color' => [
            ['type' => 'variant_metafield', 'key' => 'colour'],
            ['type' => 'selected_option', 'name' => 'Outside Colour'],
            ['type' => 'selected_option', 'name' => 'Finish'],
        ],
        'material' => [
            ['type' => 'variant_metafield', 'key' => 'material'],
        ],
        'size' => [
            ['type' => 'variant_metafield', 'key' => 'size'],
            ['type' => 'selected_option', 'name' => 'Overall Kerb Size'],
            ['type' => 'selected_option', 'name' => 'Roof Opening Size'],
            ['type' => 'selected_option', 'name' => 'Size'],
        ],
        'custom_label_0' => [
            ['type' => 'variant_metafield', 'key' => 'custom_label_0'],
        ],
        'custom_label_1' => [
            ['type' => 'variant_metafield', 'key' => 'custom_label_1'],
        ],
        'custom_label_2' => [
            ['type' => 'variant_metafield', 'key' => 'glass_type'],
        ],
    ],

    // Which attribute (if any) should override product_type when non-empty.
    // null = use Shopify productType as-is.
    'product_type_override_attribute' => 'custom_label_1',

    // MPN source — variant metafield key to prefer over SKU. null = use SKU only.
    'mpn_metafield_key' => 'mpn',

    // SEO hidden source — product metafield key. null = don't check.
    'seo_hidden_metafield_key' => 'seo_hidden',

    // Size normalisation — apply "Nmm x Nmm" spacing normalisation.
    'normalize_size' => true,
],
```

---

## Tasks

### Task 1: Add mapping config to rooflights client

**Files:**
- Modify: `feed-manager/clients/rooflights/config.php`

**Step 1: Add the `mapping` key to the feed config**

Add the full `mapping` array (as shown above) under the existing `feed` key. This is additive — the mapper won't read it yet, so nothing breaks.

**Step 2: Run existing tests to verify nothing breaks**

Run: `php tests/run.php`
Expected: All 8 tests PASS

**Step 3: Commit**

```bash
git add clients/rooflights/config.php
git commit -m "feat: add declarative mapping config to rooflights client"
```

---

### Task 2: Build dynamic GraphQL query from config

**Files:**
- Modify: `feed-manager/src/Integrations/Shopify/ShopifyVariantFeedSource.php`

**Step 1: Write test for dynamic query builder**

Create `tests/ShopifyVariantFeedSourceQueryTest.php` that:
- Constructs a `ShopifyVariantFeedSource` with a mapping config
- Calls a new public static method `buildQuery(array $mappingConfig): string`
- Asserts the returned query string contains the expected metafield aliases
- Asserts it still contains the core fields (id, legacyResourceId, title, sku, barcode, price, etc.)
- Asserts variant metafield aliases use the format `meta_{key}: metafield(namespace: "{ns}", key: "{key}") { value }`
- Asserts product metafield aliases follow the same pattern

```php
$config = [
    'variant_metafields' => [
        'colour' => ['namespace' => 'custom', 'key' => 'colour'],
        'size'   => ['namespace' => 'custom', 'key' => 'size'],
    ],
    'product_metafields' => [
        'seo_hidden' => ['namespace' => 'seo', 'key' => 'hidden'],
    ],
];

$query = ShopifyVariantFeedSource::buildQuery($config);

// Assert variant metafield aliases present
assertStringContains('meta_colour: metafield(namespace: "custom", key: "colour") { value }', $query);
assertStringContains('meta_size: metafield(namespace: "custom", key: "size") { value }', $query);

// Assert product metafield aliases present
assertStringContains('meta_seo_hidden: metafield(namespace: "seo", key: "hidden") { value }', $query);

// Assert core fields still present
assertStringContains('legacyResourceId', $query);
assertStringContains('selectedOptions {', $query);
```

**Step 2: Run test to verify it fails**

Run: `php tests/run.php`
Expected: FAIL — `buildQuery` method doesn't exist

**Step 3: Implement `buildQuery()`**

Replace the hardcoded `QUERY` constant with a static method that builds the GraphQL query string from the mapping config. The method:
- Starts with the fixed core fields (id, legacyResourceId, title, sku, barcode, price, compareAtPrice, availableForSale, selectedOptions, image)
- Iterates `variant_metafields` to append aliased metafield fragments to the variant node
- Iterates `product_metafields` to append aliased metafield fragments to the product node
- Keeps the fixed product fields (id, legacyResourceId, title, handle, productType, descriptionHtml, vendor, status, onlineStoreUrl, seo, featuredImage, images)

Alias naming convention: `meta_{configKey}` (e.g. `meta_colour`, `meta_seo_hidden`). This replaces the old `metaColour`, `metaSeoHidden` etc.

Update `fetchAllVariants()` to accept the mapping config and call `buildQuery()` instead of using the constant.

**Step 4: Run tests to verify they pass**

Run: `php tests/run.php`
Expected: New test passes. Existing tests may fail because mapper still expects old alias names — that's expected, fixed in Task 3.

**Step 5: Commit**

```bash
git add src/Integrations/Shopify/ShopifyVariantFeedSource.php tests/ShopifyVariantFeedSourceQueryTest.php
git commit -m "feat: build GraphQL query dynamically from mapping config"
```

---

### Task 3: Refactor mapper to use mapping config

**Files:**
- Modify: `feed-manager/src/Application/VariantToGoogleFeedItemMapper.php`
- Modify: `feed-manager/tests/VariantToGoogleFeedItemMapperTest.php`

This is the largest task. Break into sub-steps.

**Step 1: Update constructor to accept mapping config**

Replace the current constructor:
```php
public function __construct(
    private readonly string $currencyCode,
    private readonly string $defaultCondition,
    private readonly string $fallbackStorefrontBaseUrl,
    private readonly string $fallbackBrand,
    private readonly array $googleProductCategoryConfig = ['default' => ''],
)
```

With:
```php
public function __construct(
    private readonly string $currencyCode,
    private readonly string $defaultCondition,
    private readonly string $fallbackStorefrontBaseUrl,
    private readonly string $fallbackBrand,
    private readonly array $googleProductCategoryConfig = ['default' => ''],
    private readonly array $mappingConfig = [],
)
```

**Step 2: Add generic `resolveAttribute()` method**

```php
/**
 * Resolve a GMC attribute value from an ordered list of sources.
 *
 * @param array<int, array{type: string, key?: string, name?: string}> $sources
 * @param array<string, mixed> $variant
 * @param array<string, string> $selectedOptions
 */
private function resolveAttribute(array $sources, array $variant, array $selectedOptions): string
{
    foreach ($sources as $source) {
        $type = $source['type'] ?? '';
        $value = '';

        if ($type === 'variant_metafield') {
            $key = $source['key'] ?? '';
            $value = $this->stringOrEmpty($variant['meta_' . $key]['value'] ?? '');
        } elseif ($type === 'selected_option') {
            $name = $source['name'] ?? '';
            $value = $selectedOptions[$name] ?? '';
        }

        if ($value !== '') {
            return $value;
        }
    }

    return '';
}
```

**Step 3: Replace hardcoded attribute resolution with `resolveAttribute()`**

Replace lines 130-145 (color, material, size, glassType, customLabel0 resolution) with a loop over `$this->mappingConfig['attributes']`:

```php
$attributes = $this->mappingConfig['attributes'] ?? [];
$resolvedAttributes = [];
foreach ($attributes as $attrName => $sources) {
    $resolvedAttributes[$attrName] = $this->resolveAttribute($sources, $variant, $selectedOptions);
}

$color = $resolvedAttributes['color'] ?? '';
$material = $resolvedAttributes['material'] ?? '';
$size = $resolvedAttributes['size'] ?? '';
// ... etc
```

**Step 4: Replace hardcoded title/description strip patterns**

Replace the single hardcoded regex with a loop over config patterns:

```php
// Title strip
foreach ($this->mappingConfig['title_strip_patterns'] ?? [] as $pattern) {
    $title = trim((string) preg_replace($pattern, '', $title));
}

// In sanitizeDescription(), pass patterns in:
foreach ($patterns as $pattern) {
    $plainText = preg_replace($pattern, '', (string) $plainText);
}
```

**Step 5: Replace hardcoded product_type override and MPN source**

Use config keys `product_type_override_attribute` and `mpn_metafield_key`:

```php
$overrideAttr = $this->mappingConfig['product_type_override_attribute'] ?? null;
$resolvedProductType = ($overrideAttr !== null && ($resolvedAttributes[$overrideAttr] ?? '') !== '')
    ? $resolvedAttributes[$overrideAttr]
    : $productType;

$mpnMetafieldKey = $this->mappingConfig['mpn_metafield_key'] ?? null;
$metaMpn = $mpnMetafieldKey !== null
    ? $this->normalizeIdentifier($this->stringOrEmpty($variant['meta_' . $mpnMetafieldKey]['value'] ?? ''))
    : '';
```

**Step 6: Replace hardcoded SEO hidden source**

```php
$seoHiddenKey = $this->mappingConfig['seo_hidden_metafield_key'] ?? null;
$seoHiddenValue = $seoHiddenKey !== null
    ? ($product['meta_' . $seoHiddenKey]['value'] ?? null)
    : ($product['metafield']['value'] ?? null);
```

**Step 7: Conditional size normalisation**

```php
if ($this->mappingConfig['normalize_size'] ?? false) {
    $size = $this->normalizeSize($size);
}
```

**Step 8: Update tests**

Update all test cases to:
- Pass the full `mappingConfig` to the constructor (matching rooflights config shape)
- Update variant payloads to use `meta_` prefix for metafield aliases (e.g. `meta_colour` instead of `metaColour`)
- Update product payloads similarly (`meta_seo_hidden` instead of `metaSeoHidden`)
- Add a test with an empty `mappingConfig` to verify the mapper still works with defaults (empty attributes, no strips, no overrides)

**Step 9: Run tests**

Run: `php tests/run.php`
Expected: All tests PASS

**Step 10: Commit**

```bash
git add src/Application/VariantToGoogleFeedItemMapper.php tests/VariantToGoogleFeedItemMapperTest.php
git commit -m "feat: refactor mapper to resolve attributes from config"
```

---

### Task 4: Wire config through construction sites

**Files:**
- Modify: `feed-manager/web/_common.php` (lines 126-134)
- Modify: `feed-manager/bin/sync-feed` (lines 92-100)
- Modify: `feed-manager/src/Application/GoogleMerchantFeedExporter.php` (lines 51-59)
- Modify: `feed-manager/src/Integrations/Shopify/ShopifyVariantFeedSource.php` (`fetchAllVariants` call)

**Step 1: Update all three mapper construction sites**

Each site currently creates the mapper with 5 args. Add `mappingConfig`:

```php
$mappingConfig = $feedConfig['mapping'] ?? [];

$mapper = new VariantToGoogleFeedItemMapper(
    currencyCode: ...,
    defaultCondition: ...,
    fallbackStorefrontBaseUrl: ...,
    fallbackBrand: ...,
    googleProductCategoryConfig: ...,
    mappingConfig: $mappingConfig,
);
```

**Step 2: Update `ShopifyVariantFeedSource` construction to pass mapping config**

`fetchAllVariants()` needs the mapping config to build the query. Either:
- Pass it to the constructor and store it, or
- Pass it to `fetchAllVariants()` as a parameter

Constructor is cleaner since the config doesn't change per-call:

```php
$source = new ShopifyVariantFeedSource($shopifyClient, $mappingConfig);
```

**Step 3: Run full test suite**

Run: `php tests/run.php`
Expected: All tests PASS

**Step 4: Run a real sync to verify end-to-end**

```bash
php -d memory_limit=512M bin/sync-feed rooflights
php -d memory_limit=512M bin/export-feed rooflights
```

Verify the XML output is identical to the current feed (same field coverage, same values).

**Step 5: Commit**

```bash
git add web/_common.php bin/sync-feed src/Application/GoogleMerchantFeedExporter.php src/Integrations/Shopify/ShopifyVariantFeedSource.php
git commit -m "feat: wire mapping config through all construction sites"
```

---

### Task 5: Clean up and verify backwards compatibility

**Files:**
- Modify: `feed-manager/src/Application/VariantToGoogleFeedItemMapper.php` (remove dead code)
- Modify: `feed-manager/CLAUDE.md` (document the new config pattern)

**Step 1: Remove any dead code paths**

If the old hardcoded fallbacks for `metaColour`, `metaSeoHidden` etc. still exist as dead branches, remove them.

**Step 2: Verify empty mapping config still works**

Ensure a client with `'mapping' => []` produces a feed with core fields only (id, title, description, link, image_link, availability, price, brand, condition, item_group_id). No color/size/material/custom_labels — those require config.

**Step 3: Update CLAUDE.md**

Add to the "Feed Enrichment Architecture" section:

```markdown
### Client mapping config

All client-specific field resolution rules live in `clients/<client>/config.php` under the `feed.mapping` key. This includes:
- Which Shopify metafields to fetch (drives the GraphQL query dynamically)
- Title and description strip patterns (regex)
- Attribute source resolution (metafield → selectedOption fallback chains)
- Product type override source
- MPN metafield source
- SEO hidden metafield source

New clients only need a `config.php` — no mapper code changes required.
```

**Step 4: Run full test suite**

Run: `php tests/run.php`
Expected: All tests PASS

**Step 5: Commit**

```bash
git add src/Application/VariantToGoogleFeedItemMapper.php CLAUDE.md
git commit -m "chore: clean up dead code and document config-driven mapping"
```

---

## Verification checklist

After all tasks complete:

1. `php tests/run.php` — all tests pass
2. `php -d memory_limit=512M bin/sync-feed rooflights` — sync completes successfully
3. `php -d memory_limit=512M bin/export-feed rooflights` — exports 4,051 items
4. Spot-check XML: same field coverage as before (color 2,919, material 2,013, size 4,048, GPC all 124/2030/2766/499772)
5. No hardcoded option names or metafield keys remain in `src/` — only in `clients/rooflights/config.php`

## Risk notes

- **GraphQL query cost**: Dynamic query must produce the same number of metafield aliases as before (7 variant + 2 product). More aliases = higher cost = possible 503s at page size 50.
- **Alias naming**: Changing from `metaColour` to `meta_colour` in the GraphQL response changes the array keys the mapper reads. All three mapper construction sites and the sync workflow must be updated in the same deployment.
- **Empty mapping config**: Must gracefully produce a minimal valid feed item (no crashes on missing config keys).
