Skip to content

Multi-Market Implementation: Phase 5 - API Integration

Status: Planning

Dependencies

Requires completed: - Phase 1 (Entities) - Multi-market entities exist - Phase 2 (Migration) - Fields added to existing entities - Phase 3 (Services) - BuyingOptionService, AffiliateUrlService - Phase 4 (Cache/Repository) - Repository methods, cache invalidation

Blocks: - Phase 6 (EasyAdmin) - Admin interface uses same DTOs - Phase 7 (Matrix UI) - Optional bean-centric UI

📋 Todo Checklist

  • [ ] Refactor EntityToDtoMapper to use BuyingOptionService
  • [ ] Remove selectCrawlConfig() from coffee bean mapping
  • [ ] Update roaster DTO mapping to use markets
  • [ ] Verify all API endpoints work with new logic
  • [ ] Test backward compatibility (no breaking changes)
  • [ ] Update API documentation
  • [ ] Test performance (caching, N+1 prevention)
  • [ ] Test with various visitor countries

🔍 Analysis & Investigation

Problem Statement

The current API uses EntityToDtoMapper::selectCrawlConfig() to pick URLs based on visitor country. This needs to be replaced with the multi-market URL resolution system that includes: - Manual overrides (BuyingOption) - Priority-based selection - Affiliate transformation - Market-based availability

Current Architecture

EntityToDtoMapper (lines 218-267 in current implementation): - Uses selectCrawlConfig() to pick RoasterCrawlConfig by visitor country - Finds matching CrawlUrl for that config - Returns raw URL without affiliate transformation - Uses global CoffeeBean.available field

API Endpoints: - GET /api/coffee-beans - List endpoint - GET /api/coffee-beans/{id} - Detail endpoint - GET /api/coffee-beans/by-slug/{slug} - Slug lookup - GET /api/coffee-beans/{id}/similar - Similar beans

All endpoints accept visitorCountryId query parameter.

Target Architecture

EntityToDtoMapper: - Inject BuyingOptionService and CountryRepository - Delegate URL resolution to BuyingOptionService::getUrlForVisitor() - Remove selectCrawlConfig() for coffee beans (keep for roasters) - Calculate availability from CrawlUrl.available based on visitor country

API Endpoints: - No changes to endpoint signatures - Backward compatible behavior - Same DTOs, different internal logic

📝 Implementation Plan

Prerequisites

  • Phase 1-4 completed
  • All services and repository methods implemented
  • Cache invalidation working

Step-by-Step Implementation

Step 1: Update EntityToDtoMapper Constructor

File: src/Service/Api/EntityToDtoMapper.php (modify existing)

Add service dependencies:

<?php

namespace App\Service\Api;

use App\Repository\CountryRepository;
use App\Service\Market\BuyingOptionService;
// ... existing imports

final class EntityToDtoMapper
{
    public function __construct(
        // ... existing dependencies
        private readonly BuyingOptionService $buyingOptionService,
        private readonly CountryRepository $countryRepository,
    ) {}

    // ... methods
}

Step 2: Refactor mapCoffeeBeanToDto()

File: src/Service/Api/EntityToDtoMapper.php

Replace URL resolution logic:

/**
 * Map CoffeeBean entity to DTO.
 *
 * @param CoffeeBean $coffeeBean Entity to map (should have crawlUrls loaded with joins)
 * @param string|null $visitorCountryId Visitor's country code (2-letter, e.g., 'DE')
 * @return CoffeeBeanDTO Mapped DTO with URL and availability
 */
