Files

173 lines
8.0 KiB
PHP
Raw Permalink Normal View History

<?php
declare(strict_types=1);
/**
* Mailchimp Marketing API v3 adapter (server-only).
*
* Required config: MAILCHIMP_API_KEY, MAILCHIMP_SERVER_PREFIX (e.g. us21), MAILCHIMP_AUDIENCE_ID
* Optional: MAILCHIMP_JOURNEY_ID + MAILCHIMP_JOURNEY_STEP_ID (Customer Journey API trigger),
* MAILCHIMP_TAG_PREFIX (default "Visit:" → tags like "Visit:Norwood", matching the
* church's existing convention), MAILCHIMP_DOUBLE_OPTIN (default false)
*
* Idempotency: contacts are keyed by the MD5 of the lower-cased email (Mailchimp's
* subscriber hash) and upserted with PUT, so retries never create duplicates.
*/
final class Mailchimp
{
/**
* @param array<string,mixed> $lead Row from the leads table
* @param array<string,mixed> $campus
* @return array{status:string, detail:?string, transient:bool}
*/
public static function upsertVisitor(array $lead, array $campus): array
{
if (!(int)$lead['email_consent']) {
return ['status' => 'skipped', 'detail' => 'no email consent', 'transient' => false];
}
$payload = self::buildMemberPayload($lead, $campus);
$tags = self::buildTags($lead, $campus);
if (Config::isMock()) {
Log::info('[mock] Mailchimp upsert', ['tags' => $tags, 'merge_fields' => array_keys($payload['merge_fields'])]);
return ['status' => 'mock', 'detail' => 'mock mode: ' . implode(',', $tags), 'transient' => false];
}
$apiKey = Config::require('MAILCHIMP_API_KEY');
$dc = Config::get('MAILCHIMP_SERVER_PREFIX') ?: (str_contains($apiKey, '-') ? substr($apiKey, strrpos($apiKey, '-') + 1) : null);
if (!$dc) {
throw new RuntimeException('MAILCHIMP_SERVER_PREFIX missing');
}
$list = Config::require('MAILCHIMP_AUDIENCE_ID');
$hash = md5(strtolower($lead['email']));
$base = Config::get('MAILCHIMP_BASE_URL_OVERRIDE') ?: "https://$dc.api.mailchimp.com/3.0"; // override is for the test-suite only
$auth = ['Authorization' => 'Basic ' . base64_encode("anystring:$apiKey")];
// 1) Upsert the member (idempotent)
$r = Http::json('PUT', "$base/lists/$list/members/$hash?skip_merge_validation=true", $payload, $auth, 10);
if ($r['status'] < 200 || $r['status'] >= 300) {
$detail = is_array($r['json']) ? ($r['json']['title'] ?? '') . ': ' . ($r['json']['detail'] ?? '') : ($r['error'] ?? "HTTP {$r['status']}");
$transient = Http::isTransient($r['status'], $r['error']);
Log::warn('Mailchimp upsert failed', ['status' => $r['status'], 'detail' => $detail]);
return ['status' => 'failed', 'detail' => substr($detail, 0, 500), 'transient' => $transient];
}
// 2) Apply tags (separate endpoint; PUT on members accepts tags only on create)
$tr = Http::json('POST', "$base/lists/$list/members/$hash/tags", [
'tags' => array_map(static fn($t) => ['name' => $t, 'status' => 'active'], $tags),
], $auth, 10);
if ($tr['status'] < 200 || $tr['status'] >= 300) {
Log::warn('Mailchimp tag update failed (member saved)', ['status' => $tr['status']]);
}
// 3) Optionally trigger a Customer Journey step (API-triggered journeys)
$journey = Config::get('MAILCHIMP_JOURNEY_ID');
$step = Config::get('MAILCHIMP_JOURNEY_STEP_ID');
$journeyNote = '';
if ($journey && $step) {
$jr = Http::json('POST', "$base/customer-journeys/journeys/$journey/steps/$step/actions/trigger", ['email_address' => $lead['email']], $auth, 10);
$journeyNote = ($jr['status'] >= 200 && $jr['status'] < 300) ? '; journey triggered' : "; journey trigger HTTP {$jr['status']}";
if ($jr['status'] < 200 || $jr['status'] >= 300) {
Log::warn('Mailchimp journey trigger failed', ['status' => $jr['status'], 'body' => substr($jr['body'], 0, 300)]);
}
}
$status = is_array($r['json']) ? ($r['json']['status'] ?? 'unknown') : 'unknown';
return ['status' => 'sent', 'detail' => "member status: $status" . $journeyNote, 'transient' => false];
}
/**
* @param array<string,mixed> $lead
* @param array<string,mixed> $campus
* @return array<string,mixed>
*/
public static function buildMemberPayload(array $lead, array $campus): array
{
$attr = is_string($lead['attribution'] ?? null) ? (json_decode($lead['attribution'], true) ?: []) : ($lead['attribution'] ?? []);
$doubleOptIn = Config::bool('MAILCHIMP_DOUBLE_OPTIN', false);
$merge = [
'FNAME' => $lead['first_name'],
'LNAME' => $lead['last_name'] ?? '',
'CAMPUS' => $campus['name'],
'VISITDATE' => $lead['visit_date'] ?? '',
'SERVICE' => $lead['service_time'] ?? '',
'ADULTS' => $lead['adults'] ?? '',
'KIDS' => $lead['children'] ?? '',
'KIDAGES' => $lead['child_age_ranges'] ?? '',
'QUESTIONS' => mb_substr((string)($lead['questions'] ?? ''), 0, 255),
'UTMSOURCE' => $attr['utm_source'] ?? '',
'UTMCAMP' => $attr['utm_campaign'] ?? '',
'LPVARIANT' => $attr['variant'] ?? '',
'SUBID' => $lead['submission_id'],
];
if (!empty($lead['phone_e164'])) {
$merge['PHONE'] = $lead['phone_e164'];
}
// Remove empties so we never overwrite existing values with blanks.
$merge = array_filter($merge, static fn($v) => $v !== '' && $v !== null);
return [
'email_address' => $lead['email'],
'status_if_new' => $doubleOptIn ? 'pending' : 'subscribed',
'email_type' => 'html',
'merge_fields' => $merge,
'marketing_permissions' => [],
];
}
/**
* @param array<string,mixed> $lead
* @param array<string,mixed> $campus
* @return string[]
*/
public static function buildTags(array $lead, array $campus): array
{
$prefix = Config::get('MAILCHIMP_TAG_PREFIX', 'Visit:') ?? 'Visit:';
$attr = is_string($lead['attribution'] ?? null) ? (json_decode($lead['attribution'], true) ?: []) : ($lead['attribution'] ?? []);
// Campus tag first: "Visit:Norwood" is what the church's Mailchimp journeys trigger on.
$tags = [
$prefix . $campus['name'],
$prefix . 'First-Time Visitor',
$prefix . 'Source visit.waterschurch.org',
];
$platform = self::platform($attr);
$tags[] = $prefix . 'Platform ' . $platform;
if (!empty($attr['utm_campaign'])) {
$tags[] = $prefix . 'Campaign ' . mb_substr($attr['utm_campaign'], 0, 60);
}
if (!empty($lead['visit_date'])) {
$tags[] = $prefix . 'Visit ' . $lead['visit_date'];
} elseif (!empty($lead['service_time'])) {
$tags[] = $prefix . 'Service ' . mb_substr($lead['service_time'], 0, 40);
}
if ((int)($lead['children'] ?? 0) > 0) {
$tags[] = $prefix . 'Has Kids';
}
if ((int)$lead['sms_consent']) {
$tags[] = $prefix . 'SMS Opt-In';
}
return array_values(array_unique(array_map(static fn($t) => mb_substr($t, 0, 100), $tags)));
}
/** @param array<string,mixed> $attr */
public static function platform(array $attr): string
{
$src = strtolower((string)($attr['utm_source'] ?? ''));
if (!empty($attr['gclid']) || !empty($attr['gbraid']) || !empty($attr['wbraid']) || preg_match('/google|adwords/', $src)) {
return 'Google';
}
if (preg_match('/instagram|^ig$/', $src)) {
return 'Instagram';
}
if (!empty($attr['fbclid']) || preg_match('/facebook|^fb$|meta/', $src)) {
return 'Facebook';
}
if (!empty($attr['ttclid']) || str_contains($src, 'tiktok')) {
return 'TikTok';
}
if (!empty($attr['twclid']) || preg_match('/twitter|^x$|x\.com/', $src)) {
return 'X';
}
return $src !== '' ? ucfirst($src) : 'Direct';
}
}