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/Jobs/Ads/SyncAdAudience.php
<?php

namespace App\Jobs\Ads;

use App\Dto\Ads\SyncAudienceDto;
use App\Model\AdAudience;
use App\Model\AdPlatform;
use App\Model\MailList;
use App\Services\Ads\AdActivityLogger;
use App\Services\Ads\AdPlatformManager;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * Push an AdAudience to its connected platform.
 *
 * Pipeline:
 *   1. Create the remote custom-audience row (if none) — adapter returns
 *      platform_audience_id which we persist.
 *   2. Resolve the audience source (MailList so far; Segment + engagement
 *      sources arrive with R14).
 *   3. Hash each email `sha256(strtolower(trim(email)))` — platform dedupe.
 *   4. Chunk into batches of CHUNK_SIZE and push each chunk through the
 *      decorator chain with a deterministic idempotency key so retries
 *      don't re-upload the same chunk twice.
 *
 * Runs on the `ads` queue (opt-in) — inherits from AbstractAdsJob which
 * defaults to `ads.queues.default` and respects the 5-retry backoff.
 *
 * Feature-flag gating: the underlying adapter call goes through
 * `$manager->session($platform)->chain->syncAudience()`.  If
 * `ads.hardening.use_session_pattern=false`, the decorator chain still
 * resolves — Mock adapter simply returns a stub success.  No runtime check
 * needed inside this job.
 */
class SyncAdAudience extends AbstractAdsJob
{
    /** Meta's per-batch cap.  Stay below so single batches never 400 on size. */
    public const CHUNK_SIZE = 10000;

    public function __construct(public int $audienceId)
    {
        parent::__construct();
    }

    public function handle(AdPlatformManager $manager): void
    {
        $audience = AdAudience::find($this->audienceId);
        if (!$audience) {
            return;
        }

        $this->customerId = $audience->customer_id;
        $this->platform = $audience->platform;

        $platform = $this->resolvePlatform($audience);
        if (!$platform) {
            $this->markErrored($audience, 'No connected ' . $audience->platform . ' platform for this customer.');
            return;
        }

        $session = $manager->session($platform);
        $account = $platform->defaultAccount();
        $adAccountId = $account?->remote_account_id ?? '';

        try {
            // Step 1 — ensure a remote audience exists
            if (!$audience->platform_audience_id) {
                $created = $session->chain->syncAudience(
                    accessToken: (string) $platform->access_token,
                    adAccountId: $adAccountId,
                    data: new SyncAudienceDto(
                        platformAudienceId: null,
                        name: (string) $audience->name,
                        description: 'Synced from Acelle — ' . $audience->name,
                        hashedEmails: [],
                        operation: SyncAudienceDto::OPERATION_ADD,
                    ),
                    idempotencyKey: 'create:' . $audience->uid,
                );
                if ($created->platformAudienceId) {
                    $audience->platform_audience_id = $created->platformAudienceId;
                    $audience->save();
                }
            }

            // Step 2 — resolve + hash emails
            $emails = $this->resolveEmails($audience);
            $hashed = $this->hashEmails($emails);
            $totalChunks = max(1, (int) ceil(count($hashed) / self::CHUNK_SIZE));

            // Step 3 — push chunks through the decorator chain
            foreach (array_chunk($hashed, self::CHUNK_SIZE) as $idx => $chunk) {
                $session->chain->syncAudience(
                    accessToken: (string) $platform->access_token,
                    adAccountId: $adAccountId,
                    data: new SyncAudienceDto(
                        platformAudienceId: $audience->platform_audience_id,
                        name: (string) $audience->name,
                        description: null,
                        hashedEmails: $chunk,
                        operation: SyncAudienceDto::OPERATION_ADD,
                    ),
                    idempotencyKey: sprintf('add:%s:%d', $audience->uid, $idx),
                );
            }

            // Step 4 — mark synced
            $audience->status = AdAudience::STATUS_SYNCED;
            $audience->sync_status = AdAudience::SYNC_COMPLETED;
            $audience->size_estimate = count($emails);
            $audience->last_synced_at = now();
            $audience->sync_error = null;
            $audience->save();

            AdActivityLogger::log(
                entity: $audience,
                action: 'audience.synced',
                status: 'success',
                title: 'Audience synced to ' . $platform->platform_label,
                description: sprintf('%d emails across %d chunks', count($emails), $totalChunks),
                platform: $audience->platform,
            );
        } catch (Throwable $e) {
            // Failed job handler will also run, but mark audience here so the
            // UI reflects the error even before Horizon surfaces the failure.
            $this->markErrored($audience, $e->getMessage());
            throw $e;
        }
    }

    public function failed(Throwable $exception): void
    {
        $this->logFailure($exception);
        $audience = AdAudience::find($this->audienceId);
        if ($audience) {
            $this->markErrored($audience, $exception->getMessage());
        }
    }

    protected function resolvePlatform(AdAudience $audience): ?AdPlatform
    {
        if (!$audience->platform) {
            return null;
        }
        return AdPlatform::query()
            ->where('customer_id', $audience->customer_id)
            ->where('platform', $audience->platform)
            ->where('status', AdPlatform::STATUS_ACTIVE)
            ->first();
    }

    /**
     * @return array<int,string>
     */
    protected function resolveEmails(AdAudience $audience): array
    {
        return match ($audience->source_type) {
            AdAudience::SOURCE_MAIL_LIST => $this->emailsFromMailList((int) $audience->source_id),
            // Segment + engagement sources arrive with R14.  Until then, an
            // unrecognized source logs a warning and produces an empty list —
            // better than hard-failing the whole sync.
            default => $this->emptyWithLog($audience),
        };
    }

    /**
     * @return array<int,string>
     */
    protected function emailsFromMailList(int $listId): array
    {
        $list = MailList::find($listId);
        if (!$list) {
            return [];
        }
        return $list->subscribers()
            ->where('status', \App\Model\Subscriber::STATUS_SUBSCRIBED)
            ->pluck('email')
            ->filter()
            ->values()
            ->all();
    }

    /**
     * @return array<int,string>
     */
    protected function emptyWithLog(AdAudience $audience): array
    {
        Log::channel(config('ads.observability.log_channel', 'stack'))->warning('ads.audience.unsupported_source', [
            'audience_uid' => $audience->uid,
            'source_type' => $audience->source_type,
        ]);
        return [];
    }

    /**
     * SHA-256 lowercase trimmed — matches Meta + Google + TikTok + LinkedIn
     * hashing specs.  Platform dedupes identical hashes across audiences.
     *
     * @param array<int,string> $emails
     * @return array<int,string>
     */
    public static function hashEmails(array $emails): array
    {
        $out = [];
        foreach ($emails as $email) {
            $normalized = strtolower(trim((string) $email));
            if ($normalized === '') {
                continue;
            }
            $out[] = hash('sha256', $normalized);
        }
        return $out;
    }

    protected function markErrored(AdAudience $audience, string $reason): void
    {
        $audience->status = AdAudience::STATUS_ERROR;
        $audience->sync_status = AdAudience::SYNC_FAILED;
        $audience->sync_error = mb_substr($reason, 0, 500);
        $audience->save();
    }
}