public function mapCoffeeBeanToDto(
    CoffeeBean $coffeeBean,
    ?string $visitorCountryId = null
): CoffeeBeanDTO {
    // ... existing field mapping (name, roaster, origin, variety, etc.) ...

    // NEW: URL resolution with affiliate transformation
    $url = null;
    $available = false;

    if ($visitorCountryId !== null) {
        // Resolve URL for specific country
        $visitorCountry = $this->countryRepository->find($visitorCountryId);
        if ($visitorCountry) {
            $url = $this->buyingOptionService->getUrlForVisitor(
                $coffeeBean,
                $visitorCountry
            );
            $available = ($url !== null); // URL exists = available in this country
        }
    } else {
        // No country filter: check if available in ANY market
        $available = $this->buyingOptionService->isAvailableInAnyMarket($coffeeBean);
        // url remains null (no country = no specific URL)
    }

    return new CoffeeBeanDTO(
        id: $coffeeBean->getId(),
        name: $coffeeBean->getName(),
        roaster: $this->mapRoasterToDto($coffeeBean->getRoaster(), $visitorCountryId),
        // ... other fields ...
        url: $url,
        available: $available,
        // ... remaining fields ...
    );
}

Key changes: - With visitorCountryId: Returns affiliate-transformed URL from BuyingOptionService - Without visitorCountryId: Returns null URL, availability based on any market - Availability is per-country, not global - URL includes affiliate tracking automatically

Step 3: Remove Old selectCrawlConfig() for Beans

File: src/Service/Api/EntityToDtoMapper.php

Remove or comment out the old method:

/**
 * DEPRECATED: No longer used for coffee beans (now using BuyingOptionService).
 * Kept for roaster DTO mapping only.
 *
 * @deprecated Use BuyingOptionService::getUrlForVisitor() for coffee beans
 */
private function selectCrawlConfig(
    Collection $crawlConfigs,
    ?string $visitorCountryId = null
): ?RoasterCrawlConfig {
    // ... existing implementation (keep for roasters) ...
}

Step 4: Update Roaster DTO Mapping (Keep Simplified Logic)

File: src/Service/Api/EntityToDtoMapper.php

Update roaster config selection to use markets:

/**
 * Select a RoasterCrawlConfig for a roaster based on visitor country.
 *
 * Used for roaster DTO mapping (website URLs, not product URLs).
 *
 * @param Collection<RoasterCrawlConfig> $crawlConfigs Available configs
 * @param string|null $visitorCountryId Visitor's country code
 * @return RoasterCrawlConfig|null Selected config or null
 */
private function selectCrawlConfig(
    Collection $crawlConfigs,
    ?string $visitorCountryId = null
): ?RoasterCrawlConfig {
    $activeConfigs = $crawlConfigs->filter(
        fn(RoasterCrawlConfig $config) => $config->isActive()
    );

    if ($activeConfigs->isEmpty()) {
        return null;
    }

    // With visitor country: find config whose market serves that country
    if ($visitorCountryId !== null) {
        foreach ($activeConfigs as $config) {
            $market = $config->getMarket();
            if ($market && $market->isActive()) {
                foreach ($market->getCountries() as $country) {
                    if ($country->getId() === $visitorCountryId) {
                        return $config;
                    }
                }
            }
        }
        return null; // No config serves this country
    }

    // Without country: find default config, or fall back to first active
    foreach ($activeConfigs as $config) {
        if ($config->isDefault()) {
            return $config;
        }
    }

    return $activeConfigs->first() ?: null;
}

Notes: - Roasters don't need the full multi-market resolution (no manual overrides, no affiliate transformation) - Simple selection: config whose market serves visitor's country - Falls back to default config if no visitor country provided

Step 5: Verify API Endpoint Controllers

Files: src/Controller/Api/* (verify, do not modify)

Verify all endpoints already: 1. Accept visitorCountryId query parameter 2. Pass it to EntityToDtoMapper 3. Return DTOs with url and available fields

Endpoints to verify: - CoffeeBeanController::list() - CoffeeBeanController::detail() - CoffeeBeanController::bySlug() - CoffeeBeanController::similar()

No changes needed if they already follow this pattern.

Step 6: Update CoffeeBeanDTO Documentation

File: src/DTO/CoffeeBeanDTO.php (add docblock)

Document the new behavior:

/**
 * Coffee Bean Data Transfer Object
 *
 * Fields:
 * - url: Buying URL for visitor's country (with affiliate tracking), or null if:
 *   - No visitorCountryId provided
 *   - Bean not available in visitor's country
 * - available: Availability status:
 *   - With visitorCountryId: true if bean has available CrawlUrl for that country
 *   - Without visitorCountryId: true if bean has available CrawlUrl in any market
 */
