feat(api): implement javascript-first architecture for cache compatibility

- Add REST endpoint GET /roi-theme/v1/adsense-placement/visibility
- Add Domain layer: UserContext, VisibilityDecision, AdsenseSettings VOs
- Add Application layer: CheckAdsenseVisibilityUseCase
- Add Infrastructure: AdsenseVisibilityChecker, Controller, Enqueuer
- Add JavaScript controller with localStorage caching
- Add test plan for production validation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-12-11 13:03:14 -06:00
parent 8936670451
commit 26546e1d69
13 changed files with 1743 additions and 1 deletions

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Domain\Contracts;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\UserContext;
use ROITheme\Public\AdsensePlacement\Domain\ValueObjects\VisibilityDecision;
/**
* Interface para el servicio que evalua visibilidad de anuncios.
*
* Domain Layer - Define el contrato sin dependencias de infraestructura.
*
* @package ROITheme\Public\AdsensePlacement\Domain\Contracts
*/
interface AdsenseVisibilityCheckerInterface
{
/**
* Evalua si los anuncios deben mostrarse para el contexto dado.
*
* @param int $postId ID del post (0 para paginas de archivo/home)
* @param UserContext $userContext Contexto del usuario
* @return VisibilityDecision Decision con razones y cache
*/
public function check(int $postId, UserContext $userContext): VisibilityDecision;
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Domain\ValueObjects;
/**
* Value Object que encapsula la configuracion de AdSense relevante para visibilidad.
*
* Inmutable despues de construccion. Sin dependencias de WordPress.
*
* @package ROITheme\Public\AdsensePlacement\Domain\ValueObjects
*/
final class AdsenseSettings
{
/**
* @param bool $isEnabled Si AdSense esta activo globalmente
* @param bool $showOnDesktop Si se muestra en desktop
* @param bool $showOnMobile Si se muestra en mobile
* @param bool $hideForLoggedIn Si se oculta para usuarios logueados
* @param bool $javascriptFirstMode Si el modo JS-first esta activo
* @param array<int> $excludedCategoryIds IDs de categorias excluidas
* @param array<int> $excludedPostIds IDs de posts excluidos
* @param array<string> $excludedPostTypes Post types excluidos
*/
public function __construct(
private bool $isEnabled,
private bool $showOnDesktop,
private bool $showOnMobile,
private bool $hideForLoggedIn,
private bool $javascriptFirstMode,
private array $excludedCategoryIds = [],
private array $excludedPostIds = [],
private array $excludedPostTypes = []
) {
}
public function isEnabled(): bool
{
return $this->isEnabled;
}
public function showOnDesktop(): bool
{
return $this->showOnDesktop;
}
public function showOnMobile(): bool
{
return $this->showOnMobile;
}
public function hideForLoggedIn(): bool
{
return $this->hideForLoggedIn;
}
public function isJavascriptFirstMode(): bool
{
return $this->javascriptFirstMode;
}
/**
* @return array<int>
*/
public function getExcludedCategoryIds(): array
{
return $this->excludedCategoryIds;
}
/**
* @return array<int>
*/
public function getExcludedPostIds(): array
{
return $this->excludedPostIds;
}
/**
* @return array<string>
*/
public function getExcludedPostTypes(): array
{
return $this->excludedPostTypes;
}
public function isPostExcluded(int $postId): bool
{
return in_array($postId, $this->excludedPostIds, true);
}
public function isPostTypeExcluded(string $postType): bool
{
return in_array($postType, $this->excludedPostTypes, true);
}
public function isCategoryExcluded(int $categoryId): bool
{
return in_array($categoryId, $this->excludedCategoryIds, true);
}
/**
* Crea instancia desde array de configuracion de BD.
*
* @param array<string, array<string, mixed>> $settings Configuracion agrupada
*/
public static function fromArray(array $settings): self
{
$visibility = $settings['visibility'] ?? [];
$behavior = $settings['behavior'] ?? [];
$forms = $settings['forms'] ?? [];
return new self(
isEnabled: (bool) ($visibility['is_enabled'] ?? false),
showOnDesktop: (bool) ($visibility['show_on_desktop'] ?? true),
showOnMobile: (bool) ($visibility['show_on_mobile'] ?? true),
hideForLoggedIn: (bool) ($visibility['hide_for_logged_in'] ?? false),
javascriptFirstMode: (bool) ($behavior['javascript_first_mode'] ?? false),
excludedCategoryIds: self::parseIds($forms['exclude_categories'] ?? ''),
excludedPostIds: self::parseIds($forms['exclude_post_ids'] ?? ''),
excludedPostTypes: self::parsePostTypes($forms['exclude_post_types'] ?? '')
);
}
/**
* Parsea string de IDs separados por coma a array de enteros.
*
* @return array<int>
*/
private static function parseIds(string $value): array
{
if (empty($value)) {
return [];
}
return array_filter(
array_map(
static fn(string $id): int => (int) trim($id),
explode(',', $value)
),
static fn(int $id): bool => $id > 0
);
}
/**
* Parsea string de post types separados por coma.
*
* @return array<string>
*/
private static function parsePostTypes(string $value): array
{
if (empty($value)) {
return [];
}
return array_filter(
array_map(
static fn(string $type): string => trim($type),
explode(',', $value)
),
static fn(string $type): bool => $type !== ''
);
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Domain\ValueObjects;
/**
* Value Object que encapsula el contexto del usuario para decisiones de visibilidad.
*
* Inmutable despues de construccion. Sin dependencias de WordPress.
*
* @package ROITheme\Public\AdsensePlacement\Domain\ValueObjects
*/
final class UserContext
{
/**
* @param bool $isLoggedIn Si el usuario tiene sesion activa
* @param bool $isMobile Si el dispositivo es movil (viewport < 992px)
* @param array<int> $userRoles IDs de roles del usuario
*/
public function __construct(
private bool $isLoggedIn,
private bool $isMobile,
private array $userRoles = []
) {
}
public function isLoggedIn(): bool
{
return $this->isLoggedIn;
}
public function isMobile(): bool
{
return $this->isMobile;
}
public function isDesktop(): bool
{
return !$this->isMobile;
}
/**
* @return array<int>
*/
public function getUserRoles(): array
{
return $this->userRoles;
}
public function hasRole(int $roleId): bool
{
return in_array($roleId, $this->userRoles, true);
}
/**
* Crea instancia desde array (para deserializacion).
*
* @param array{is_logged_in: bool, is_mobile: bool, user_roles?: array<int>} $data
*/
public static function fromArray(array $data): self
{
return new self(
isLoggedIn: $data['is_logged_in'] ?? false,
isMobile: $data['is_mobile'] ?? false,
userRoles: $data['user_roles'] ?? []
);
}
/**
* Serializa a array para respuesta JSON.
*
* @return array{is_logged_in: bool, is_mobile: bool, user_roles: array<int>}
*/
public function toArray(): array
{
return [
'is_logged_in' => $this->isLoggedIn,
'is_mobile' => $this->isMobile,
'user_roles' => $this->userRoles,
];
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Domain\ValueObjects;
/**
* Value Object que representa la decision de mostrar o no anuncios.
*
* Inmutable despues de construccion. Sin dependencias de WordPress.
* El timestamp se inyecta en toArray() para mantener Domain puro.
*
* @package ROITheme\Public\AdsensePlacement\Domain\ValueObjects
*/
final class VisibilityDecision
{
private const DEFAULT_CACHE_SECONDS = 300; // 5 minutos
/**
* @param bool $showAds Si se deben mostrar los anuncios
* @param array<string> $reasons Razones de la decision (para debugging)
* @param int $cacheSeconds Tiempo de cache en segundos
*/
public function __construct(
private bool $showAds,
private array $reasons = [],
private int $cacheSeconds = self::DEFAULT_CACHE_SECONDS
) {
}
public function shouldShowAds(): bool
{
return $this->showAds;
}
/**
* @return array<string>
*/
public function getReasons(): array
{
return $this->reasons;
}
public function getCacheSeconds(): int
{
return $this->cacheSeconds;
}
/**
* Factory: Crea decision positiva (mostrar ads).
*/
public static function show(int $cacheSeconds = self::DEFAULT_CACHE_SECONDS): self
{
return new self(
showAds: true,
reasons: ['all_conditions_passed'],
cacheSeconds: $cacheSeconds
);
}
/**
* Factory: Crea decision negativa (ocultar ads).
*
* @param array<string> $reasons
*/
public static function hide(array $reasons, int $cacheSeconds = self::DEFAULT_CACHE_SECONDS): self
{
return new self(
showAds: false,
reasons: $reasons,
cacheSeconds: $cacheSeconds
);
}
/**
* Serializa a array para respuesta JSON.
*
* El timestamp se inyecta aqui (no en constructor) para mantener Domain puro.
*
* @param int $timestamp Unix timestamp actual
* @return array{show_ads: bool, reasons: array<string>, cache_seconds: int, timestamp: int}
*/
public function toArray(int $timestamp): array
{
return [
'show_ads' => $this->showAds,
'reasons' => $this->reasons,
'cache_seconds' => $this->cacheSeconds,
'timestamp' => $timestamp,
];
}
}