Skip to content

Multi-Market Implementation: Phase 1 - Core Entities and Validation

Status: Planning

📋 Todo Checklist

  • [ ] Create Market entity with country relationships
  • [ ] Create AffiliateProgram entity with Twig template support
  • [ ] Create BuyingOption entity with unique constraint
  • [ ] Add validation constraints to all new entities
  • [ ] Create database migration for new tables and indexes
  • [ ] Create unit tests for entity validation
  • [ ] Update doctrine schema and verify migrations

🔍 Analysis & Investigation

Problem Statement

The current system lacks multi-market support for handling different affiliate programs and buying options across geographic regions. Each coffee bean needs to support: - Different URLs for different markets (US Direct, US Amazon, EU, etc.) - Market-specific affiliate transformations - Manual overrides for special cases (Amazon links, Roastmarket.de, etc.)

Current Architecture

The system currently uses: - RoasterCrawlConfig.shipsTo for geographic coverage - Direct URLs without affiliate transformation - Global CoffeeBean.available flag (to be replaced)

Target Architecture

Three new entities form the foundation of multi-market support: 1. Market - Consumer markets with geographic coverage and affiliate programs 2. AffiliateProgram - URL transformation templates using Twig 3. BuyingOption - Sparse manual URL overrides per bean-market combination

Dependencies

Prerequisites: - None - this is phase 1

Blocked by this phase: - Phase 2 (Migration) - needs entities to exist - Phase 3 (Services) - needs entities to exist - All subsequent phases

📝 Implementation Plan

Prerequisites

  • Doctrine ORM configured
  • Symfony validation component available
  • Existing entities: Country, CoffeeBean, RoasterCrawlConfig

Step-by-Step Implementation

Step 1: Create Market Entity

File: src/Entity/Market.php

Create entity representing consumer markets with geographic coverage:

<?php

namespace App\Entity;

use App\Repository\MarketRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: MarketRepository::class)]
#[ORM\Table(name: 'market')]
#[ORM\Index(name: 'idx_market_active', columns: ['is_active'])]
#[ORM\HasLifecycleCallbacks]
class Market
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid', unique: true)]
    private Uuid $id;

    #[ORM\Column(type: Types::STRING, length: 255)]
    #[Assert\NotBlank(message: 'Market name is required.')]
    #[Assert\Length(max: 255)]
    private string $name;

    #[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])]
    private bool $isActive = true;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $notes = null;

    #[ORM\ManyToMany(targetEntity: Country::class)]
    #[ORM\JoinTable(name: 'market_country')]
    #[Assert\Count(
        min: 1,
        minMessage: 'At least one country must be assigned to the market.'
    )]
    private Collection $countries;

    #[ORM\ManyToOne(targetEntity: AffiliateProgram::class, inversedBy: 'markets')]
    #[ORM\JoinColumn(name: 'affiliate_program_id', referencedColumnName: 'id', nullable: true)]
    private ?AffiliateProgram $affiliateProgram = null;

    #[ORM\OneToMany(mappedBy: 'market', targetEntity: RoasterCrawlConfig::class)]
    private Collection $roasterCrawlConfigs;

    #[ORM\OneToMany(mappedBy: 'market', targetEntity: BuyingOption::class, cascade: ['remove'])]
    private Collection $buyingOptions;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private \DateTimeImmutable $updatedAt;

    public function __construct()
    {
        $this->id = Uuid::v7();
        $this->countries = new ArrayCollection();
        $this->roasterCrawlConfigs = new ArrayCollection();
        $this->buyingOptions = new ArrayCollection();
        $this->createdAt = new \DateTimeImmutable();
        $this->updatedAt = new \DateTimeImmutable();
    }

    #[ORM\PreUpdate]
    public function preUpdate(): void
    {
        $this->updatedAt = new \DateTimeImmutable();
    }

    public function getId(): Uuid
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): self
    {
        $this->name = $name;
        return $this;
    }

    public function isActive(): bool
    {
        return $this->isActive;
    }

    public function setIsActive(bool $isActive): self
    {
        $this->isActive = $isActive;
        return $this;
    }

    public function getNotes(): ?string
    {
        return $this->notes;
    }

    public function setNotes(?string $notes): self
    {
        $this->notes = $notes;
        return $this;
    }

    public function getCountries(): Collection
    {
        return $this->countries;
    }

    public function addCountry(Country $country): self
    {
        if (!$this->countries->contains($country)) {
            $this->countries->add($country);
        }
        return $this;
    }

    public function removeCountry(Country $country): self
    {
        $this->countries->removeElement($country);
        return $this;
    }

    public function getAffiliateProgram(): ?AffiliateProgram
    {
        return $this->affiliateProgram;
    }

    public function setAffiliateProgram(?AffiliateProgram $affiliateProgram): self
    {
        $this->affiliateProgram = $affiliateProgram;
        return $this;
    }

    public function getRoasterCrawlConfigs(): Collection
    {
        return $this->roasterCrawlConfigs;
    }

    public function getBuyingOptions(): Collection
    {
        return $this->buyingOptions;
    }

    public function getCreatedAt(): \DateTimeImmutable
    {
        return $this->createdAt;
    }

    public function getUpdatedAt(): \DateTimeImmutable
    {
        return $this->updatedAt;
    }

    public function __toString(): string
    {
        return $this->name;
    }
}