final readonly class CoffeeBeanDTO
{
    public function __construct(
        public string $id,
        public string $name,
        public RoasterDTO $roaster,
        // ... other fields ...
        public ?string $url, // Affiliate-transformed URL or null
        public bool $available, // Market-aware availability
        // ... remaining fields ...
    ) {}
}

Step 7: Create API Integration Tests

File: tests/Controller/Api/CoffeeBeanControllerTest.php (extend existing)

Add tests for multi-market behavior:

public function testListEndpointWithVisitorCountry(): void
{
    // Create test data: bean available in DE market
    $bean = $this->createBeanInMarket('DE');

    // Request with DE visitor
    $response = $this->client->request('GET', '/api/coffee-beans?visitorCountryId=DE');

    $this->assertResponseIsSuccessful();
    $data = $response->toArray();

    $this->assertCount(1, $data['items']);
    $this->assertTrue($data['items'][0]['available']);
    $this->assertNotNull($data['items'][0]['url']);
    // Verify URL has affiliate tracking
    $this->assertStringContainsString('ref=', $data['items'][0]['url']);
}

public function testListEndpointFiltersUnavailableBeansForCountry(): void
{
    // Create bean only available in US market
    $bean = $this->createBeanInMarket('US');

    // Request with DE visitor
    $response = $this->client->request('GET', '/api/coffee-beans?visitorCountryId=DE&available=true');

    $this->assertResponseIsSuccessful();
    $data = $response->toArray();

    $this->assertCount(0, $data['items'], 'Bean should not appear for DE visitor');
}

public function testDetailEndpointWithManualOverride(): void
{
    // Create bean with manual Amazon override for DE market
    $bean = $this->createBeanWithManualOverride('DE', 'https://amazon.de/dp/B08XYZ');

    // Request with DE visitor
    $response = $this->client->request('GET', '/api/coffee-beans/' . $bean->getId() . '?visitorCountryId=DE');

    $this->assertResponseIsSuccessful();
    $data = $response->toArray();

    $this->assertEquals('https://amazon.de/dp/B08XYZ', $data['url']);
    $this->assertTrue($data['available']);
}

public function testPriorityBasedSelection(): void
{
    // Create bean with two configs for DE:
    // - Config A: priority 50
    // - Config B: priority 100
    $bean = $this->createBeanWithMultipleConfigs([
        ['country' => 'DE', 'priority' => 50, 'url' => 'https://low-priority.com'],
        ['country' => 'DE', 'priority' => 100, 'url' => 'https://high-priority.com'],
    ]);

    // Request with DE visitor
    $response = $this->client->request('GET', '/api/coffee-beans/' . $bean->getId() . '?visitorCountryId=DE');

    $this->assertResponseIsSuccessful();
    $data = $response->toArray();

    // Should return high-priority URL
    $this->assertStringContainsString('high-priority.com', $data['url']);
}

public function testBackwardCompatibilityWithoutCountry(): void
{
    // Create bean available in multiple markets
    $bean = $this->createBeanInMultipleMarkets(['US', 'DE', 'FR']);

    // Request WITHOUT visitorCountryId
    $response = $this->client->request('GET', '/api/coffee-beans/' . $bean->getId());

    $this->assertResponseIsSuccessful();
    $data = $response->toArray();

    $this->assertNull($data['url'], 'URL should be null without country');
    $this->assertTrue($data['available'], 'Should be available (in ANY market)');
}

Step 8: Performance Testing

File: tests/Performance/ApiPerformanceTest.php (create new)

Test query performance:

public function testListEndpointQueryCount(): void
{
    // Create 100 beans with multiple CrawlUrls, Markets, AffiliatePrograms
    $this->createComplexBeanDataset(100);

    // Enable query logging
    $queryLogger = $this->enableQueryLogging();

    // Request list endpoint
    $this->client->request('GET', '/api/coffee-beans?visitorCountryId=DE&limit=50');

    // Verify acceptable query count (no N+1)
    $queries = $queryLogger->getQueries();
    $this->assertLessThan(10, count($queries), 'Should not have excessive queries');
}

