$lead Row from the leads table * @param array $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 $lead * @param array $campus * @return array */ 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 $lead * @param array $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 $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'; } }