* feat: capturar origem de marketing do briefing de contato (MAN-105) Adiciona tracking simples da origem (UTM ou referrer, primeiro-toque vence) para satisfazer WEB-05: a origem viaja apenas com o e-mail interno do briefing, sem Lead model, tabela ou superfície de CRM (Fase 2 fora de escopo). Middleware no grupo "web" cobre só as rotas públicas (o painel Filament tem sua própria stack de middleware); lógica de captura/sanitização isolada em App\Domain\Contact para não poluir controller nem view. Fail-open por construção: qualquer falha ao capturar ou ler a origem é logada sem dados pessoais e a submissão segue normalmente. Respeita LGPD (§12.5): nada além da origem é guardado, valores são truncados para não inflar a sessão, e um referrer apontando para o próprio host é descartado por não ser sinal de marketing. Valores não confiáveis (query string, referrer) são sanitizados antes de entrar na sessão e no e-mail em HTML. Co-Authored-By: Claude noreply@anthropic.com AI-Assisted: yes AI-Tool: claude-code * fix(contato): capturar apenas a origem do referrer, não a URL completa O fallback de referrer em MarketingOrigin::capture() armazenava a URL inteira (incluindo query string e fragmento), não apenas o site de origem. Um link de campanha de e-mail personalizado (ex.: ?email=...&subscriber_id=...) chegava intacto à sessão (persistida em banco via SESSION_DRIVER=database) e ao corpo do e-mail de briefing lido por um humano — indo além do que SPEC.md WEB-05 pede ("origem de marketing") e do §12.5 ("coletar apenas dados necessários"). Agora o referrer é reduzido a esquema+host antes de sanitizar, descartando path, query string e userinfo. Co-Authored-By: Claude noreply@anthropic.com AI-Assisted: yes AI-Tool: claude-code * test(contato): cobrir first-touch quando a navegação seguinte traz UTM concorrente test_first_touch_wins_when_later_navigation_has_no_utm não exercitava de fato a guarda "primeira captura vence" em CaptureMarketingOrigin — as duas navegações seguintes não carregavam nenhum sinal (nem UTM, nem referrer), então MarketingOrigin::capture() já retornava [] e o early-return por ausência de sinal (não a guarda de sessão) é quem impedia a sobrescrita. Confirmado removendo a guarda: a suíte continuava passando. Este teste novo usa uma segunda navegação com UTM diferente (utm_source=facebook), o que só passa se a guarda de sessão estiver ativa — mutação verificada manualmente antes de escrever a asserção. Co-Authored-By: Claude noreply@anthropic.com AI-Assisted: yes AI-Tool: claude-code * fix(contato): não capturar origem em sitemap.xml e robots.txt O middleware estava anexado ao grupo `web` inteiro, que também cobre `/sitemap.xml` e `/robots.txt`. Um crawler chegando em `/sitemap.xml?utm_source=...` consumia o slot de primeiro toque com tráfego que nunca vai enviar um briefing, e ainda iniciava sessão em uma rota técnica. As duas rotas passam a ser ignoradas por nome, com teste cobrindo que elas não gravam nada na sessão e que uma visita humana seguinte ainda é capturada normalmente. Co-Authored-By: Claude noreply@anthropic.com AI-Assisted: yes AI-Tool: claude-code --------- Co-authored-by: manoel.neto <manoel.neto@creditas.com>
182 lines
6.1 KiB
PHP
182 lines
6.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Domain\Contact;
|
|
|
|
/**
|
|
* Captures the marketing origin of a visit (SPEC.md WEB-05, "origem de
|
|
* marketing capturada quando disponível") from raw, untrusted request
|
|
* data and turns it into a short, human-readable pt-BR label for the
|
|
* internal briefing e-mail.
|
|
*
|
|
* Deliberately framework-free (no Illuminate\Http\Request dependency) so
|
|
* it stays a pure transformation, mirroring BrazilianPhoneNumber: callers
|
|
* in the HTTP layer extract the query string / referrer and hand them in
|
|
* as primitives.
|
|
*/
|
|
final class MarketingOrigin
|
|
{
|
|
/**
|
|
* Session key both the capturing middleware and the controller read
|
|
* from — kept here so there is exactly one name for the concept.
|
|
*/
|
|
public const string SESSION_KEY = 'marketing_origin';
|
|
|
|
/**
|
|
* Caps every captured value. This is marketing metadata, not user
|
|
* content — a crafted query string must not be able to bloat the
|
|
* session (SPEC.md §12.5 LGPD).
|
|
*/
|
|
private const int MAX_LENGTH = 100;
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const array UTM_KEYS = [
|
|
'utm_source',
|
|
'utm_medium',
|
|
'utm_campaign',
|
|
'utm_term',
|
|
'utm_content',
|
|
];
|
|
|
|
/**
|
|
* pt-BR labels for the UTM keys, in the order they should be
|
|
* displayed when composing the briefing e-mail row.
|
|
*
|
|
* @var array<string, string>
|
|
*/
|
|
private const array LABELS = [
|
|
'utm_source' => 'origem',
|
|
'utm_medium' => 'mídia',
|
|
'utm_campaign' => 'campanha',
|
|
'utm_term' => 'termo',
|
|
'utm_content' => 'conteúdo',
|
|
];
|
|
|
|
/**
|
|
* Reads UTM parameters from the query string, falling back to the
|
|
* HTTP referrer when none are present. Returns an empty array when
|
|
* neither is available — capturing nothing is a valid outcome
|
|
* ("capturada quando disponível").
|
|
*
|
|
* A referrer pointing back at this same host is not a marketing
|
|
* origin — it is the visitor clicking from one internal page to
|
|
* another (common once the original session has expired and a
|
|
* fresh one starts mid-visit) — so it is discarded rather than
|
|
* stored as noise (SPEC.md §12.5, "coletar apenas dados
|
|
* necessários").
|
|
*
|
|
* @param array<string, mixed> $queryParams
|
|
* @return array<string, string>
|
|
*/
|
|
public static function capture(array $queryParams, ?string $referrer, ?string $requestHost): array
|
|
{
|
|
$utm = [];
|
|
|
|
foreach (self::UTM_KEYS as $key) {
|
|
$value = $queryParams[$key] ?? null;
|
|
|
|
if (is_string($value) && trim($value) !== '') {
|
|
$utm[$key] = self::sanitize($value);
|
|
}
|
|
}
|
|
|
|
if ($utm !== []) {
|
|
return $utm;
|
|
}
|
|
|
|
if (is_string($referrer) && trim($referrer) !== '' && ! self::isSameHost($referrer, $requestHost)) {
|
|
$origin = self::originOf($referrer);
|
|
|
|
if ($origin !== null) {
|
|
return ['referrer' => self::sanitize($origin)];
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Reduces a referrer URL to its scheme+host identity, dropping the
|
|
* path, query string, fragment, and any userinfo. The referrer is
|
|
* only ever used as a marketing-origin *site* label (SPEC.md WEB-05)
|
|
* — the query string can carry data that identifies an individual
|
|
* (e.g. a personalized campaign link's `?email=...`), which would
|
|
* exceed "coletar apenas dados necessários" (SPEC.md §12.5) once it
|
|
* lands in the session and the internal briefing e-mail.
|
|
*/
|
|
private static function originOf(string $referrer): ?string
|
|
{
|
|
$host = parse_url($referrer, PHP_URL_HOST);
|
|
|
|
if (! is_string($host) || $host === '') {
|
|
return null;
|
|
}
|
|
|
|
$scheme = parse_url($referrer, PHP_URL_SCHEME);
|
|
$prefix = is_string($scheme) && $scheme !== '' ? "{$scheme}://" : '';
|
|
|
|
return "{$prefix}{$host}";
|
|
}
|
|
|
|
private static function isSameHost(string $referrer, ?string $requestHost): bool
|
|
{
|
|
if ($requestHost === null || $requestHost === '') {
|
|
return false;
|
|
}
|
|
|
|
$referrerHost = parse_url($referrer, PHP_URL_HOST);
|
|
|
|
return is_string($referrerHost) && strcasecmp($referrerHost, $requestHost) === 0;
|
|
}
|
|
|
|
/**
|
|
* Composes the single-row, pt-BR display value for the internal
|
|
* briefing e-mail. Returns null when nothing was captured, letting
|
|
* the caller fall back to the same "—" convention already used for
|
|
* other optional briefing fields.
|
|
*
|
|
* Accepts loosely-typed input on purpose: this reads back whatever
|
|
* was put in the session, so it is treated as untrusted rather than
|
|
* assumed to still match the shape `capture()` produced.
|
|
*
|
|
* @param array<string, mixed> $origin
|
|
*/
|
|
public static function describe(array $origin): ?string
|
|
{
|
|
if (isset($origin['referrer']) && is_string($origin['referrer']) && $origin['referrer'] !== '') {
|
|
return $origin['referrer'];
|
|
}
|
|
|
|
$parts = [];
|
|
|
|
foreach (self::LABELS as $key => $label) {
|
|
if (isset($origin[$key]) && is_string($origin[$key]) && $origin[$key] !== '') {
|
|
$parts[] = "{$label}: {$origin[$key]}";
|
|
}
|
|
}
|
|
|
|
return $parts === [] ? null : implode(' | ', $parts);
|
|
}
|
|
|
|
/**
|
|
* Untrusted input (query string, HTTP referrer) that ends up in an
|
|
* HTML e-mail: strip any markup, drop control characters (also
|
|
* closes off header-injection-style newline tricks), trim, and cap
|
|
* the length before it ever reaches storage.
|
|
*/
|
|
private static function sanitize(string $value): string
|
|
{
|
|
$withoutTags = strip_tags($value);
|
|
// No /u flag: this is a byte-wise scrub of ASCII control bytes, so
|
|
// it cannot land mid-sequence in valid UTF-8 (continuation bytes
|
|
// are all >= 0x80) — unlike the Unicode-mode regex, it never
|
|
// blanks the whole string just because one byte is malformed.
|
|
$withoutControlChars = preg_replace('/[\x00-\x1F\x7F]/', '', $withoutTags) ?? '';
|
|
|
|
return mb_substr(trim($withoutControlChars), 0, self::MAX_LENGTH);
|
|
}
|
|
}
|