Step 2: Create AffiliateProvider Enum

File: src/Enum/AffiliateProvider.php

<?php

namespace App\Enum;

enum AffiliateProvider: string
{
    case IMPACT = 'impact';
    case AWIN = 'awin';
    case PARTNERIZE = 'partnerize';
    case AMAZON_ASSOCIATES = 'amazon_associates';
    case CUSTOM = 'custom';

    public function getLabel(): string
    {
        return match($this) {
            self::IMPACT => 'Impact',
            self::AWIN => 'AWIN',
            self::PARTNERIZE => 'Partnerize',
            self::AMAZON_ASSOCIATES => 'Amazon Associates',
            self::CUSTOM => 'Custom',
        };
    }
}

Step 3: Create AffiliateProgram Entity

File: src/Entity/AffiliateProgram.php

<?php

namespace App\Entity;

use App\Enum\AffiliateProvider;
use App\Repository\AffiliateProgramRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

#[ORM\Entity(repositoryClass: AffiliateProgramRepository::class)]
#[ORM\Table(name: 'affiliate_program')]
#[ORM\Index(name: 'idx_affiliate_active', columns: ['is_active'])]
#[ORM\Index(name: 'idx_affiliate_provider', columns: ['provider'])]
#[ORM\HasLifecycleCallbacks]
class AffiliateProgram
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid', unique: true)]
    private Uuid $id;

    #[ORM\Column(type: Types::STRING, length: 255)]
    #[Assert\NotBlank(message: 'Program name is required.')]
    #[Assert\Length(max: 255)]
    private string $name;

    #[ORM\Column(type: Types::STRING, enumType: AffiliateProvider::class)]
    #[Assert\NotNull]
    private AffiliateProvider $provider;

    #[ORM\Column(type: Types::STRING, length: 255)]
    #[Assert\NotBlank(message: 'Affiliate ID is required.')]
    #[Assert\Length(max: 255)]
    private string $affiliateId;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    #[Assert\Length(max: 2000, maxMessage: 'URL pattern too long (max 2000 chars)')]
    private ?string $urlPattern = null;

    #[ORM\Column(type: Types::JSON, nullable: true)]
    private ?array $parameters = null;

    #[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])]
    private bool $isActive = true;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $notes = null;

    #[ORM\OneToMany(mappedBy: 'affiliateProgram', targetEntity: Market::class)]
    private Collection $markets;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private \DateTimeImmutable $updatedAt;

    public function __construct()
    {
        $this->id = Uuid::v7();
        $this->markets = new ArrayCollection();
        $this->createdAt = new \DateTimeImmutable();
        $this->updatedAt = new \DateTimeImmutable();
    }

    #[ORM\PreUpdate]
    public function preUpdate(): void
    {
        $this->updatedAt = new \DateTimeImmutable();
    }

    #[Assert\Callback]
    public function validateUrlPattern(ExecutionContextInterface $context): void
    {
        if ($this->provider === AffiliateProvider::CUSTOM && empty($this->urlPattern)) {
            $context->buildViolation('URL Pattern is required for CUSTOM provider.')
                ->atPath('urlPattern')
                ->addViolation();
        }
    }

    #[Assert\Callback]
    public function validateParameters(ExecutionContextInterface $context): void
    {
        if ($this->parameters === null) {
            return;
        }

        // Validate reserved variables
        $reserved = ['url', 'affiliateId'];
        foreach (array_keys($this->parameters) as $key) {
            if (in_array($key, $reserved, true)) {
                $context->buildViolation('Parameter "{{ key }}" is reserved and cannot be used.')
                    ->setParameter('{{ key }}', $key)
                    ->atPath('parameters')
                    ->addViolation();
            }
        }
    }

    public function getId(): Uuid
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): self
    {
        $this->name = $name;
        return $this;
    }

    public function getProvider(): AffiliateProvider
    {
        return $this->provider;
    }

    public function setProvider(AffiliateProvider $provider): self
    {
        $this->provider = $provider;
        return $this;
    }

    public function getAffiliateId(): string
    {
        return $this->affiliateId;
    }

    public function setAffiliateId(string $affiliateId): self
    {
        $this->affiliateId = $affiliateId;
        return $this;
    }

    public function getUrlPattern(): ?string
    {
        return $this->urlPattern;
    }

    public function setUrlPattern(?string $urlPattern): self
    {
        $this->urlPattern = $urlPattern;
        return $this;
    }

    public function getParameters(): ?array
    {
        return $this->parameters;
    }

    public function setParameters(?array $parameters): self
    {
        $this->parameters = $parameters;
        return $this;
    }

    public function isActive(): bool
    {
        return $this->isActive;
    }

    public function setIsActive(bool $isActive): self
    {
        $this->isActive = $isActive;
        return $this;
    }

    public function getNotes(): ?string
    {
        return $this->notes;
    }

    public function setNotes(?string $notes): self
    {
        $this->notes = $notes;
        return $this;
    }

    public function getMarkets(): Collection
    {
        return $this->markets;
    }

    public function getCreatedAt(): \DateTimeImmutable
    {
        return $this->createdAt;
    }

    public function getUpdatedAt(): \DateTimeImmutable
    {
        return $this->updatedAt;
    }

    public function __toString(): string
    {
        return $this->name;
    }
}

