teqo-backup/docs/plans/central-conteudos-analytics-impl.md
Francisco Solla f47c2645ac feat(C213): contadores anônimos de circulação por peça na Central de Conteúdos (Closes #1258)
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).
2026-09-23 15:29:43 -03:00

29 KiB
Raw Blame History

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 Consent novo (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.
  • 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ão CampaignVoteSummarySnapshot (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 afterChange do ContentPiece busta a tag pública contentPieces a cada write (src/collections/ContentPiece.ts:78-81) — cada evento anônimo invalidaria o cache da Central pública.

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): collection contentEvent, admin: { hidden: true, group: 'Comunicação' }; campos type (select required, enum abertura | download | compartilhar_whatsapp | compartilhar_link), subjectType (select required, enum peca | card), subjectId (text required); indexes: [{ fields: ['subjectType', 'subjectId', 'type'] }]; access de D1; sem hooks (não busta contentPieces); timestamps default (createdAt = base da janela futura).
  • src/payload.config.ts: registrar ContentEvent no 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.ts e rodar pnpm migrate + pnpm generate:types local.
  • src/lib/contentEvents.ts (novo, puro/client-safe): CONTENT_EVENT_TYPES/isContentEventType + sendContentPieceEvent(type, slug) (beacon → fallback fetch keepalive; try/catch; nunca lança).
  • src/lib/schemas/contentEvent.ts (novo): contentEventRequestSchema (type do vocabulário; pieceSlug bounded) — importa o vocabulário de contentEvents.ts (uma fonte, sem drift).
  • src/utilities/content/contentEventWrite.ts (novo, server-only): recordContentEvent(payload, { type, subjectType, subjectId }) — Local API overrideAccess: 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 com after() quando download=1, sem Range e 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), mapa type → metric, toContentPieceCirculationView com a precedência de D5, literais de estado (0 abertura · 0 download · 0 WhatsApp · 0 link, Sem dados — ainda não publicada) e ContentPieceRowViewModel = ContentPieceViewModel & { circulation: ContentPieceCirculationView }.
  • src/utilities/content/contentPiecePageData.ts (editar, :92-121 e :141-176): list/detail anexam circulation (uma query por página; detalhe 1 id; hasBeenPublished derivado do publishedAt cru do select — o VM src/lib/contentPiece.ts nã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): coluna circulation entre publication e action.
  • 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): entrada circulation no picker.
  • src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/page.tsx (editar, :194-217): painel acima do ContentPieceForm na coluna direita.

Instrumentação cliente (S27)

  • src/components/conteudos/ContentPieceDetail.tsx (editar): abertura no mount com guarda useRef.
  • src/components/conteudos/ContentPieceShareSheet.tsx (editar, :155-176): WhatsApp no onClick do <a>; CopyLinkButton onCopied para o link.
  • src/components/CopyLinkButton.tsx (editar): prop aditiva onCopied? — não instrumenta por dentro (cortes não contam).
  • src/lib/copyFeedback.ts (editar): copy devolve Promise<boolean> (callers seguem com void).

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 de ContentEvent sem IP/UA/cookie e collection sem hooks.
  • Int: tests/int/contentEvents.int.spec.ts (novo; importa os handlers de rota como contentPiece.int.spec.ts:28,36; after mockado 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 para peca; falha do drizzle → ok: false; loaders com circulation (data/empty/draft/unavailable); mídia ?download=1 grava 1, com Range não grava, sem download não grava.
  • E2E: tests/e2e/frontendConteudos.e2e.spec.ts (abertura/WhatsApp/copy disparam POST observado por page.waitForRequest/postDataJSON, clipboard com context.grantPermissions e linha de download via REST admin GET /api/contentEvent?where[...]); tests/e2e/campaignSpeechAcervo.e2e.spec.ts (semeia eventos via campaign.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): acrescentar src/app/(frontend)/api/content-events, src/collections/ContentEvent.ts, src/lib/contentEvents.ts, src/lib/contentPieceCirculation.ts (os demais arquivos já caem em src/components/campaign/content, src/utilities/content e src/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 createdAt do 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

  1. 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 e tests/int/contentEvents.int.spec.ts verdes antes de seguir. Tracer bullet: primeiro evento (download) ponta a ponta até a agregação retornar 1.
  2. 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.
  3. Gates — quota ~15%: pnpm gate:fast por 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 com Design tier: no PR; pnpm push; PR com Closes #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 variant ficam 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 CopyLinkButton por 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.count por linha / find dos 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 de hooks e 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 } → estado unavailable ("—" + 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 + recordContentEvent genérico desde já; o que o S32 adiciona é só o assunto card (variante no schema da rota + coluna variant quando necessária).
  • Migration em produção: aditiva (tabela + enum + índice), aplicada pelo deploy antes do build; revisar o SQL gerado e rodar pnpm migrate local.
  • 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 (bloquear wa.me com page.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 o payload internamente (não recebe payload como 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 (ContentEventAggregateRow em src/lib/contentEvents.ts) e o acesso ao drizzle é o helper compartilhado getPayloadDrizzle (src/utilities/drizzleBulk.ts) — o supporterListOverviewAggregate foi migrado para o mesmo dono (sem twin).
  • Índices: só o composto (subjectType, subjectId, type) — sem índices por campo (D1).
  • Manifest e2e: src/lib/contentPieceCirculation.ts não precisou de entry própria — o prefixo src/lib/contentPiece da entrada do C211 já o cobre.
  • Cobertura: o branch de throttle da rota e o estado unavailable dos loaders entraram como int (mocks de rate limit/agregação); o estado empty (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 e xl:whitespace-normal na 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 Consent novo; fail-soft (abertura/download/compartilhamento nunca dependem da contagem).
  • Invariantes AGENTS/engineering-standards: Local API com overrideAccess: true explícito e documentado nos dois writes; identificadores em inglês/copy pt-BR; migrations existentes intocadas; nenhum top-level novo em src/utilities (tudo em src/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.ts commitados; pnpm migrate local verde; SQL revisado (aditivo).
  • Manifest e2e atualizado; e2e local afetado rodado (discricionário); pnpm gate:fast verde.
  • 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 com Closes #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).