HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/ai.naniguide.com/app/Services/Ads/AdCreativePreflightValidator.php
<?php

namespace App\Services\Ads;

use App\Model\AdCampaign;
use App\Model\AdCreative;
use App\Model\AdPlatform;

/**
 * Pre-submit validator for a creative against the platforms it'll ship to.
 *
 * R5 introduces this so the wizard can surface warnings (and block on errors)
 * BEFORE the campaign hits Meta / Google / TikTok / LinkedIn APIs.  Every
 * platform has its own spec — image size, video length, headline/body
 * character caps, CTA-objective matrix.  Hitting the API with a bad creative
 * wastes a round-trip + a rate-limit hit + customer time.
 *
 * Output shape:
 *   [
 *     'errors'   => [ ['platform' => 'meta', 'field' => 'headline', 'message' => '...' ] ],
 *     'warnings' => [ ['platform' => 'meta', 'field' => 'body',     'message' => '...' ] ],
 *   ]
 *
 * Errors block submit. Warnings show in Step 6 review.
 *
 * Spec source: Meta Ads Manager 2026 guidelines, Google Ads 2026 text-ad
 * limits, TikTok Ads Creative Specs, LinkedIn Campaign Manager limits.
 * These are documented here so future spec drift is a single-file update.
 */
class AdCreativePreflightValidator
{
    private const LIMITS = [
        AdPlatform::PLATFORM_META => [
            'headline_max' => 40,
            'body_max' => 125,
            'description_max' => 30,
            'image_min_dimension' => 600,
            'video_min_seconds' => 1,
            'video_max_seconds' => 240,
        ],
        AdPlatform::PLATFORM_GOOGLE => [
            'headline_max' => 30,
            'body_max' => 90,
            'description_max' => 90,
            'image_min_dimension' => 600,
            'video_min_seconds' => 6,
            'video_max_seconds' => 120,
        ],
        AdPlatform::PLATFORM_TIKTOK => [
            'headline_max' => 100,
            'body_max' => 100,
            'description_max' => 100,
            'image_min_dimension' => 600,
            'video_min_seconds' => 5,
            'video_max_seconds' => 180,
        ],
        AdPlatform::PLATFORM_LINKEDIN => [
            'headline_max' => 70,
            'body_max' => 150,
            'description_max' => 70,
            'image_min_dimension' => 400,
            'video_min_seconds' => 3,
            'video_max_seconds' => 30 * 60,
        ],
    ];

    /**
     * CTA → objective compatibility.  Mismatches surface as WARNINGS (not
     * blockers) because platforms often accept them with reduced performance,
     * not an outright rejection.
     *
     * @var array<string,array<int,string>>
     */
    private const CTA_FOR_OBJECTIVE = [
        'awareness' => ['learn_more', 'sign_up'],
        'traffic' => ['learn_more', 'shop_now', 'book_now', 'apply_now', 'get_quote'],
        'engagement' => ['learn_more', 'contact_us', 'sign_up'],
        'leads' => ['sign_up', 'get_quote', 'apply_now', 'contact_us', 'download'],
        'sales' => ['shop_now', 'book_now', 'apply_now'],
        'app_installs' => ['download'],
    ];

    /**
     * Run preflight against a creative + target platforms + campaign context.
     *
     * @param array<int,AdPlatform> $platforms
     * @return array{errors: array<int, array{platform: string, field: string, message: string}>, warnings: array<int, array{platform: string, field: string, message: string}>}
     */
    public function validate(AdCreative $creative, array $platforms, ?AdCampaign $campaign = null): array
    {
        $errors = [];
        $warnings = [];

        foreach ($platforms as $platform) {
            $slug = $platform->platform;
            $limits = self::LIMITS[$slug] ?? null;
            if ($limits === null) {
                continue; // Unknown platform — skip (e.g. mock)
            }

            $errors = array_merge($errors, $this->checkTextLimits($slug, $creative, $limits));

            if ($creative->type === AdCreative::TYPE_VIDEO) {
                $errors = array_merge($errors, $this->checkVideo($slug, $creative, $limits));
            }

            if ($campaign && $creative->call_to_action) {
                $ctaWarning = $this->checkCta($slug, $creative, $campaign);
                if ($ctaWarning !== null) {
                    $warnings[] = $ctaWarning;
                }
            }
        }

        return ['errors' => $errors, 'warnings' => $warnings];
    }

    /**
     * @param array<string, int> $limits
     * @return array<int, array{platform: string, field: string, message: string}>
     */
    private function checkTextLimits(string $slug, AdCreative $creative, array $limits): array
    {
        $errors = [];
        $fields = [
            'headline' => ['value' => (string) $creative->headline, 'max' => $limits['headline_max']],
            'primary_text' => ['value' => (string) $creative->primary_text, 'max' => $limits['body_max']],
            'description' => ['value' => (string) $creative->description, 'max' => $limits['description_max']],
        ];

        foreach ($fields as $name => $spec) {
            $len = mb_strlen($spec['value']);
            if ($len > $spec['max']) {
                $errors[] = [
                    'platform' => $slug,
                    'field' => $name,
                    'message' => sprintf(
                        '%s is %d characters (max %d on %s).',
                        ucwords(str_replace('_', ' ', $name)),
                        $len,
                        $spec['max'],
                        ucfirst($slug),
                    ),
                ];
            }
        }

        return $errors;
    }

    /**
     * @param array<string, int> $limits
     * @return array<int, array{platform: string, field: string, message: string}>
     */
    private function checkVideo(string $slug, AdCreative $creative, array $limits): array
    {
        $errors = [];
        $duration = (int) ($creative->metadata['video_duration_seconds'] ?? 0);

        if ($duration === 0) {
            // Unknown duration — surface as warning via error list entry with a hint
            // so the wizard can show "Video duration unknown — ensure ≤ Nsec" guidance.
            // We classify as an error only if we have a duration to compare.
            return $errors;
        }

        if ($duration < $limits['video_min_seconds']) {
            $errors[] = [
                'platform' => $slug,
                'field' => 'video',
                'message' => sprintf(
                    'Video is %d seconds (minimum %d on %s).',
                    $duration,
                    $limits['video_min_seconds'],
                    ucfirst($slug),
                ),
            ];
        }

        if ($duration > $limits['video_max_seconds']) {
            $errors[] = [
                'platform' => $slug,
                'field' => 'video',
                'message' => sprintf(
                    'Video is %d seconds (maximum %d on %s).',
                    $duration,
                    $limits['video_max_seconds'],
                    ucfirst($slug),
                ),
            ];
        }

        return $errors;
    }

    /**
     * @return array{platform: string, field: string, message: string}|null
     */
    private function checkCta(string $slug, AdCreative $creative, AdCampaign $campaign): ?array
    {
        $allowed = self::CTA_FOR_OBJECTIVE[$campaign->objective] ?? null;
        if ($allowed === null) {
            return null;
        }
        if (!in_array($creative->call_to_action, $allowed, true)) {
            return [
                'platform' => $slug,
                'field' => 'call_to_action',
                'message' => sprintf(
                    'CTA "%s" is unusual for "%s" campaigns on %s (may reduce performance).',
                    $creative->call_to_action,
                    $campaign->objective,
                    ucfirst($slug),
                ),
            ];
        }
        return null;
    }
}