Step 4: Create BuyingOption Entity

File: src/Entity/BuyingOption.php

<?php

namespace App\Entity;

use App\Repository\BuyingOptionRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: BuyingOptionRepository::class)]
#[ORM\Table(name: 'buying_option')]
#[ORM\Index(name: 'idx_buying_option_bean', columns: ['coffee_bean_id'])]
#[ORM\UniqueConstraint(name: 'uniq_buying_option_bean_market', columns: ['coffee_bean_id', 'market_id'])]
#[ORM\HasLifecycleCallbacks]
class BuyingOption
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid', unique: true)]
    private Uuid $id;

    #[ORM\ManyToOne(targetEntity: CoffeeBean::class)]
    #[ORM\JoinColumn(name: 'coffee_bean_id', referencedColumnName: 'id', nullable: false)]
    #[Assert\NotNull(message: 'Coffee bean is required.')]
    private CoffeeBean $coffeeBean;

    #[ORM\ManyToOne(targetEntity: Market::class, inversedBy: 'buyingOptions')]
    #[ORM\JoinColumn(name: 'market_id', referencedColumnName: 'id', nullable: false)]
    #[Assert\NotNull(message: 'Market is required.')]
    private Market $market;

    #[ORM\Column(type: Types::STRING, length: 512)]
    #[Assert\NotBlank(message: 'URL override is required for manual buying options.')]
    #[Assert\Url(message: 'URL override must be a valid URL.')]
    #[Assert\Length(max: 512)]
    private string $urlOverride;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $notes = null;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private \DateTimeImmutable $updatedAt;

    public function __construct()
    {
        $this->id = Uuid::v7();
        $this->createdAt = new \DateTimeImmutable();
        $this->updatedAt = new \DateTimeImmutable();
    }

    #[ORM\PreUpdate]
    public function preUpdate(): void
    {
        $this->updatedAt = new \DateTimeImmutable();
    }

    public function getId(): Uuid
    {
        return $this->id;
    }

    public function getCoffeeBean(): CoffeeBean
    {
        return $this->coffeeBean;
    }

    public function setCoffeeBean(CoffeeBean $coffeeBean): self
    {
        $this->coffeeBean = $coffeeBean;
        return $this;
    }

    public function getMarket(): Market
    {
        return $this->market;
    }

    public function setMarket(Market $market): self
    {
        $this->market = $market;
        return $this;
    }

    public function getUrlOverride(): string
    {
        return $this->urlOverride;
    }

    public function setUrlOverride(string $urlOverride): self
    {
        $this->urlOverride = $urlOverride;
        return $this;
    }

    public function getNotes(): ?string
    {
        return $this->notes;
    }

    public function setNotes(?string $notes): self
    {
        $this->notes = $notes;
        return $this;
    }

    public function getCreatedAt(): \DateTimeImmutable
    {
        return $this->createdAt;
    }

    public function getUpdatedAt(): \DateTimeImmutable
    {
        return $this->updatedAt;
    }

    public function __toString(): string
    {
        return sprintf(
            '%s - %s',
            $this->coffeeBean->getName() ?? 'Unknown Bean',
            $this->market->getName() ?? 'Unknown Market'
        );
    }
}

