* 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>
86 lines
2.6 KiB
PHP
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);
|
|
}
|
|
}
|