Collection contentEvent (hidden, sem hooks, sem PII) + beacon público POST /api/content-events fail-soft, download contado no servidor na rota de mídia, agregação em uma query group-by e a coluna "Circulação" na lista interna + painel na ficha (port classe-a-classe do artefato estendido, crítica final do designer certificada).
29 KiB
Impl: C213 — Analytics da Central de Conteúdos — uso, downloads e compartilhamentos
Status: aprovado (--auto) Atualizado em: 2026-09-23 Issue: #1258 Intenção: docs/plans/central-conteudos-analytics.md Design UI (gate): docs/plans/central-conteudos-analytics-ui-design.html Appetite restante: herdado (~1–1,5 dia eng). Corte explícito para caber: só acumulado desde a publicação (sem janela/retenção), uma query de agregação por página, nenhuma superfície nova além da coluna "Circulação" + bloco no detalhe. Fases: server/schema ~55%, UI ~30%, gates ~15%.
Leitura da intenção
- Outcome: a lista interna de peças do C211 (e o detalhe) mostra, por peça, quatro contadores absolutos — Aberturas · Downloads · WhatsApp · Link — anônimos e agregados; a assessoria decide o que reforçar/reeditar e a coordenação decide se a Central vale o esforço lendo a própria lista, sem ferramenta nova.
- O que NÃO negociar:
- Anonimato fail-closed: sem IP persistido, sem cookie/identidade, sem user-agent, sem rastreio entre páginas/peças; nenhum
Consentnovo (não há PII a consentir); nada de visitante único. - Contadores separados, lado a lado: sem total "usos", sem %, sem série, sem ranking, sem dashboard/rota de analytics; WhatsApp × link medem escolhas diferentes.
- Fail-soft absoluto: abrir, baixar e compartilhar nunca dependem da contagem; falha do contador é invisível ao visitante; contador a menos é aceitável.
- Nada de serviço novo: sem plugin beta, sem Umami/Plausible, sem serviço para operar/backup; a peça continua sendo a do C211 e a página a do S27.
- Cross-feature S32: um único mecanismo/collection/endpoint; o card entra depois como mais um assunto (
subjectType: 'card'), nunca como collection/rota paralela.
- Anonimato fail-closed: sem IP persistido, sem cookie/identidade, sem user-agent, sem rastreio entre páginas/peças; nenhum
- O que reavaliar (hipóteses da "Direção no codebase"):
- O precedente de rollup útil é a agregação SQL de
supporterListOverviewAggregate.ts(group-by em uma query), nãoCampaignVoteSummarySnapshot(snapshot diário, outro problema). - "Rota pública de escrita em
src/app/(frontend)/api/" não existia (só GETs públicos e POSTs autenticados): a rota POST nasce aqui. - A instrumentação "na página do S27" não cobre download: o clique no
<a download>é client, mas o download real só o servidor vê — a contagem de download é server-side na rota de mídia. - Contador não pode ser campo da peça: o
afterChangedoContentPiecebusta a tag públicacontentPiecesa cada write (src/collections/ContentPiece.ts:78-81) — cada evento anônimo invalidaria o cache da Central pública.
- O precedente de rollup útil é a agregação SQL de
Abordagem recomendada
flowchart LR
V["Visitante anônimo"] -->|"abre /conteudos/slug"| P["S27 · ContentPieceDetail (client)"]
V -->|"Baixar / A mídia (?download=1)"| M["GET /conteudos/slug/midia"]
V -->|"clica WhatsApp"| W["Sheet S27 (client)"]
V -->|"copia link (sucesso)"| W
P -->|"beacon abertura"| API["POST /api/content-events"]
W -->|"beacon compartilhar_whatsapp"| API
W -->|"beacon compartilhar_link"| API
M -->|"after()"| REC["recordContentEvent (Local API overrideAccess)"]
API -->|"same-origin → schema → limiter → peça publicada por slug"| REC
REC --> CE[("contentEvent (sem hook · sem bust da tag pública)")]
CE --> AGG["loadContentEventCountsBySubject — 1 query group-by"]
AGG --> L["Lista C211 · coluna Circulação (4 contadores / 2×2 mobile)"]
AGG --> D["Detalhe C211 · bloco Circulação (4 células + notas)"]
Opções consideradas (abordagem geral): A) coleção mínima de eventos anônimos no próprio Payload, escrita por rota pública fail-soft e agregada na lista do C211; B) plugin @10x-media/analytics (0.1.0-beta.0, adapter native(), superfície no /admin); C) serviço externo self-hosted (Umami/Plausible).
Recomendação: A — atribuição por peça garantida, superfície onde a assessoria já está, zero serviço para operar/backup e nenhum identificador de visitante; o S32 reusa o mesmo mecanismo.
Rejeitadas: B porque está em beta, vive no /admin (superfície errada para a comunicação) e traria migrations de pacote; C porque soma um serviço ao homeserver e ainda exigiria eventos customizados para "por peça" — reavaliar B/C só se a pergunta virar funil/pageview agregado (fora desta fatia).
D1 — Modelo do evento: collection contentEvent genérica
Opções: A) collection nova contentEvent com type + subjectType + subjectId (assunto genérico); B) relationship piece + type; C) tabela de rollup por contador (subject_id, type → count, upsert); D) campo contador na própria peça; E) payload.count por linha/tipo.
Recomendação: A — evento cru anônimo, assunto por (subjectType, subjectId): subjectType já nasce com peca | card e subjectId é texto (id interno da peça hoje; model id do card no S32). subjectId é o id interno (String(piece.id)), não o slug: id é a chave estável da lista e um slug reaproveitado após delete não herda contagem alheia. Sem campo variant agora — S32 adiciona coluna nullable por migration quando precisar (campo não usado é YAGNI); registrar a extensão aditiva prevista. createdAt (default do Payload) já grava a base da janela de 7 dias futura, sem migration. Índice: um composto (subjectType, subjectId, type) (compound indexes, suportado pelo Payload 3.82) — os prefixos à esquerda cobrem subjectType e subjectType+subjectId, e a terceira coluna torna a agregação index-only; evitar três índices individuais numa tabela write-heavy. Collection admin.hidden, sem hooks: nenhum write de evento busta a tag contentPieces.
Access: create: () => false, update: () => false, read: canReadContentPiece (src/utilities/access/contentPieces.ts:16), delete: payloadAdminOnly; escrita exclusivamente via Local API overrideAccess: true, documentada nos dois call sites (rota pública e rota de mídia).
Rejeitadas: B porque acopla o mecanismo à peça e o S32 exigiria relationship/caminho paralelo; C porque perde o evento cru (a janela de 7 dias viraria nova coleta, impossível retroativamente), exige upsert/atomic increment e transforma um tipo novo em migration de coluna/linha; D porque cada evento bustaria a tag pública contentPieces (cache da Central invalidado por visita anônima — inaceitável); E porque é N+1 (25 queries/página).
D2 — Escrita pública: POST /api/content-events
Opções: A) rota nova em src/app/(frontend)/api/content-events/route.ts com body { type, pieceSlug }, zod, same-origin e limiter in-memory; B) server action; C) gravar por slug direto (sem resolver a peça); D) aceitar subjectType/subjectId do cliente; E) sem limiter.
Recomendação: A — ordem do handler: isSameOriginRequest (403) → contentEventRequestSchema.safeParse (400) → limiter por IP hasheado (204 silencioso se estourar) → getPublishedContentPieceBySlug (204 se rascunho/desconhecida — nunca revela existência) → recordContentEvent (subjectType: 'peca', subjectId: String(piece.id)). Resposta 204 + Cache-Control: no-store; qualquer falha nunca quebra o cliente (o beacon ignora a resposta). Limiter: src/utilities/content/contentEventRateLimit.ts (server-only), Map por hash sha256(salt do processo + IP) — salt de processo em memória, nunca persistido; janela generosa (120 eventos/10 min por IP) para NAT; IP de cf-connecting-ip → 1º de x-forwarded-for → x-real-ip; sem header → fail-open (nunca um balde único para todo mundo). O mesmo limiter não é aplicado ao download (D3): a rota de mídia é caminho legítimo de alto volume e throttlar contagem ali subcontaria NAT; risco residual aceito (contagem inflada por spam de banda) com gatilho de revisita se abuso aparecer.
Rejeitadas: B porque beacon não usa action e a página pública é client (action acoplaria refresh de RSC a telemetria); C porque permitiria evento de slug inexistente/rascunho (lixo no banco) e não valida publicação; D porque qualquer um inflaria contador de peça arbitrária (inclusive rascunho) — o cliente manda o slug e o servidor resolve; E porque endpoint público de escrita sem throttle é superfície de spam trivial.
D3 — Download contado no servidor
Opções: A) contar na rota [slug]/midia quando download=1 e sem header Range, com after(); B) contar no clique do <a> (client); C) contar todo GET da mídia; D) contar também downloads retomados (com Range).
Recomendação: A — o servidor é o único que vê o download real; after() (precedente .../acervo/[id]/poster/route.ts:69) não atrasa a resposta; a contagem só ocorre quando buildPrivateMediaResponse devolve status === 200 (arquivo existiu), e cobre de graça o fallback "A mídia" do sheet (mesmo ?download=1). download=1 + Range (retomada) não conta — contador a menos aceitável.
Rejeitadas: B porque conta clique cancelado e duplicaria com o fallback "A mídia"; C porque contaria reprodução/preview (play e fetch do share nativo), não download; D porque contaria cada parte da retomada como um download.
D4 — Abertura e compartilhamentos no cliente (beacon)
Opções: A) beacon sendBeacon com fallback fetch keepalive, disparado por mount/clique/sucesso de copy; B) ping server-side de abertura; C) instrumentar dentro do CopyLinkButton compartilhado; D) sem guarda de StrictMode.
Recomendação: A — transporte único em src/lib/contentEvents.ts (client-safe, nunca lança; Blob application/json): abertura = mount do ContentPieceDetail com guarda useRef (dedupe do StrictMode em dev); WhatsApp = onClick no <a> do sheet (sem preventDefault); link = sucesso do copy. useCopyFeedback.copy passa a devolver Promise<boolean> (src/lib/copyFeedback.ts:66-73) e CopyLinkButton ganha onCopied?: () => void — ambos aditivos, callers atuais (cortes C166/C167/C168) intactos.
Rejeitadas: B porque a página pode ser servida do Full Route Cache (sem dynamic forçado não há hook por visita) e forçá-la dinâmica para ganhar contador degradaria a página; C porque o componente é compartilhado com os cortes — contaria compartilhamento de outra superfície; D porque inflaria a contagem em dev e a guarda é barata.
D5 — Agregação: uma query, estados explícitos
Opções: A) uma query SQL group-by por página (drizzle.execute + drizzleResultRows, precedente supporterListOverviewAggregate.ts:34-109) e mapeamento puro para a view; B) payload.count por linha; C) rollup persistido; D) payload.find dos eventos crus e contagem em JS.
Recomendação: A — src/utilities/content/contentEventAggregate.ts (server-only) expõe loadContentEventCountsBySubject(payload, { subjectType, subjectIds }): WHERE subject_type = $1 AND subject_id IN (...) GROUP BY subject_id, type, subjectType parametrizado (S32 reusa com 'card'), retorno discriminado { ok: true, countsBySubject } | { ok: false } (falha → unavailable, nunca derruba a lista). Uma chamada por página (25 ids) e uma por detalhe (1 id); rows.length === 0 pula a query. O mapeamento puro (src/lib/contentPieceCirculation.ts) resolve a precedência dos estados: unavailable → contadores > 0 → data → publishedAt ausente (nunca publicada) → draft → empty; assim peça despublicada depois de acumular eventos mostra os contadores (a copy "Sem dados — ainda não publicada" só vale para nunca publicada).
Rejeitadas: B porque N+1; C porque perde o evento cru (D1-C); D porque puxa milhares de linhas para contar em JS (memória/custo crescentes).
D6 — Superfície e extensão do artefato
Opções: A) port classe-a-classe do artefato aprovado com a coluna "Circulação" na CampaignTable, grid 2×2 nos cards mobile e bloco no detalhe acima do form, estendendo o artefato (trigger a/b) para os estados não cobertos; B) improvisar os estados faltantes no markup; C) tela/rota nova.
Recomendação: A — o artefato cobre lista com dados/tudo-zero/rascunho e detalhe com dados/indisponível; não cobre: (1) detalhe de rascunho nunca publicada, (2) peça despublicada com contadores históricos, (3) rotulagem acessível do "—" e das notas no desktop. O designer estende o artefato antes do markup (triggers a/b do §Design de execution-pipeline.md) e a crítica final (trigger c) é obrigatória antes do push, com Design tier: no PR. A estrutura exata do bloco do detalhe segue o artefato estendido: CampaignMetricStrip é molde, não contrato — a cena 04 do artefato (mobile) mostra linhas verticais label-esquerda/valor-direita, que o strip (células empilhadas) não produz; a extensão do designer decide a estrutura a portar. A coluna entra no picker como { id: 'circulation', label: 'Circulação' } não-mandatória e visível por default (não entra em DEFAULT_HIDDEN_COLUMN_IDS).
Rejeitadas: B porque o implementador não decide estrutura visual; C porque a intenção veda superfície nova e a lista já é a mesa de trabalho.
D7 — E2E: estender os specs existentes, sem família nova
Opções: A) estender frontendConteudos (fluxo público: beacon + linhas via REST admin) e campaignSpeechAcervo (contadores na lista/ficha); B) família nova contentEvents (project no Playwright + manifest + fixtures duplicadas); C) sem e2e (só unit/int).
Recomendação: A — as duas superfícies já têm família e fixtures (REST admin no público; login de campanha + campaign.fixtures.payload no interno); a família nova duplicaria setup sem cobertura nova. Nota honesta: o PR inclui migration → ci-scope classifica curated (conjunto congelado com frontendConteudos, sem campaignSpeechAcervo); o interno roda no full do verify do deploy e no e2e local discricionário (pnpm test:e2e --no-deps -- tests/e2e/campaignSpeechAcervo.e2e.spec.ts --workers=1).
Rejeitadas: B porque paga project/manifest/fixtures por cobertura que A já dá; C porque o POST público é contrato HTTP novo e o wiring do beacon só existe de ponta a ponta no browser.
Componentes / mudanças
Schema / dados
src/collections/ContentEvent.ts(novo): collectioncontentEvent,admin: { hidden: true, group: 'Comunicação' }; campostype(select required, enumabertura | download | compartilhar_whatsapp | compartilhar_link),subjectType(select required, enumpeca | card),subjectId(text required);indexes: [{ fields: ['subjectType', 'subjectId', 'type'] }]; access de D1; sem hooks (não bustacontentPieces); timestamps default (createdAt= base da janela futura).src/payload.config.ts: registrarContentEventno array de collections (ordem dos imports).- Migration:
pnpm migrate:create add_content_events(última do repo:20260923_054145_add_share_link_announcement) — revisar o SQL gerado (sócontent_event+ enum + índice, aditivo, sem tocar dados), commitar.ts/.json/index.tse rodarpnpm migrate+pnpm generate:typeslocal. src/lib/contentEvents.ts(novo, puro/client-safe):CONTENT_EVENT_TYPES/isContentEventType+sendContentPieceEvent(type, slug)(beacon → fallbackfetch keepalive; try/catch; nunca lança).src/lib/schemas/contentEvent.ts(novo):contentEventRequestSchema(typedo vocabulário;pieceSlugbounded) — importa o vocabulário decontentEvents.ts(uma fonte, sem drift).src/utilities/content/contentEventWrite.ts(novo, server-only):recordContentEvent(payload, { type, subjectType, subjectId })— Local APIoverrideAccess: true, valida o tipo, loga a falha sem PII e engole (fail-soft); único dono da escrita.src/utilities/content/contentEventRateLimit.ts(novo, server-only):clientIpFromHeaders, limiter in-memory (salt de processo + hash, cleanup por janela) de D2.src/app/(frontend)/api/content-events/route.ts(novo): POST de D2 (dynamic = 'force-dynamic', 403/400/204 +no-store).src/app/(frontend)/conteudos/[slug]/midia/route.ts(editar,:31-60): contar download comafter()quandodownload=1, semRangee resposta 200.
Leitura / agregação
src/utilities/content/contentEventAggregate.ts(novo, server-only): query group-by de D5; falha →{ ok: false }.src/lib/contentPieceCirculation.ts(novo, puro): ordem/rótulos das quatro métricas (lista: Aberturas · Downloads · WhatsApp · Link; detalhe: Link copiado), mapatype → metric,toContentPieceCirculationViewcom a precedência de D5, literais de estado (0 abertura · 0 download · 0 WhatsApp · 0 link,Sem dados — ainda não publicada) eContentPieceRowViewModel = ContentPieceViewModel & { circulation: ContentPieceCirculationView }.src/utilities/content/contentPiecePageData.ts(editar,:92-121e:141-176): list/detail anexamcirculation(uma query por página; detalhe 1 id;hasBeenPublishedderivado dopublishedAtcru do select — o VMsrc/lib/contentPiece.tsnão muda).
UI (só depois da extensão do artefato)
src/components/campaign/content/ContentPieceCirculationCounters.tsx(novo): apresentação dos quatro contadores (layout: 'list' | 'card'),tabular-nums, estados dados/tudo-zero/rascunho/indisponível.src/components/campaign/content/ContentPieceCirculationPanel.tsx(novo): bloco do detalhe (estrutura conforme o artefato estendido), nota "Leitura: compare os sinais sem somá-los…", nota de privacidade e cópia de indisponível.src/components/campaign/content/ContentPieceTable.tsx(editar,:52-121): colunacirculationentrepublicationeaction.src/components/campaign/content/ContentPieceCardList.tsx(editar): grid 2×2 no card mobile.src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx(editar,:55-60): entradacirculationno picker.src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/page.tsx(editar,:194-217): painel acima doContentPieceFormna coluna direita.
Instrumentação cliente (S27)
src/components/conteudos/ContentPieceDetail.tsx(editar): abertura no mount com guardauseRef.src/components/conteudos/ContentPieceShareSheet.tsx(editar,:155-176): WhatsApp noonClickdo<a>;CopyLinkButton onCopiedpara o link.src/components/CopyLinkButton.tsx(editar): prop aditivaonCopied?— não instrumenta por dentro (cortes não contam).src/lib/copyFeedback.ts(editar):copydevolvePromise<boolean>(callers seguem comvoid).
Testes / manifest / changelog
- Unit:
tests/unit/contentPieceCirculation.unit.spec.ts(mapeamento, estados, literais),tests/unit/contentEventRateLimit.unit.spec.ts(limite/janela/IP/headers),tests/unit/contentEvents.unit.spec.ts(transport beacon/fallback nunca lança) e pin de schema: campos deContentEventsem IP/UA/cookie e collection semhooks. - Int:
tests/int/contentEvents.int.spec.ts(novo; importa os handlers de rota comocontentPiece.int.spec.ts:28,36;aftermockado para executar o callback): POST publicada grava / desconhecida e rascunho não gravam / tipo inválido 400 / cross-origin 403 / limiter estourado não grava; agregação por peça +subjectType: 'card'não vaza parapeca; falha do drizzle →ok: false; loaders comcirculation(data/empty/draft/unavailable); mídia?download=1grava 1, comRangenão grava, semdownloadnão grava. - E2E:
tests/e2e/frontendConteudos.e2e.spec.ts(abertura/WhatsApp/copy disparam POST observado porpage.waitForRequest/postDataJSON, clipboard comcontext.grantPermissionse linha de download via REST adminGET /api/contentEvent?where[...]);tests/e2e/campaignSpeechAcervo.e2e.spec.ts(semeia eventos viacampaign.fixtures.payload.create({ collection: 'contentEvent', overrideAccess: true })e pina "Circulação" + contadores na lista e na ficha). scripts/lib/e2e-affected-manifest.mjs(editar, entrada S27:147-165): acrescentarsrc/app/(frontend)/api/content-events,src/collections/ContentEvent.ts,src/lib/contentEvents.ts,src/lib/contentPieceCirculation.ts(os demais arquivos já caem emsrc/components/campaign/content,src/utilities/contentesrc/lib/schemas).- Changelog:
docs/changelog/2026-09-23-c213.md(uma entrada curta; nunca editar o agregado/HISTORY).
Access / Consent: reusa canReadContentPiece e payloadAdminOnly; create/update negados por access (escrita só via Local API com bypass documentado); sem Consent novo (não há PII a consentir) — fail-closed de privacidade.
Dados → forma (pergunta 3 de data-presentation)
- Forma escolhida: quatro contadores absolutos lado a lado por peça, com estados explícitos — coluna "Circulação" na lista (grid de 4 no desktop; 2×2 no card mobile) e bloco de 4 células no detalhe, sem total, sem %, sem série e sem ranking. É a forma mais pobre que ainda desbloqueia as decisões da intenção (qual peça reforçar/reeditar; se a Central vale o esforço; qual canal entrega circulação).
- Rejeitadas: B número único de "usos" (esconde onde a circulação para — a decisão é por canal); C percentual/taxa de conversão (base anônima e desconhecida; % sugere precisão que não existe); D série/janela/gráfico (vira dashboard; o
createdAtdo evento já habilita a janela de 7 dias como próximo passo, via migration); E ranking/top peças (a leitura é relativa por linha; placar é vaidade e distorce); F barra/heatmap (mesma razão de C). - Honestidade do dado: os números medem eventos, não pessoas — a superfície não sugere visitante único (impossível sem identidade) e não soma os sinais.
Fases verificáveis
- Tracer / schema+server — quota ~55%: collection + config + migration +
generate:types; vocabulário + schema + limiter + write; rota POST; download na rota de mídia; agregação + módulo puro + loaders; units etests/int/contentEvents.int.spec.tsverdes antes de seguir. Tracer bullet: primeiro evento (download) ponta a ponta até a agregação retornar 1. - UI — quota ~30% (após a extensão do artefato, triggers a/b): coluna + picker + cards 2×2 + painel do detalhe; instrumentação cliente (abertura/WhatsApp/link) e o contrato aditivo de
copyFeedback/CopyLinkButton. - Gates — quota ~15%:
pnpm gate:fastpor iteração;pnpm test:int; e2e local afetado (discricionário:frontendConteudos+campaignSpeechAcervo --workers=1); crítica final do designer (trigger c) certificada antes do push comDesign tier:no PR;pnpm push; PR comCloses #1258; changelog commitado.
Rabbit holes / Não escopo (engenharia)
- Contador/rollup persistido na peça ou tabela de contagem — perde o evento cru e/ou busta o cache público (D1-C/D); janela de 7 dias, prune e
variantficam para item próprio (gatilho: pergunta virar "o que circula agora" ou o S32 pedir). - Visitante único, cookie, sessão, IP persistido, user-agent, fingerprint — proibido pela intenção (fail-closed de privacidade); nenhum campo desses na collection (pin de teste).
- Captcha/anti-bot/detecção de fraude — fora; o teto do v1 é same-origin + limiter in-memory fail-open.
- Rate limit distribuído/Redis — in-memory por processo é o teto do v1 (reseta no deploy); gatilho: abuso real medido.
- Dashboard, rota de analytics, gráfico, exportação, alertas — vedados; a superfície é a lista existente.
- Instrumentar
CopyLinkButtonpor dentro — contaria cortes (C166/C167/C168). - Segunda collection/rota para o card (S32) — o card entra como
subjectType: 'card'na mesma collection/rota (body ganha variante discriminada aditiva); sem retrabalho. payload.countpor linha /finddos eventos crus — proibido (D5).- UTM/encurtador/pós-clique — fora (a intenção já corta).
Riscos e mitigação
- Evento invalidando o cache público: a collection nova não tem hook de revalidação e nenhum write toca
contentPieces; pin de teste garante ausência dehookse de campos PII. - Spam no endpoint público: same-origin + limiter + 204 silencioso + sem PII; contagem inflada é aceita como contador a menos/inflado (fail-soft); gatilho de revisita registrado.
- Falha da agregação derrubando a lista interna:
{ ok: false }→ estadounavailable("—" + nota), com int test forçando o erro no drizzle; a lista nunca depende da contagem. after()perde o evento se o processo morrer: aceito (contador a menos); em teste,afteré mockado para executar o callback e a linha é assertada.- Download não contado (Range/retomada/clique cancelado): aceito por decisão (contador a menos) — a régua é o arquivo efetivamente servido com 200.
- Cross-feature S32 (rework):
subjectType/subjectId+ agregação parametrizada +recordContentEventgenérico desde já; o que o S32 adiciona é só o assuntocard(variante no schema da rota + colunavariantquando necessária). - Migration em produção: aditiva (tabela + enum + índice), aplicada pelo deploy antes do build; revisar o SQL gerado e rodar
pnpm migratelocal. - Estados não cobertos pelo artefato (detalhe rascunho, despublicada com histórico, a11y do "—"): extensão do artefato pelo designer (triggers a/b) antes do markup e crítica final (trigger c) antes do push — sem improviso de estrutura.
- E2E de clipboard/wa.me: clipboard exige
grantPermissions; o clique no WhatsApp abre popup externo (bloquearwa.mecompage.route/fechar popup) — a asserção do beacon não depende da navegação externa.
Desvios registrados na execução (simplify)
- Guard de tamanho do body (D2): em vez de olhar
content-length, a rota lê o stream com teto rígido de 4 KB (readBoundedBody) — um body chunked sem header não bufferiza sem limite. recordContentEvent(event)resolve opayloadinternamente (não recebepayloadcomo no D2): o contrato "nunca lança, nunca atrasa" fica encapsulado num único ponto e os dois call sites não podem esquecer de resolvê-lo.- Agregação: o tipo de linha é genérico (
ContentEventAggregateRowemsrc/lib/contentEvents.ts) e o acesso ao drizzle é o helper compartilhadogetPayloadDrizzle(src/utilities/drizzleBulk.ts) — osupporterListOverviewAggregatefoi migrado para o mesmo dono (sem twin). - Índices: só o composto
(subjectType, subjectId, type)— sem índices por campo (D1). - Manifest e2e:
src/lib/contentPieceCirculation.tsnão precisou de entry própria — o prefixosrc/lib/contentPieceda entrada do C211 já o cobre. - Cobertura: o branch de throttle da rota e o estado
unavailabledos loaders entraram como int (mocks de rate limit/agregação); o estadoempty(publicada sem eventos) entrou no loader int. - Crítica final do design (trigger c, certificada, tier primário): a tabela em 1280 cortava a coluna obrigatória "Próxima ação" com a coluna nova; portado
xl:[&_table]:table-fixed+ larguras 25/13/11/39/12 por cabeçalho exl:whitespace-normalna célula "Peça" (abaixo de xl a tabela segue natural com o scroll do shell). O estado indisponível foi renderizado de verdade (falha forçada da agregação) e conferido contra a cena 07.
Aceite de engenharia
- Aceite de produto da intenção coberto: quatro contadores absolutos por peça (Aberturas · Downloads · WhatsApp · Link) na lista do C211 e no detalhe, anônimos e agregados, com estados honestos (dados/tudo-zero/rascunho/indisponível); nenhum total/%/série/dashboard/serviço novo.
- Guardrails: sem PII (sem IP persistido, cookie, user-agent, identidade ou rastreio entre peças); sem
Consentnovo; fail-soft (abertura/download/compartilhamento nunca dependem da contagem). - Invariantes AGENTS/engineering-standards: Local API com
overrideAccess: trueexplícito e documentado nos dois writes; identificadores em inglês/copy pt-BR; migrations existentes intocadas; nenhum top-level novo emsrc/utilities(tudo emsrc/utilities/content/). - Testes de domínio previstos (unit/int) onde access/write paths mudam: access da collection, rota pública, rota de mídia, agregação, loaders e estados.
- Migration
add_content_events+payload-types.tscommitados;pnpm migratelocal verde; SQL revisado (aditivo). - Manifest e2e atualizado; e2e local afetado rodado (discricionário);
pnpm gate:fastverde. - Design: artefato estendido (triggers a/b) antes do markup; crítica final (trigger c) certificada antes do push com
Design tier:no PR. - Changelog
docs/changelog/2026-09-23-c213.md; PR comCloses #1258; impl plan incluído no commit da entrega.
Self-score decision-quality
5/5 — todas as decisões caras (modelo do evento, transporte/limiter, contagem de download, contrato de copy, agregação, superfície e e2e) têm opções + recomendação + rejeitadas; cabe no appetite herdado sem inflar (sem retenção/janela/dashboard, uma query por página); rabbit holes nomeados (rollup, visitante único, captcha, Redis, instrumentar o botão compartilhado); depth check reusa shells/helpers existentes (CampaignTable + picker, drizzleBulk/precedente de agregação, sameOriginRequest, after(), canReadContentPiece); e o outcome da intenção permanece intacto (quatro contadores separados, anonimato fail-closed, fail-soft, sem serviço novo, mesmo mecanismo para o S32).