Step 5: Create Repository Classes

File: src/Repository/MarketRepository.php

<?php

namespace App\Repository;

use App\Entity\Country;
use App\Entity\Market;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class MarketRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Market::class);
    }

    /**
     * Find all active markets serving a specific country.
     *
     * @return Market[]
     */
    public function findByCountry(Country $country): array
    {
        return $this->createQueryBuilder('m')
            ->join('m.countries', 'c')
            ->where('c.id = :countryId')
            ->andWhere('m.isActive = true')
            ->setParameter('countryId', $country->getId())
            ->getQuery()
            ->getResult();
    }
}

File: src/Repository/AffiliateProgramRepository.php

<?php

namespace App\Repository;

use App\Entity\AffiliateProgram;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class AffiliateProgramRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, AffiliateProgram::class);
    }
}

File: src/Repository/BuyingOptionRepository.php

<?php

namespace App\Repository;

use App\Entity\BuyingOption;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class BuyingOptionRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, BuyingOption::class);
    }
}

Step 6: Generate Database Migration

Run Doctrine migration diff to create migration file:

make migrate-diff ARGS="multi_market_entities"

Verify the generated migration includes: - New tables: market, affiliate_program, buying_option, market_country - All indexes defined in entity annotations - Unique constraint on buying_option (coffee_bean_id, market_id)

Step 7: Create Entity Validation Tests

File: tests/Entity/MarketTest.php

Test validation: - Market requires at least one country - Name is required and max 255 chars - isActive defaults to true - Relationships work correctly

File: tests/Entity/AffiliateProgramTest.php

Test validation: - CUSTOM provider requires urlPattern - Reserved parameters (url, affiliateId) are rejected - Parameters must be valid JSON - urlPattern max length 2000 chars

File: tests/Entity/BuyingOptionTest.php

Test validation: - urlOverride is required - urlOverride must be valid URL - coffeeBean and market are required - Unique constraint prevents duplicates

Testing Strategy

Unit Tests

  1. Entity Validation Tests:
  2. Test all validation constraints
  3. Test lifecycle callbacks (timestamps)
  4. Test relationship management (add/remove)
  5. Test __toString() methods

  6. Repository Tests:

  7. Test MarketRepository::findByCountry()
  8. Test with active/inactive markets
  9. Test with multiple markets serving same country

  10. Enum Tests:

  11. Test AffiliateProvider enum values
  12. Test getLabel() method

Integration Tests

  1. Database Schema Tests:
  2. Verify all tables created
  3. Verify all indexes exist
  4. Verify unique constraints work
  5. Test cascading deletes (Market → BuyingOption)

  6. Constraint Tests:

  7. Test unique constraint on BuyingOption (bean + market)
  8. Test foreign key constraints
  9. Test NOT NULL constraints

🎯 Success Criteria

  • All three entities (Market, AffiliateProgram, BuyingOption) created with complete validation
  • Database migration generates correct schema (tables, indexes, constraints)
  • Entity validation prevents invalid data:
  • Market requires at least 1 country
  • AffiliateProgram validates reserved parameters
  • CUSTOM provider requires urlPattern
  • BuyingOption enforces unique bean-market combinations
  • Repository methods work correctly (MarketRepository::findByCountry)
  • All unit tests pass
  • Migration runs successfully up and down (reversible)
  • Doctrine schema validation passes: bin/console doctrine:schema:validate

Files to Create:

  • src/Entity/Market.php
  • src/Entity/AffiliateProgram.php
  • src/Entity/BuyingOption.php
  • src/Enum/AffiliateProvider.php
  • src/Repository/MarketRepository.php
  • src/Repository/AffiliateProgramRepository.php
  • src/Repository/BuyingOptionRepository.php
  • migrations/VersionXXX_multi_market_entities.php (auto-generated)
  • tests/Entity/MarketTest.php
  • tests/Entity/AffiliateProgramTest.php
  • tests/Entity/BuyingOptionTest.php
  • tests/Repository/MarketRepositoryTest.php

Files to Reference (not modify):

  • src/Entity/Country.php
  • src/Entity/CoffeeBean.php
  • src/Entity/RoasterCrawlConfig.php

Next Steps

After completing this phase: - Proceed to Phase 2 (Migration) to add fields to existing entities and migrate data - Entities will be ready for service layer implementation in Phase 3