$values Canonical keys, empties already dropped. */ private function __construct( public readonly array $values, public readonly bool $isTest, /** @var array Provider field id => accepted text or selected interest. */ public readonly array $mapped = [], /** The accepted submission purpose; an email address alone is not marketing consent. */ public readonly string $purpose = 'request', /** * One delivery of one submission to one Destination: the same on every * retry, different for each submission and each route. A receiver that * cannot be upserted into dedupes on it (ADR 0148). */ public readonly string $deliveryId = '', /** * The submission as a whole, for a type that forwards it rather than * mapping it — {@see PushDetails}. Every other type ignores it. * * @var array */ public readonly array $details = [], ) { } /** * One captured [[Lead]], as the thing a Destination receives. * * `$lead` is the frozen snapshot of one submission. `$whole` is the Lead it * belongs to, which is what lets the details of a journey's second step * carry its first. */ public static function of(Lead $lead, string $destinationId = '', string $submissionId = 'primary', ?Lead $whole = null): self { $mapping = $lead->capture['field_mappings'][$destinationId] ?? []; $mapped = []; if (is_array($mapping)) { foreach ($mapping as $source => $target) { if (!is_string($target) || !preg_match('/^[A-Za-z][A-Za-z0-9_]{0,31}$/D', $target)) continue; $value = null; if (is_string($source) && str_starts_with($source, 'choice:')) { [, $question, $choice] = array_pad(explode(':', $source, 3), 3, ''); foreach ($lead->questionAnswers as $answer) { if (($answer['id'] ?? null) !== $question || ($answer['type'] ?? '') !== 'multi') continue; // Only selected choices add interests. Skipped or deselected choices never clear them. if (in_array($choice, $answer['values'] ?? [], true)) $value = true; break; } } elseif (is_string($source) && str_starts_with($source, 'field:')) { $key = substr($source, 6); if (in_array($key, ['interest', 'message'], true)) $value = $lead->fields[$key] ?? null; } else { foreach ($lead->questionAnswers as $answer) { if (($answer['id'] ?? null) !== $source) continue; $parts = ($answer['type'] ?? '') === 'text' ? ($answer['values'] ?? []) : ($answer['labels'] ?? []); $value = is_array($parts) ? implode('; ', array_filter($parts, 'is_string')) : null; break; } } if ($value === true || (is_string($value) && trim($value) !== '')) $mapped[$target] = $value; } } return new self(CanonicalFields::of($lead), false, $mapped, is_string($lead->capture['purpose'] ?? null) ? $lead->capture['purpose'] : 'request', 'msg_' . substr(hash('sha256', $lead->id . '|' . $submissionId . '|' . $destinationId), 0, 32), PushDetails::of($whole ?? $lead, $submissionId)); } /** * A merchant's own address, with no row behind it. * * The values are put through the same {@see CanonicalFields} filter a Lead * is, so a test cannot reach a Destination carrying a key the vocabulary * does not have — which is the one way a test send could otherwise exercise * a path a capture never takes. * * Its details are a full sample — every key filled — so a receiver that * learns its fields from the first payload it sees (Zapier, Make) sees all * of them. A campaign editor passes that campaign's own questions. * * @param array $values * @param array $mapped * @param array}>|null $answers */ public static function test(array $values, array $mapped = [], string $purpose = 'email_marketing', ?array $answers = null): self { $values = CanonicalFields::present($values); return new self($values, true, $mapped, $purpose, 'msg_' . bin2hex(random_bytes(16)), PushDetails::sample($values, $purpose, $answers)); } }