* 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>
141 lines
4.6 KiB
PHP
141 lines
4.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\PublicSite;
|
|
|
|
use App\Domain\Contact\MarketingOrigin;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Requests\PublicSite\ContactBriefingRequest;
|
|
use App\Mail\ContactBriefing;
|
|
use App\Mail\ContactBriefingConfirmation;
|
|
use App\Models\SiteSetting;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Throwable;
|
|
|
|
final class ContactController extends Controller
|
|
{
|
|
private const string DUPLICATE_SESSION_KEY = 'contact_briefing_hash';
|
|
|
|
public function store(ContactBriefingRequest $request): RedirectResponse
|
|
{
|
|
if (filled($request->input('empresa'))) {
|
|
return $this->success();
|
|
}
|
|
|
|
$validated = $request->validated();
|
|
unset($validated['privacidade']);
|
|
|
|
if ($this->isDuplicate($validated)) {
|
|
return $this->success();
|
|
}
|
|
|
|
$this->rememberSubmission($validated);
|
|
|
|
$settings = SiteSetting::instance();
|
|
$fields = $this->buildFields($validated, $this->resolveMarketingOrigin($request));
|
|
|
|
$this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields);
|
|
|
|
return $this->success();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $validated
|
|
*/
|
|
private function buildFields(array $validated, ?string $marketingOrigin): array
|
|
{
|
|
return [
|
|
'Nome' => (string) $validated['nome'],
|
|
'E-mail' => (string) $validated['email'],
|
|
'Telefone/WhatsApp' => (string) $validated['telefone'],
|
|
'Tipo de evento' => (string) $validated['tipo_evento'],
|
|
'Data ou período desejado' => isset($validated['data_periodo']) ? (string) $validated['data_periodo'] : null,
|
|
'Cidade' => (string) $validated['cidade'],
|
|
'Número estimado de convidados' => isset($validated['convidados']) ? (string) $validated['convidados'] : null,
|
|
'Serviço de interesse' => isset($validated['servico_interesse']) ? (string) $validated['servico_interesse'] : null,
|
|
'Mensagem' => (string) $validated['mensagem'],
|
|
'Origem de marketing' => $marketingOrigin,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Reads the origin captured on arrival (SPEC.md WEB-05) so it can
|
|
* ride along with the internal briefing e-mail only — never the
|
|
* confirmation sent to the visitor. Fail-open: any problem reading
|
|
* or interpreting the session value must never block the
|
|
* submission, so it degrades to "not captured" instead.
|
|
*/
|
|
private function resolveMarketingOrigin(ContactBriefingRequest $request): ?string
|
|
{
|
|
try {
|
|
$origin = $request->session()->get(MarketingOrigin::SESSION_KEY, []);
|
|
|
|
return is_array($origin) ? MarketingOrigin::describe($origin) : null;
|
|
} catch (Throwable $exception) {
|
|
Log::warning('Falha ao ler origem de marketing armazenada (ignorada; fail-open)', [
|
|
'exception' => $exception::class,
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $validated
|
|
*/
|
|
private function isDuplicate(array $validated): bool
|
|
{
|
|
$hash = $this->hash($validated);
|
|
|
|
return session()->get(self::DUPLICATE_SESSION_KEY) === $hash;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $validated
|
|
*/
|
|
private function rememberSubmission(array $validated): void
|
|
{
|
|
session()->put(self::DUPLICATE_SESSION_KEY, $this->hash($validated));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $validated
|
|
*/
|
|
private function hash(array $validated): string
|
|
{
|
|
return hash('sha256', serialize($validated));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $fields
|
|
*/
|
|
private function dispatchEmails(SiteSetting $settings, string $name, string $email, array $fields): void
|
|
{
|
|
$attempt = function () use ($settings, $name, $email, $fields): void {
|
|
if (filled($settings->email)) {
|
|
Mail::to($settings->email)->send(new ContactBriefing($fields));
|
|
}
|
|
|
|
if (filled(config('mail.default'))) {
|
|
Mail::to($email)->send(new ContactBriefingConfirmation($name, (string) $settings->brand_name));
|
|
}
|
|
};
|
|
|
|
try {
|
|
$attempt();
|
|
} catch (Throwable $exception) {
|
|
Log::error('Falha ao enviar briefing de contato', [
|
|
'exception' => $exception,
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function success(): RedirectResponse
|
|
{
|
|
return redirect()->route('contact')->with('status', 'briefing-sent');
|
|
}
|
|
}
|