public function testCachingReducesQueries(): void
{
    $bean = $this->createBeanInMarket('DE');

    // First request (cache miss)
    $queryLogger1 = $this->enableQueryLogging();
    $this->client->request('GET', '/api/coffee-beans/' . $bean->getId() . '?visitorCountryId=DE');
    $firstRequestQueries = count($queryLogger1->getQueries());

    // Second request (cache hit)
    $queryLogger2 = $this->enableQueryLogging();
    $this->client->request('GET', '/api/coffee-beans/' . $bean->getId() . '?visitorCountryId=DE');
    $secondRequestQueries = count($queryLogger2->getQueries());

    $this->assertLessThan($firstRequestQueries, $secondRequestQueries, 'Cache should reduce queries');
}

Testing Strategy

Unit Tests

  1. EntityToDtoMapper:
  2. Test mapCoffeeBeanToDto() with visitor country
  3. Test without visitor country
  4. Test with manual override
  5. Test with priority-based selection
  6. Test availability calculation

  7. Roaster DTO Mapping:

  8. Test selectCrawlConfig() with markets
  9. Test fallback to default config
  10. Test with no matching config

Integration Tests

  1. API Endpoints:
  2. Test all endpoints with visitorCountryId
  3. Test filtering by availability
  4. Test URL includes affiliate tracking
  5. Test manual overrides
  6. Test priority-based selection

  7. Backward Compatibility:

  8. Test endpoints without visitorCountryId
  9. Verify no breaking changes
  10. Test legacy configs (market = NULL)

  11. Performance:

  12. Test query count (no N+1)
  13. Test caching effectiveness
  14. Test with large datasets

Manual Testing

  1. Different Countries:
  2. Test visitor from US, DE, FR, etc.
  3. Verify correct URLs returned
  4. Verify affiliate tracking

  5. Edge Cases:

  6. Bean available in some markets, not others
  7. Multiple configs with different priorities
  8. Manual overrides
  9. Legacy configs (no market)

🎯 Success Criteria

  • EntityToDtoMapper uses BuyingOptionService for URL resolution
  • API endpoints return affiliate-transformed URLs
  • Manual overrides (BuyingOption) work correctly
  • Priority-based selection works as expected
  • Availability is calculated per-country (not global)
  • Backward compatibility maintained (no breaking changes)
  • All API tests pass
  • Performance acceptable (caching, no N+1 queries)
  • URLs include correct affiliate tracking parameters
  • Legacy mode (market = NULL) still works

Behavioral Changes

URL Field

Before: - Raw product URL from selected RoasterCrawlConfig - No affiliate tracking - Selected by shipsTo countries only

After: - Affiliate-transformed URL from BuyingOptionService - Includes affiliate tracking parameters - Selected by priority cascade (override → priority → affiliate) - NULL if no visitor country or bean not available

Available Field

Before: - Global CoffeeBean.available field - Same for all countries

After: - Per-country availability based on CrawlUrl.available - With country: true if bean has available CrawlUrl for that market - Without country: true if bean has available CrawlUrl in any market

No Breaking Changes

  • API endpoint signatures unchanged
  • DTO structure unchanged
  • Query parameter names unchanged
  • Response format unchanged
  • Only internal logic changed

Files to Modify:

  • src/Service/Api/EntityToDtoMapper.php

Files to Create:

  • tests/Controller/Api/CoffeeBeanControllerTest.php (extend existing)
  • tests/Performance/ApiPerformanceTest.php

Files to Reference (verify, not modify):

  • src/Controller/Api/CoffeeBeanController.php
  • src/DTO/CoffeeBeanDTO.php
  • src/DTO/RoasterDTO.php

Files Used (from previous phases):

  • src/Service/Market/BuyingOptionService.php
  • src/Service/Affiliate/AffiliateUrlService.php
  • src/Repository/CountryRepository.php
  • src/Repository/CoffeeBeanRepository.php

Next Steps

After completing this phase: - Proceed to Phase 6 (EasyAdmin) to create admin controllers for managing markets - API ready for production use - Frontend can consume affiliate URLs with tracking - Admins can configure markets and affiliate programs