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();
}
}