Files
amare/app/Http/Middleware/CaptureMarketingOrigin.php
Manoel Freitas 37296f758f feat: capturar origem de marketing do briefing de contato (MAN-105) (#37)
* 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>
2026-08-10 09:12:26 -03:00

86 lines
2.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Domain\Contact\MarketingOrigin;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* Captures the marketing origin (UTM parameters, or the HTTP referrer as
* a fallback) on the first public page load of a visit and stores it in
* the session, so it can later travel with a contact briefing submission
* (SPEC.md WEB-05).
*
* First *informative* touch wins: nothing is written until a page load
* actually carries a UTM parameter or an external referrer, and once
* that happens later page views never overwrite it — navigating to
* another page without UTM parameters must not erase what arrived with
* the visitor. A page load with no signal at all is left unrecorded so a
* later, informative page load in the same session can still be
* captured.
*
* Fail-open by construction: any failure here is caught and logged
* without request data, and the request proceeds untouched. This path
* must never be able to block a page load or, downstream, a conversion.
*/
final class CaptureMarketingOrigin
{
/**
* Technical routes that share the `web` middleware group but are never a
* human arriving at the site. A crawler fetching `/sitemap.xml?utm_source=…`
* would otherwise consume the first-touch slot with traffic that will never
* submit a briefing.
*
* @var list<string>
*/
private const IGNORED_ROUTES = ['sitemap', 'robots'];
public function handle(Request $request, Closure $next): Response
{
if (in_array($request->route()?->getName(), self::IGNORED_ROUTES, true)) {
return $next($request);
}
try {
$this->captureFirstTouch($request);
} catch (Throwable $exception) {
Log::warning('Falha ao capturar origem de marketing (ignorada; fail-open)', [
'exception' => $exception::class,
]);
}
return $next($request);
}
private function captureFirstTouch(Request $request): void
{
if (! $request->hasSession()) {
return;
}
$session = $request->session();
if ($session->has(MarketingOrigin::SESSION_KEY)) {
return;
}
$origin = MarketingOrigin::capture(
$request->query(),
$request->headers->get('referer'),
$request->getHost(),
);
if ($origin === []) {
return;
}
$session->put(MarketingOrigin::SESSION_KEY, $origin);
}
}