mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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).
This commit is contained in:
parent
75e5825b05
commit
f47c2645ac
38 changed files with 20992 additions and 96 deletions
1
docs/changelog/2026-09-23-c213.md
Normal file
1
docs/changelog/2026-09-23-c213.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C213 — Analytics da Central de Conteúdos: contadores anônimos de circulação por peça (2026-09-23):** a lista interna do C211 (e a ficha) passa a mostrar, por peça, quatro contadores absolutos e anônimos — Aberturas · Downloads · WhatsApp · Link — sem dashboard, serviço novo ou PII. O mecanismo é uma collection nova `contentEvent` (hidden, sem hooks — um write de evento nunca busta a tag pública `contentPieces`) com `type` (`abertura|download|compartilhar_whatsapp|compartilhar_link`) + `subjectType` (`peca|card`, o S32 entra como mais um assunto) + `subjectId` (id interno da peça), índice composto único `(subjectType, subjectId, type)`, `create/update` negados por access e escrita só via Local API com bypass documentado. A escrita pública é o `POST /api/content-events` (beacon): zod (`type` + slug público), `isSameOriginRequest`, teto rígido de 4 KB lido no stream, limitador in-memory por IP hasheado com salt de processo (nunca persistido, fail-open, teto de 10k chaves) e resolução da peça publicada pelo slug — slug desconhecido ou despublicado responde 204 silencioso sem gravar. O download é contado no servidor, na rota pública de mídia (`?download=1` sem `Range`, via `after()`, só com 200 — cobre de graça o fallback "A mídia"); abertura/WhatsApp/link saem do cliente (`sendBeacon` com fallback `fetch keepalive`, guarda `useRef` contra o double-effect do StrictMode; `CopyLinkButton` ganhou `onCopied` aditivo e `useCopyFeedback.copy` passou a devolver `Promise<boolean>`, cortes C166/C167/C168 intactos). A agregação é UMA query group-by por página (`loadContentEventCountsBySubject` + o helper compartilhado `getPayloadDrizzle` em `drizzleBulk.ts`, dono único do acesso ao drizzle para o qual o `supporterListOverviewAggregate` também migrou), com falha degradando para o estado "—"/"contagem indisponível" sem derrubar a lista. A UI porta o artefato aprovado classe-a-classe: coluna "Circulação" na `CampaignTable` (picker `conteudos`, visível por default) com os estados dados/tudo-zero (`0 abertura · 0 download · 0 WhatsApp · 0 link`)/rascunho nunca publicada (`Sem dados — ainda não publicada`)/despublicada com histórico (`Histórico de quando esteve publicada · peça fora do ar`)/indisponível; grid 2×2 no card mobile; painel na ficha com o strip responsivo, a nota "Leitura:" (só quando há contadores) e a linha de privacidade — e `xl:[&_table]:table-fixed` com larguras por coluna para os cinco cabeçalhos caberem em 1280 sem cortar "Próxima ação" (ajuste da crítica final do `designer`, certificada em tier primário; artefato estendido com as cenas 05–07 + CONTRATO DE PORT). Privacidade fail-closed: sem IP/cookie/user-agent/sessão em lugar nenhum (o evento guarda só tipo + assunto), sem `Consent` novo e nenhum rastreio entre peças. Migration aditiva `20260923_172437_add_content_events` (tabela + enums + índice + rel de locked documents). Verificação: unit (`contentPieceCirculation` com precedência/mapeamento/literais, `contentEvents` com o contrato da collection sem PII e sem hooks + transporte do beacon, `contentEventRateLimit` com janela/reset/chaves), int (`contentEvents`: rota publicada/desconhecida/tipo inválido/oversized/cross-origin/throttle, agregação por assunto sem vazar `card` em `peca`, falha → `ok:false`, loaders data/empty/neverPublished/unavailable, mídia com download/range/leitura simples, access fechado), e2e (`frontendConteudos` com os quatro beacons + a linha anônima por REST, `campaignSpeechAcervo` com os contadores na lista e na ficha), `pnpm gate:fast`, `pnpm test:int` (927), build e os e2e afetados locais.
|
||||
196
docs/plans/central-conteudos-analytics-impl.md
Normal file
196
docs/plans/central-conteudos-analytics-impl.md
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
# 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
|
||||
|
||||
```mermaid
|
||||
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).
|
||||
|
|
@ -105,10 +105,11 @@
|
|||
<body class="board min-h-screen text-foreground antialiased">
|
||||
<main class="mx-auto max-w-[1480px] space-y-16 px-4 py-12 md:px-8">
|
||||
<header class="max-w-3xl">
|
||||
<span class="badge bg-primary text-white">C213 · revisão do gate</span>
|
||||
<span class="badge bg-primary text-white">C213 · extensão pré-markup</span>
|
||||
<h1 class="mt-4 text-3xl font-semibold tracking-tight">Quatro sinais. Nenhum dashboard.</h1>
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
Os contadores vivem junto da peça e respondem só ao que a assessoria precisa decidir.
|
||||
Cenas 05–07 fecham rascunho, histórico, indisponibilidade e acessibilidade.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
|
@ -158,13 +159,14 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="mt-5 overflow-hidden rounded-xl border">
|
||||
<table class="w-full text-left text-sm">
|
||||
<table class="w-full table-fixed text-left text-sm">
|
||||
<thead class="bg-stone-50 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="p-4">Peça</th>
|
||||
<th>Publicação</th>
|
||||
<th>Circulação</th>
|
||||
<th class="pr-4 text-right">Ação</th>
|
||||
<th class="w-[25%] p-4">Peça</th>
|
||||
<th class="w-[13%]">Processamento</th>
|
||||
<th class="w-[11%]">Publicação</th>
|
||||
<th class="w-[39%]">Circulação</th>
|
||||
<th class="w-[12%] pr-4 text-right">Próxima ação</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
|
|
@ -178,6 +180,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="badge bg-green-100 text-green-800">Pronto</span></td>
|
||||
<td><span class="badge live">Publicado</span></td>
|
||||
<td>
|
||||
<div class="grid grid-cols-4 gap-5">
|
||||
|
|
@ -206,6 +209,7 @@
|
|||
<b>Solla em defesa do SUS</b>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Foto</p>
|
||||
</td>
|
||||
<td><span class="badge bg-green-100 text-green-800">Pronto</span></td>
|
||||
<td><span class="badge live">Publicado</span></td>
|
||||
<td class="font-semibold tabular-nums">
|
||||
0 abertura · 0 download · 0 WhatsApp · 0 link
|
||||
|
|
@ -217,10 +221,43 @@
|
|||
<b>Por que defender o SUS</b>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Texto</p>
|
||||
</td>
|
||||
<td><span class="badge bg-green-100 text-green-800">Pronto</span></td>
|
||||
<td><span class="badge draft">Rascunho</span></td>
|
||||
<td class="text-xs text-muted-foreground">Sem dados — ainda não publicada</td>
|
||||
<td class="pr-4 text-right"><button class="btn outline">Abrir</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p-4">
|
||||
<b>Saúde pública é compromisso</b>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Vídeo</p>
|
||||
</td>
|
||||
<td><span class="badge bg-green-100 text-green-800">Pronto</span></td>
|
||||
<td><span class="badge draft">Rascunho</span></td>
|
||||
<td>
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<span class="block text-[10px] text-muted-foreground">Aberturas</span
|
||||
><span class="mt-1 block h-3 w-8 rounded bg-stone-300"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-[10px] text-muted-foreground">Downloads</span
|
||||
><span class="mt-1 block h-3 w-8 rounded bg-stone-300"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-[10px] text-muted-foreground">WhatsApp</span
|
||||
><span class="mt-1 block h-3 w-8 rounded bg-stone-300"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-[10px] text-muted-foreground">Link</span
|
||||
><span class="mt-1 block h-3 w-8 rounded bg-stone-300"></span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1.5 text-[10px] text-muted-foreground">
|
||||
Histórico de quando esteve publicada · peça fora do ar
|
||||
</p>
|
||||
</td>
|
||||
<td class="pr-4 text-right"><button class="btn outline">Abrir</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -235,52 +272,322 @@
|
|||
|
||||
<section>
|
||||
<div class="scene mb-3 flex items-center gap-3 text-xs font-bold text-stone-600">
|
||||
<span>CENA 02</span>
|
||||
<h2>Desktop 1280 · detalhe por canal, sem gráfico</h2>
|
||||
<span>CENA 05 · REVISÃO C213</span>
|
||||
<h2>Desktop 1280 · detalhe de rascunho nunca publicado</h2>
|
||||
</div>
|
||||
<div class="desktop frame mx-auto overflow-hidden rounded-xl border bg-white">
|
||||
<div class="border-b px-8 py-5">
|
||||
<a class="text-xs font-semibold text-primary">← Conteúdos</a>
|
||||
<h3 class="mt-2 text-xl font-semibold">Fim da escala 6×1 é saúde</h3>
|
||||
<div class="mt-2 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold">Por que defender o SUS</h3>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<span class="badge bg-green-100 text-green-800">Pronto</span
|
||||
><span class="badge draft">Rascunho</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary">Publicar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-[300px_1fr] gap-8 p-8">
|
||||
<div>
|
||||
<div class="asset aspect-video rounded-xl">NEEDS ASSET · PREVIEW REAL</div>
|
||||
<button class="btn outline mt-4 w-full">Editar peça</button>
|
||||
<dl class="mt-4 space-y-2 text-xs">
|
||||
<div class="flex justify-between gap-3">
|
||||
<dt class="text-muted-foreground">Origem</dt>
|
||||
<dd>Arquivo</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3">
|
||||
<dt class="text-muted-foreground">Duração</dt>
|
||||
<dd>—</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-base font-semibold">Circulação</h4>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Contagem anônima e acumulada desde a publicação.
|
||||
</p>
|
||||
<div class="mt-5 grid grid-cols-4 overflow-hidden rounded-xl border">
|
||||
<div class="p-4">
|
||||
<span class="text-xs text-muted-foreground">Aberturas</span
|
||||
><span class="mt-3 block h-5 w-14 rounded bg-stone-300"></span>
|
||||
<div class="mt-5 rounded-xl border bg-muted/40 p-5">
|
||||
<p class="text-sm font-semibold">Sem dados — esta peça ainda não foi publicada.</p>
|
||||
<p class="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
A contagem começa quando ela entrar na Central pública.
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-3 text-xs leading-5 text-muted-foreground">
|
||||
<span class="font-medium text-foreground">Privacidade:</span> sem IP persistido,
|
||||
cookie de identidade ou rastreio entre peças.
|
||||
</p>
|
||||
<div class="mt-6 border-t pt-6">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label class="col-span-2 text-xs font-medium"
|
||||
>Título<input class="field mt-1" value="Por que defender o SUS"
|
||||
/></label>
|
||||
<label class="text-xs font-medium"
|
||||
>Tipo<select class="field mt-1">
|
||||
<option>Texto</option>
|
||||
</select></label
|
||||
>
|
||||
<label class="text-xs font-medium"
|
||||
>Data da peça<input class="field mt-1" type="text" placeholder="dd/mm/aaaa"
|
||||
/></label>
|
||||
</div>
|
||||
<div class="border-l p-4">
|
||||
<span class="text-xs text-muted-foreground">Downloads</span
|
||||
><span class="mt-3 block h-5 w-14 rounded bg-stone-300"></span>
|
||||
</div>
|
||||
<div class="border-l p-4">
|
||||
<span class="text-xs text-muted-foreground">WhatsApp</span
|
||||
><span class="mt-3 block h-5 w-14 rounded bg-stone-300"></span>
|
||||
</div>
|
||||
<div class="border-l p-4">
|
||||
<span class="text-xs text-muted-foreground">Link copiado</span
|
||||
><span class="mt-3 block h-5 w-14 rounded bg-stone-300"></span>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button class="btn primary">Salvar alterações</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="scene mb-3 flex items-center gap-3 text-xs font-bold text-stone-600">
|
||||
<span>CENA 06 · REVISÃO C213</span>
|
||||
<h2>Mobile 390 · peça despublicada preserva o histórico</h2>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-start justify-center gap-8">
|
||||
<div class="phone frame min-h-[760px] overflow-hidden border bg-white">
|
||||
<div class="flex h-14 items-center gap-3 border-b px-4">
|
||||
<span>←</span><b>Detalhe da peça</b>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h3 class="text-base font-semibold">Saúde pública é compromisso</h3>
|
||||
<span class="badge draft shrink-0">Rascunho</span>
|
||||
</div>
|
||||
<div class="asset mt-4 aspect-video rounded-xl">NEEDS ASSET · PREVIEW REAL</div>
|
||||
<h4 class="mt-6 text-sm font-semibold">Circulação</h4>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
Contagem anônima e acumulada desde a publicação.
|
||||
</p>
|
||||
<dl class="mt-3 grid grid-cols-1 divide-y overflow-hidden rounded-xl border">
|
||||
<div class="flex min-w-0 items-center justify-between gap-4 p-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">Aberturas</dt>
|
||||
<dd
|
||||
class="h-4 w-12 rounded bg-stone-300"
|
||||
aria-label="Valor real omitido no gate"
|
||||
></dd>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center justify-between gap-4 p-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">Downloads</dt>
|
||||
<dd
|
||||
class="h-4 w-12 rounded bg-stone-300"
|
||||
aria-label="Valor real omitido no gate"
|
||||
></dd>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center justify-between gap-4 p-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">WhatsApp</dt>
|
||||
<dd
|
||||
class="h-4 w-12 rounded bg-stone-300"
|
||||
aria-label="Valor real omitido no gate"
|
||||
></dd>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center justify-between gap-4 p-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">Link copiado</dt>
|
||||
<dd
|
||||
class="h-4 w-12 rounded bg-stone-300"
|
||||
aria-label="Valor real omitido no gate"
|
||||
></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="mt-4 rounded-lg bg-muted p-3 text-xs leading-5 text-muted-foreground">
|
||||
<b class="text-foreground">Histórico de quando esteve publicada.</b> A peça está
|
||||
fora do ar.
|
||||
</p>
|
||||
<aside
|
||||
aria-label="Como ler os contadores"
|
||||
class="mt-3 rounded-lg bg-muted p-3 text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
<b class="text-foreground">Leitura:</b> compare os sinais sem somá-los. WhatsApp e
|
||||
link mostram escolhas diferentes.
|
||||
</aside>
|
||||
<p class="mt-3 text-xs leading-5 text-muted-foreground">
|
||||
<span class="font-medium text-foreground">Privacidade:</span> sem IP persistido,
|
||||
cookie de identidade ou rastreio entre peças.
|
||||
</p>
|
||||
<div class="mt-5 border-t pt-5">
|
||||
<label class="text-xs font-medium"
|
||||
>Título<input class="field mt-1" value="Saúde pública é compromisso"
|
||||
/></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="w-[520px] rounded-xl border bg-white p-6 shadow-sm">
|
||||
<span class="badge bg-primary text-white">CONTRATO DE PORT</span>
|
||||
<h3 class="mt-4 text-lg font-semibold">Bloco de domínio “Circulação”</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
O strip compartilhado atual é só referência de tokens; sua pilha mobile não
|
||||
corresponde a este desenho. O alvo responsivo é o
|
||||
<code class="rounded bg-muted px-1 py-0.5 text-foreground"><dl></code> da cena
|
||||
02; as cenas 04 e 06 isolam as classes-base do mesmo bloco para a prévia de 390 px.
|
||||
</p>
|
||||
<div class="mt-5 space-y-4 text-sm">
|
||||
<div>
|
||||
<b>Contêiner</b
|
||||
><code
|
||||
class="mt-1 block overflow-x-auto rounded-lg bg-stone-950 p-3 text-xs leading-5 text-stone-100"
|
||||
>mt-5 grid grid-cols-1 divide-y overflow-hidden rounded-xl border sm:grid-cols-2
|
||||
sm:divide-y-0 lg:grid-cols-4</code
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<b>Célula</b
|
||||
><code
|
||||
class="mt-1 block overflow-x-auto rounded-lg bg-stone-950 p-3 text-xs leading-5 text-stone-100"
|
||||
>flex min-w-0 items-center justify-between gap-4 p-4 sm:flex-col sm:items-start
|
||||
sm:justify-start sm:gap-1</code
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<b>Tipografia</b
|
||||
><code
|
||||
class="mt-1 block overflow-x-auto rounded-lg bg-stone-950 p-3 text-xs leading-5 text-stone-100"
|
||||
>dt: text-sm font-medium text-muted-foreground sm:text-xs · dd: text-lg
|
||||
font-medium tabular-nums tracking-tight</code
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<b>Divisórias das células 2–4</b
|
||||
><code
|
||||
class="mt-1 block overflow-x-auto rounded-lg bg-stone-950 p-3 text-xs leading-5 text-stone-100"
|
||||
>2: sm:border-l · 3: sm:border-t lg:border-l lg:border-t-0 · 4: sm:border-l
|
||||
sm:border-t lg:border-t-0</code
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 border-t pt-5 text-sm leading-6">
|
||||
<p><b>Lista:</b> Aberturas · Downloads · WhatsApp · Link.</p>
|
||||
<p><b>Detalhe:</b> Aberturas · Downloads · WhatsApp · Link copiado.</p>
|
||||
<p>
|
||||
<b>Indisponível:</b> “—” visível com
|
||||
<code class="rounded bg-muted px-1">aria-hidden</code>; cada valor recebe “Rótulo:
|
||||
contagem indisponível” como nome acessível.
|
||||
</p>
|
||||
<p><b>Hierarquia:</b> strip → estado/Leitura → Privacidade → formulário já aberto.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="scene mb-3 flex items-center gap-3 text-xs font-bold text-stone-600">
|
||||
<span>CENA 02</span>
|
||||
<h2>Desktop 1280 · detalhe por canal, sem gráfico</h2>
|
||||
</div>
|
||||
<div class="desktop frame mx-auto overflow-hidden rounded-xl border bg-white">
|
||||
<div class="border-b px-8 py-5">
|
||||
<a class="text-xs font-semibold text-primary">← Conteúdos</a>
|
||||
<div class="mt-2 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold">Fim da escala 6×1 é saúde</h3>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<span class="badge bg-green-100 text-green-800">Pronto</span
|
||||
><span class="badge live">Publicado</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary">Despublicar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-[300px_1fr] gap-8 p-8">
|
||||
<div>
|
||||
<div class="asset aspect-video rounded-xl">NEEDS ASSET · PREVIEW REAL</div>
|
||||
<dl class="mt-4 space-y-2 text-xs">
|
||||
<div class="flex justify-between gap-3">
|
||||
<dt class="text-muted-foreground">Origem</dt>
|
||||
<dd>Arquivo</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3">
|
||||
<dt class="text-muted-foreground">Duração</dt>
|
||||
<dd>—</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3">
|
||||
<dt class="text-muted-foreground">Publicado em</dt>
|
||||
<dd
|
||||
class="h-3 w-20 rounded bg-stone-300"
|
||||
aria-label="Data real omitida no gate"
|
||||
></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-base font-semibold">Circulação</h4>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Contagem anônima e acumulada desde a publicação.
|
||||
</p>
|
||||
<dl
|
||||
class="mt-5 grid grid-cols-1 divide-y overflow-hidden rounded-xl border sm:grid-cols-2 sm:divide-y-0 lg:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 items-center justify-between gap-4 p-4 sm:flex-col sm:items-start sm:justify-start sm:gap-1"
|
||||
>
|
||||
<dt class="text-sm font-medium text-muted-foreground sm:text-xs">Aberturas</dt>
|
||||
<dd
|
||||
class="mt-2 h-5 w-14 rounded bg-stone-300"
|
||||
aria-label="Valor real omitido no gate"
|
||||
></dd>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-w-0 items-center justify-between gap-4 p-4 sm:flex-col sm:items-start sm:justify-start sm:gap-1 sm:border-l"
|
||||
>
|
||||
<dt class="text-sm font-medium text-muted-foreground sm:text-xs">Downloads</dt>
|
||||
<dd
|
||||
class="mt-2 h-5 w-14 rounded bg-stone-300"
|
||||
aria-label="Valor real omitido no gate"
|
||||
></dd>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-w-0 items-center justify-between gap-4 p-4 sm:flex-col sm:items-start sm:justify-start sm:gap-1 sm:border-t lg:border-l lg:border-t-0"
|
||||
>
|
||||
<dt class="text-sm font-medium text-muted-foreground sm:text-xs">WhatsApp</dt>
|
||||
<dd
|
||||
class="mt-2 h-5 w-14 rounded bg-stone-300"
|
||||
aria-label="Valor real omitido no gate"
|
||||
></dd>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-w-0 items-center justify-between gap-4 p-4 sm:flex-col sm:items-start sm:justify-start sm:gap-1 sm:border-l sm:border-t lg:border-t-0"
|
||||
>
|
||||
<dt class="text-sm font-medium text-muted-foreground sm:text-xs">Link copiado</dt>
|
||||
<dd
|
||||
class="mt-2 h-5 w-14 rounded bg-stone-300"
|
||||
aria-label="Valor real omitido no gate"
|
||||
></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
Valores reais omitidos neste gate; o produto mostra contagens absolutas.
|
||||
</p>
|
||||
<div class="mt-5 rounded-lg bg-muted p-4 text-sm leading-6 text-muted-foreground">
|
||||
<aside
|
||||
aria-label="Como ler os contadores"
|
||||
class="mt-5 rounded-lg bg-muted p-4 text-sm leading-6 text-muted-foreground"
|
||||
>
|
||||
<b class="text-foreground">Leitura:</b> compare os sinais sem somá-los. WhatsApp e
|
||||
link ficam separados porque mostram escolhas diferentes.
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-muted-foreground">
|
||||
</aside>
|
||||
<p class="mt-3 text-xs leading-5 text-muted-foreground">
|
||||
<span class="font-medium text-foreground">Privacidade:</span>
|
||||
Sem IP persistido, cookie de identidade ou rastreio entre peças.
|
||||
</p>
|
||||
<div class="mt-6 border-t pt-6">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label class="col-span-2 text-xs font-medium"
|
||||
>Título<input class="field mt-1" value="Fim da escala 6×1 é saúde"
|
||||
/></label>
|
||||
<label class="text-xs font-medium"
|
||||
>Tipo<select class="field mt-1">
|
||||
<option>Vídeo</option>
|
||||
</select></label
|
||||
>
|
||||
<label class="text-xs font-medium"
|
||||
>Data da peça<input class="field mt-1" type="text" placeholder="dd/mm/aaaa"
|
||||
/></label>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button class="btn primary">Salvar alterações</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -304,8 +611,9 @@
|
|||
<article class="mt-4 rounded-xl border p-4">
|
||||
<div class="flex justify-between gap-3">
|
||||
<b class="text-sm">Fim da escala 6×1 é saúde</b
|
||||
><span class="badge live h-fit">Publicado</span>
|
||||
><span class="badge h-fit bg-green-100 text-green-800">Pronto</span>
|
||||
</div>
|
||||
<div class="mt-1"><span class="badge live">Publicado</span></div>
|
||||
<div class="mt-4 grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-border">
|
||||
<div class="bg-white p-3">
|
||||
<span class="text-[10px] text-muted-foreground">Aberturas</span
|
||||
|
|
@ -329,8 +637,9 @@
|
|||
<article class="mt-3 rounded-xl border p-4">
|
||||
<div class="flex justify-between">
|
||||
<b class="text-sm">Solla em defesa do SUS</b
|
||||
><span class="badge live">Publicado</span>
|
||||
><span class="badge bg-green-100 text-green-800">Pronto</span>
|
||||
</div>
|
||||
<div class="mt-1"><span class="badge live">Publicado</span></div>
|
||||
<p class="mt-3 text-xs text-muted-foreground">
|
||||
0 abertura · 0 download · 0 WhatsApp · 0 link
|
||||
</p>
|
||||
|
|
@ -338,9 +647,10 @@
|
|||
<article class="mt-3 rounded-xl border p-4">
|
||||
<div class="flex justify-between">
|
||||
<b class="text-sm">Por que defender o SUS</b
|
||||
><span class="badge draft">Rascunho</span>
|
||||
><span class="badge bg-green-100 text-green-800">Pronto</span>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-muted-foreground">Sem dados — ainda não publicada.</p>
|
||||
<div class="mt-1"><span class="badge draft">Rascunho</span></div>
|
||||
<p class="mt-3 text-xs text-muted-foreground">Sem dados — ainda não publicada</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -352,19 +662,159 @@
|
|||
<div class="asset aspect-video rounded-xl">NEEDS ASSET · PREVIEW REAL</div>
|
||||
<h3 class="mt-4 text-base font-semibold">Fim da escala 6×1 é saúde</h3>
|
||||
<h4 class="mt-6 text-sm font-semibold">Circulação</h4>
|
||||
<div class="mt-3 divide-y overflow-hidden rounded-xl border">
|
||||
<div class="flex justify-between p-4 text-sm"><span>Aberturas</span><b>—</b></div>
|
||||
<div class="flex justify-between p-4 text-sm"><span>Downloads</span><b>—</b></div>
|
||||
<div class="flex justify-between p-4 text-sm"><span>WhatsApp</span><b>—</b></div>
|
||||
<div class="flex justify-between p-4 text-sm">
|
||||
<span>Link copiado</span><b>—</b>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
Contagem anônima e acumulada desde a publicação.
|
||||
</p>
|
||||
<dl class="mt-3 grid grid-cols-1 divide-y overflow-hidden rounded-xl border">
|
||||
<div class="flex min-w-0 items-center justify-between gap-4 p-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">Aberturas</dt>
|
||||
<dd
|
||||
class="text-lg font-medium tabular-nums tracking-tight"
|
||||
aria-label="Aberturas: contagem indisponível"
|
||||
>
|
||||
<span aria-hidden="true">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center justify-between gap-4 p-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">Downloads</dt>
|
||||
<dd
|
||||
class="text-lg font-medium tabular-nums tracking-tight"
|
||||
aria-label="Downloads: contagem indisponível"
|
||||
>
|
||||
<span aria-hidden="true">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center justify-between gap-4 p-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">WhatsApp</dt>
|
||||
<dd
|
||||
class="text-lg font-medium tabular-nums tracking-tight"
|
||||
aria-label="WhatsApp: contagem indisponível"
|
||||
>
|
||||
<span aria-hidden="true">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center justify-between gap-4 p-4">
|
||||
<dt class="text-sm font-medium text-muted-foreground">Link copiado</dt>
|
||||
<dd
|
||||
class="text-lg font-medium tabular-nums tracking-tight"
|
||||
aria-label="Link copiado: contagem indisponível"
|
||||
>
|
||||
<span aria-hidden="true">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p
|
||||
role="status"
|
||||
class="mt-4 rounded-lg bg-muted p-3 text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
<b class="text-foreground">Contagem indisponível agora.</b> A página pública, o
|
||||
download e o compartilhamento continuam funcionando.
|
||||
</p>
|
||||
<p class="mt-3 text-xs leading-5 text-muted-foreground">
|
||||
<span class="font-medium text-foreground">Privacidade:</span> sem IP persistido,
|
||||
cookie de identidade ou rastreio entre peças.
|
||||
</p>
|
||||
<div class="mt-5 border-t pt-5">
|
||||
<label class="text-xs font-medium"
|
||||
>Título<input class="field mt-1" value="Fim da escala 6×1 é saúde"
|
||||
/></label>
|
||||
<button class="btn primary mt-3 w-full">Salvar alterações</button>
|
||||
</div>
|
||||
<div class="mt-4 rounded-lg bg-muted p-3 text-xs leading-5 text-muted-foreground">
|
||||
Contagem indisponível agora. A página pública, o download e o compartilhamento
|
||||
continuam funcionando.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="scene mb-3 flex items-center gap-3 text-xs font-bold text-stone-600">
|
||||
<span>CENA 07 · REVISÃO C213</span>
|
||||
<h2>Desktop 1280 · indisponibilidade e hierarquia acessível</h2>
|
||||
</div>
|
||||
<div class="desktop frame mx-auto overflow-hidden rounded-xl border bg-white p-8">
|
||||
<div class="grid grid-cols-[300px_1fr] gap-8">
|
||||
<aside class="rounded-xl bg-muted p-5 text-sm leading-6 text-muted-foreground">
|
||||
<b class="text-foreground">Ordem de leitura</b>
|
||||
<ol class="mt-2 list-decimal space-y-1 pl-4">
|
||||
<li>Título e explicação da medição.</li>
|
||||
<li>Quatro termos e seus valores.</li>
|
||||
<li>Estado indisponível, visível e anunciado.</li>
|
||||
<li>Nota de privacidade.</li>
|
||||
<li>Formulário já aberto.</li>
|
||||
</ol>
|
||||
<p class="mt-4 text-xs">
|
||||
“Leitura:” só aparece quando há contadores para comparar. Não compete com a mensagem
|
||||
de falha.
|
||||
</p>
|
||||
</aside>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold">Circulação</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Contagem anônima e acumulada desde a publicação.
|
||||
</p>
|
||||
<dl
|
||||
class="mt-5 grid grid-cols-1 divide-y overflow-hidden rounded-xl border sm:grid-cols-2 sm:divide-y-0 lg:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 items-center justify-between gap-4 p-4 sm:flex-col sm:items-start sm:justify-start sm:gap-1"
|
||||
>
|
||||
<dt class="text-sm font-medium text-muted-foreground sm:text-xs">Aberturas</dt>
|
||||
<dd
|
||||
class="text-lg font-medium tabular-nums tracking-tight"
|
||||
aria-label="Aberturas: contagem indisponível"
|
||||
>
|
||||
<span aria-hidden="true">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-w-0 items-center justify-between gap-4 p-4 sm:flex-col sm:items-start sm:justify-start sm:gap-1 sm:border-l"
|
||||
>
|
||||
<dt class="text-sm font-medium text-muted-foreground sm:text-xs">Downloads</dt>
|
||||
<dd
|
||||
class="text-lg font-medium tabular-nums tracking-tight"
|
||||
aria-label="Downloads: contagem indisponível"
|
||||
>
|
||||
<span aria-hidden="true">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-w-0 items-center justify-between gap-4 p-4 sm:flex-col sm:items-start sm:justify-start sm:gap-1 sm:border-t lg:border-l lg:border-t-0"
|
||||
>
|
||||
<dt class="text-sm font-medium text-muted-foreground sm:text-xs">WhatsApp</dt>
|
||||
<dd
|
||||
class="text-lg font-medium tabular-nums tracking-tight"
|
||||
aria-label="WhatsApp: contagem indisponível"
|
||||
>
|
||||
<span aria-hidden="true">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-w-0 items-center justify-between gap-4 p-4 sm:flex-col sm:items-start sm:justify-start sm:gap-1 sm:border-l sm:border-t lg:border-t-0"
|
||||
>
|
||||
<dt class="text-sm font-medium text-muted-foreground sm:text-xs">Link copiado</dt>
|
||||
<dd
|
||||
class="text-lg font-medium tabular-nums tracking-tight"
|
||||
aria-label="Link copiado: contagem indisponível"
|
||||
>
|
||||
<span aria-hidden="true">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p
|
||||
role="status"
|
||||
class="mt-4 rounded-lg bg-muted p-4 text-sm leading-6 text-muted-foreground"
|
||||
>
|
||||
<b class="text-foreground">Contagem indisponível agora.</b> A página pública, o
|
||||
download e o compartilhamento continuam funcionando.
|
||||
</p>
|
||||
<p class="mt-3 text-xs leading-5 text-muted-foreground">
|
||||
<span class="font-medium text-foreground">Privacidade:</span> sem IP persistido,
|
||||
cookie de identidade ou rastreio entre peças.
|
||||
</p>
|
||||
<div class="mt-6 border-t pt-6">
|
||||
<label class="text-xs font-medium"
|
||||
>Título<input class="field mt-1" value="Fim da escala 6×1 é saúde"
|
||||
/></label>
|
||||
</div>
|
||||
<button class="btn outline mt-5 w-full">Editar peça</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -153,12 +153,16 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
{
|
||||
// S27 — the public Central de Conteúdos: the facet/term catalogue, the
|
||||
// on-demand play, the private-media public route, the vote share sheet and
|
||||
// the kill-switch contract over the C211 listing tag.
|
||||
// the kill-switch contract over the C211 listing tag. C213 adds the
|
||||
// anonymous beacon endpoint, its vocabulary and the event collection the
|
||||
// public page (and the public download) write to.
|
||||
prefixes: [
|
||||
'src/app/(frontend)/conteudos',
|
||||
'src/app/(frontend)/api/content-events',
|
||||
'src/components/conteudos',
|
||||
'src/lib/contentPieceCatalog',
|
||||
'src/lib/contentPieceShare',
|
||||
'src/lib/contentEvents.ts',
|
||||
'src/utilities/content/contentPieceReads.ts',
|
||||
// S27-FOLLOWUP-DRY — the owner of the page's og:image fallback.
|
||||
'src/utilities/ogImageReads.ts',
|
||||
|
|
@ -166,6 +170,7 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
'src/utilities/documents.ts',
|
||||
'src/collections/ContentPiece.ts',
|
||||
'src/collections/ContentMedia.ts',
|
||||
'src/collections/ContentEvent.ts',
|
||||
// The private-media response rules the public route reuses.
|
||||
'src/lib/privateMedia',
|
||||
],
|
||||
|
|
@ -462,6 +467,9 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
'src/components/CopyLinkButton.tsx',
|
||||
// S27-FOLLOWUP-DRY — the owner of the page's og:image fallback.
|
||||
'src/utilities/ogImageReads.ts',
|
||||
// C213 — the copy feedback the button consumes (its success now answers
|
||||
// the share-by-link counter of the piece share sheet).
|
||||
'src/lib/copyFeedback.ts',
|
||||
],
|
||||
specs: ['campaignSpeechCut'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { notFound } from 'next/navigation'
|
|||
import { getPayload } from 'payload'
|
||||
|
||||
import { ContentPieceAttachFileButton } from '@/components/campaign/content/ContentPieceAttachFileButton'
|
||||
import { ContentPieceCirculationPanel } from '@/components/campaign/content/ContentPieceCirculationPanel'
|
||||
import { ContentPieceForm } from '@/components/campaign/content/ContentPieceForm'
|
||||
import { ContentPiecePublicationButton } from '@/components/campaign/content/ContentPiecePublicationButton'
|
||||
import { ContentPieceRetryButton } from '@/components/campaign/content/ContentPieceRetryButton'
|
||||
|
|
@ -192,28 +193,34 @@ export default async function ContentPieceDetailPage({ params }: ContentPieceDet
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<ContentPieceForm
|
||||
piece={{
|
||||
id: piece.id,
|
||||
title: piece.title,
|
||||
description: piece.description,
|
||||
type: piece.type,
|
||||
pieceDate: piece.pieceDate,
|
||||
topics: piece.topics,
|
||||
municipalityId: piece.municipalityId,
|
||||
institution: piece.institution,
|
||||
transcript: piece.transcript,
|
||||
}}
|
||||
municipalityOptions={options.municipalities}
|
||||
formAction={updateContentPieceFormAction}
|
||||
<ContentPieceCirculationPanel
|
||||
circulation={piece.circulation}
|
||||
isPublished={piece.isPublished}
|
||||
/>
|
||||
{piece.fileHref ? (
|
||||
<div className="mt-4">
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<a href={`${piece.fileHref}?download=1`}>Baixar arquivo</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-6 border-t pt-6">
|
||||
<ContentPieceForm
|
||||
piece={{
|
||||
id: piece.id,
|
||||
title: piece.title,
|
||||
description: piece.description,
|
||||
type: piece.type,
|
||||
pieceDate: piece.pieceDate,
|
||||
topics: piece.topics,
|
||||
municipalityId: piece.municipalityId,
|
||||
institution: piece.institution,
|
||||
transcript: piece.transcript,
|
||||
}}
|
||||
municipalityOptions={options.municipalities}
|
||||
formAction={updateContentPieceFormAction}
|
||||
/>
|
||||
{piece.fileHref ? (
|
||||
<div className="mt-4">
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<a href={`${piece.fileHref}?download=1`}>Baixar arquivo</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CampaignPageShell>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export default async function ContentPiecesPage({ searchParams }: ContentPiecesP
|
|||
{ id: 'piece', label: 'Peça', mandatory: true },
|
||||
{ id: 'processing', label: 'Processamento' },
|
||||
{ id: 'publication', label: 'Publicação' },
|
||||
{ id: 'circulation', label: 'Circulação' },
|
||||
{ id: 'action', label: 'Próxima ação', mandatory: true },
|
||||
])
|
||||
|
||||
|
|
|
|||
100
src/app/(frontend)/api/content-events/route.ts
Normal file
100
src/app/(frontend)/api/content-events/route.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { contentEventRequestSchema } from '@/lib/schemas/contentEvent'
|
||||
import {
|
||||
checkContentEventRateLimit,
|
||||
contentEventClientKey,
|
||||
} from '@/utilities/content/contentEventRateLimit'
|
||||
import { recordContentEvent } from '@/utilities/content/contentEventWrite'
|
||||
import { getPublishedContentPieceBySlug } from '@/utilities/content/contentPieceReads'
|
||||
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* C213 — the public anonymous beacon of the Central de Conteúdos. The visitor's
|
||||
* browser fires one event (abertura, download, compartilhar WhatsApp/link) and
|
||||
* forgets it: every response is silent and the client never reads it.
|
||||
*
|
||||
* There is no cookie and no session here, so there is nothing to authorize —
|
||||
* the same-origin check and the in-memory limiter are bar-raisers against a
|
||||
* third-party page or a trivial loop, never a security boundary. The route
|
||||
* writes nothing when the slug does not resolve to a PUBLISHED piece (the
|
||||
* cached public read is the gate — the same one the media route uses), so a
|
||||
* draft or an unknown slug is indistinguishable from a recorded event. Fail
|
||||
* soft end to end: a malformed body, a throttled client, a read failure or a
|
||||
* write failure never produce an error the page has to handle.
|
||||
*/
|
||||
|
||||
const NO_STORE = { 'Cache-Control': 'no-store' } as const
|
||||
|
||||
/** The largest body a beacon can carry — anything bigger is not ours. */
|
||||
const MAX_BODY_BYTES = 4 * 1024
|
||||
|
||||
const silentResponse = (status: number): NextResponse =>
|
||||
new NextResponse(null, { status, headers: NO_STORE })
|
||||
|
||||
/**
|
||||
* Reads the body with a hard byte ceiling, streaming and aborting as soon as
|
||||
* the ceiling is crossed — a chunked body without `content-length` cannot make
|
||||
* the route buffer unbounded input. `null` means "refused".
|
||||
*/
|
||||
const readBoundedBody = async (request: Request): Promise<string | null> => {
|
||||
const body = request.body
|
||||
if (!body) return ''
|
||||
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let text = ''
|
||||
let bytes = 0
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
bytes += value.byteLength
|
||||
if (bytes > MAX_BODY_BYTES) {
|
||||
await reader.cancel().catch(() => undefined)
|
||||
return null
|
||||
}
|
||||
text += decoder.decode(value, { stream: true })
|
||||
}
|
||||
return text + decoder.decode()
|
||||
} catch {
|
||||
await reader.cancel().catch(() => undefined)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = async (request: Request): Promise<NextResponse> => {
|
||||
if (!isSameOriginRequest(request)) return silentResponse(403)
|
||||
|
||||
const rawBody = await readBoundedBody(request)
|
||||
if (rawBody === null) return silentResponse(400)
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = JSON.parse(rawBody)
|
||||
} catch {
|
||||
return silentResponse(400)
|
||||
}
|
||||
|
||||
const parsed = contentEventRequestSchema.safeParse(body)
|
||||
if (!parsed.success) return silentResponse(400)
|
||||
|
||||
if (!checkContentEventRateLimit(contentEventClientKey(request.headers))) {
|
||||
return silentResponse(204)
|
||||
}
|
||||
|
||||
const piece = await getPublishedContentPieceBySlug(parsed.data.pieceSlug).catch(() => null)
|
||||
if (!piece) return silentResponse(204)
|
||||
|
||||
await recordContentEvent({
|
||||
type: parsed.data.type,
|
||||
subjectType: 'peca',
|
||||
subjectId: String(piece.id),
|
||||
})
|
||||
|
||||
return silentResponse(204)
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import config from '@payload-config'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { after, NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { CONTENT_MEDIA_SLUG } from '@/lib/contentPiece'
|
||||
import { contentPieceDownloadFilename } from '@/lib/contentPieceCatalog'
|
||||
import { PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
|
||||
import { recordContentEvent } from '@/utilities/content/contentEventWrite'
|
||||
import { getPublishedContentPieceBySlug } from '@/utilities/content/contentPieceReads'
|
||||
import {
|
||||
buildPrivateMediaResponse,
|
||||
|
|
@ -49,13 +50,33 @@ export const GET = async (
|
|||
.catch(() => null)
|
||||
if (!media?.filename) return notFound()
|
||||
|
||||
return buildPrivateMediaResponse({
|
||||
const download = new URL(request.url).searchParams.get('download') === '1'
|
||||
const rangeHeader = request.headers.get('range')
|
||||
const response = await buildPrivateMediaResponse({
|
||||
media,
|
||||
staticDir: resolvePrivateMediaStaticDir(payload, CONTENT_MEDIA_SLUG),
|
||||
rangeHeader: request.headers.get('range'),
|
||||
download: new URL(request.url).searchParams.get('download') === '1',
|
||||
rangeHeader,
|
||||
download,
|
||||
// The voter downloads `jorge-solla-1313-<slug>.<ext>`, never the internal
|
||||
// upload name; the object key stays `media.filename`.
|
||||
dispositionFilename: contentPieceDownloadFilename(slug, media.filename),
|
||||
})
|
||||
|
||||
// C213 — one download served is one anonymous counter, and only a FULL
|
||||
// `?download=1` request counts: a plain media GET is play/preview, and a
|
||||
// ranged request is a resumed transfer (counting each part would multiply one
|
||||
// download). `after` keeps the write off the response path — a lost count is
|
||||
// acceptable, a slower download is not.
|
||||
if (download && !rangeHeader && response.status === 200) {
|
||||
const pieceId = piece.id
|
||||
after(async () => {
|
||||
await recordContentEvent({
|
||||
type: 'download',
|
||||
subjectType: 'peca',
|
||||
subjectId: String(pieceId),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
|
|
|||
90
src/collections/ContentEvent.ts
Normal file
90
src/collections/ContentEvent.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import {
|
||||
CONTENT_EVENT_SUBJECT_TYPES,
|
||||
CONTENT_EVENT_TYPES,
|
||||
contentEventSubjectTypeLabels,
|
||||
contentEventTypeLabels,
|
||||
} from '@/lib/contentEvents'
|
||||
import { payloadAdminOnly } from '@/utilities/access/shared'
|
||||
import { canReadContentPiece } from '@/utilities/campaignAccess'
|
||||
|
||||
/**
|
||||
* C213 — one anonymous engagement event of the Central de Conteúdos (and, by
|
||||
* S32, of the personalized cards: the card is another `subjectType` of this
|
||||
* same mechanism, never a second collection).
|
||||
*
|
||||
* The row carries the event type and the subject — `subjectType` + `subjectId`
|
||||
* (the internal piece id today; the card model id later) — and nothing else:
|
||||
* no IP, no cookie, no user-agent, no session, no visitor identity. Counting is
|
||||
* best-effort (fail-soft): the public page, the download and the share never
|
||||
* depend on a row landing here.
|
||||
*
|
||||
* There is deliberately NO hook: a write here must never bust the public
|
||||
* `contentPieces` listing tag (a counter is not content). Rows are written only
|
||||
* through the Local API with an intentional admin bypass
|
||||
* (`overrideAccess: true`) — the public `POST /api/content-events` (validated,
|
||||
* same-origin, rate-limited) and the public media route's `?download=1` — so no
|
||||
* access predicate grants a create.
|
||||
* Hidden from the admin nav: the surface is the Central list, not a dashboard.
|
||||
*/
|
||||
export const ContentEvent: CollectionConfig = {
|
||||
slug: 'contentEvent',
|
||||
labels: {
|
||||
singular: 'Evento de conteúdo',
|
||||
plural: 'Eventos de conteúdo',
|
||||
},
|
||||
admin: {
|
||||
group: 'Comunicação',
|
||||
hidden: () => true,
|
||||
useAsTitle: 'type',
|
||||
defaultColumns: ['type', 'subjectType', 'subjectId', 'createdAt'],
|
||||
description: 'Eventos anônimos de circulação (abertura, download, compartilhamento).',
|
||||
},
|
||||
access: {
|
||||
create: () => false,
|
||||
read: canReadContentPiece,
|
||||
update: () => false,
|
||||
delete: payloadAdminOnly,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'type',
|
||||
type: 'select',
|
||||
label: 'Evento',
|
||||
required: true,
|
||||
options: CONTENT_EVENT_TYPES.map((value) => ({
|
||||
value,
|
||||
label: contentEventTypeLabels[value],
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: 'subjectType',
|
||||
type: 'select',
|
||||
label: 'Assunto',
|
||||
required: true,
|
||||
options: CONTENT_EVENT_SUBJECT_TYPES.map((value) => ({
|
||||
value,
|
||||
label: contentEventSubjectTypeLabels[value],
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: 'subjectId',
|
||||
type: 'text',
|
||||
label: 'Chave do assunto',
|
||||
required: true,
|
||||
admin: {
|
||||
description:
|
||||
'Id interno da peça (peça) ou do modelo (card). Nunca um identificador de visitante.',
|
||||
},
|
||||
},
|
||||
],
|
||||
// ONE compound index and no per-field ones: every read filters by the whole
|
||||
// prefix (`subject_type` + `subject_id` + the grouped `type`), and a
|
||||
// write-heavy event table should not carry an index per column.
|
||||
indexes: [
|
||||
{
|
||||
fields: ['subjectType', 'subjectId', 'type'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -11,6 +11,12 @@ type CopyLinkButtonProps = {
|
|||
url: string
|
||||
variant?: 'default' | 'outline' | 'ghost'
|
||||
className?: string
|
||||
/**
|
||||
* Called only when the text actually landed in the clipboard (C213 counts the
|
||||
* share-by-link of a piece). The control itself never counts: it is shared
|
||||
* with the cut surfaces.
|
||||
*/
|
||||
onCopied?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -19,7 +25,12 @@ type CopyLinkButtonProps = {
|
|||
* A relative path is resolved against the current origin on click, so a server
|
||||
* component can pass `publicPath` directly.
|
||||
*/
|
||||
export const CopyLinkButton = ({ url, variant = 'outline', className }: CopyLinkButtonProps) => {
|
||||
export const CopyLinkButton = ({
|
||||
url,
|
||||
variant = 'outline',
|
||||
className,
|
||||
onCopied,
|
||||
}: CopyLinkButtonProps) => {
|
||||
const { feedback, copy } = useCopyFeedback()
|
||||
|
||||
return (
|
||||
|
|
@ -29,7 +40,9 @@ export const CopyLinkButton = ({ url, variant = 'outline', className }: CopyLink
|
|||
variant={variant}
|
||||
className={cn('min-h-10', className)}
|
||||
onClick={() => {
|
||||
void copy(new URL(url, window.location.origin).toString())
|
||||
void copy(new URL(url, window.location.origin).toString()).then((copied) => {
|
||||
if (copied) onCopied?.()
|
||||
})
|
||||
}}
|
||||
>
|
||||
{feedback === 'copied' ? (
|
||||
|
|
|
|||
|
|
@ -1,23 +1,26 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { ContentPieceCirculationCounters } from '@/components/campaign/content/ContentPieceCirculationCounters'
|
||||
import { ContentPieceRetryButton } from '@/components/campaign/content/ContentPieceRetryButton'
|
||||
import {
|
||||
ContentPieceProcessingBadge,
|
||||
ContentPiecePublicationBadge,
|
||||
} from '@/components/campaign/content/ContentPieceStatusBadge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { contentPieceStepLabels, type ContentPieceViewModel } from '@/lib/contentPiece'
|
||||
import { contentPieceStepLabels } from '@/lib/contentPiece'
|
||||
import type { ContentPieceRowViewModel } from '@/lib/contentPieceCirculation'
|
||||
|
||||
/**
|
||||
* C211 — the Central list on mobile (approved design scenes 5): one card per
|
||||
* piece with the title + processing state, the type/publication line and the
|
||||
* single "Abrir" (or "Reprocessar" when it failed).
|
||||
* single "Abrir" (or "Reprocessar" when it failed). C213 adds the piece's
|
||||
* circulation counters (2×2 grid) right above the action.
|
||||
*/
|
||||
export const ContentPieceCardList = ({
|
||||
rows,
|
||||
empty,
|
||||
}: {
|
||||
rows: readonly ContentPieceViewModel[]
|
||||
rows: readonly ContentPieceRowViewModel[]
|
||||
empty?: React.ReactNode
|
||||
}) => (
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
|
|
@ -51,6 +54,12 @@ export const ContentPieceCardList = ({
|
|||
<p className="mt-2 text-xs text-red-800">{piece.failureMessage}</p>
|
||||
) : null}
|
||||
|
||||
<ContentPieceCirculationCounters
|
||||
circulation={piece.circulation}
|
||||
isPublished={piece.isPublished}
|
||||
layout="card"
|
||||
/>
|
||||
|
||||
{piece.canRetry ? (
|
||||
<ContentPieceRetryButton contentPieceId={piece.id} className="mt-3" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import {
|
||||
CONTENT_PIECE_CIRCULATION_HISTORY_LABEL,
|
||||
CONTENT_PIECE_CIRCULATION_METRICS,
|
||||
CONTENT_PIECE_CIRCULATION_NEVER_PUBLISHED_LABEL,
|
||||
CONTENT_PIECE_CIRCULATION_UNAVAILABLE_LABEL,
|
||||
CONTENT_PIECE_CIRCULATION_UNAVAILABLE_VALUE,
|
||||
CONTENT_PIECE_CIRCULATION_ZERO_LABEL,
|
||||
isContentPieceCirculationEmpty,
|
||||
showsContentPieceCirculationHistory,
|
||||
type ContentPieceCirculationView,
|
||||
} from '@/lib/contentPieceCirculation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* C213 — the four anonymous counters of one piece in the internal LIST (table
|
||||
* cell) and in the mobile card, ported class-a-class from the approved design:
|
||||
* with data they are a labelled grid (4-up in the table, 2×2 in the card); the
|
||||
* all-zero piece collapses into the singular sentence; a piece that never
|
||||
* entered the public Central says so; and a piece unpublished after circulating
|
||||
* keeps its history with the honest subline.
|
||||
*/
|
||||
|
||||
const layoutClasses = {
|
||||
list: {
|
||||
grid: 'grid grid-cols-4 gap-4',
|
||||
cell: '',
|
||||
label: 'block text-[10px] text-muted-foreground',
|
||||
value: 'mt-1 block text-sm font-semibold tabular-nums',
|
||||
},
|
||||
card: {
|
||||
grid: 'mt-4 grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-border',
|
||||
cell: 'bg-card p-3',
|
||||
label: 'text-[10px] text-muted-foreground',
|
||||
value: 'mt-1 block text-sm font-semibold tabular-nums',
|
||||
},
|
||||
} as const
|
||||
|
||||
export const ContentPieceCirculationCounters = ({
|
||||
circulation,
|
||||
isPublished,
|
||||
layout,
|
||||
}: {
|
||||
circulation: ContentPieceCirculationView
|
||||
isPublished: boolean
|
||||
layout: 'list' | 'card'
|
||||
}) => {
|
||||
const card = layout === 'card'
|
||||
const classes = layoutClasses[layout]
|
||||
|
||||
if (circulation.state === 'unavailable') {
|
||||
return (
|
||||
<p className={cn('text-sm font-semibold text-muted-foreground tabular-nums', card && 'mt-3')}>
|
||||
<span className="sr-only">{CONTENT_PIECE_CIRCULATION_UNAVAILABLE_LABEL}</span>
|
||||
<span aria-hidden="true">{CONTENT_PIECE_CIRCULATION_UNAVAILABLE_VALUE}</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (circulation.state === 'neverPublished') {
|
||||
return (
|
||||
<p className={cn('text-xs text-muted-foreground', card && 'mt-3')}>
|
||||
{CONTENT_PIECE_CIRCULATION_NEVER_PUBLISHED_LABEL}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (isContentPieceCirculationEmpty(circulation.counts)) {
|
||||
return (
|
||||
<p className={cn(card ? 'mt-3 text-xs text-muted-foreground' : 'font-semibold tabular-nums')}>
|
||||
{CONTENT_PIECE_CIRCULATION_ZERO_LABEL}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.grid}>
|
||||
{CONTENT_PIECE_CIRCULATION_METRICS.map((metric) => (
|
||||
<div key={metric.key} className={classes.cell}>
|
||||
<span className={classes.label}>{metric.label}</span>
|
||||
<span className={classes.value}>{circulation.counts[metric.key]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showsContentPieceCirculationHistory(circulation, isPublished) ? (
|
||||
<p className={cn('text-[10px] text-muted-foreground', card ? 'mt-2' : 'mt-1.5')}>
|
||||
{CONTENT_PIECE_CIRCULATION_HISTORY_LABEL}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
134
src/components/campaign/content/ContentPieceCirculationPanel.tsx
Normal file
134
src/components/campaign/content/ContentPieceCirculationPanel.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
CONTENT_PIECE_CIRCULATION_DETAIL_INTRO,
|
||||
CONTENT_PIECE_CIRCULATION_HISTORY_BODY,
|
||||
CONTENT_PIECE_CIRCULATION_HISTORY_TITLE,
|
||||
CONTENT_PIECE_CIRCULATION_METRICS,
|
||||
CONTENT_PIECE_CIRCULATION_NEVER_PUBLISHED_BODY,
|
||||
CONTENT_PIECE_CIRCULATION_NEVER_PUBLISHED_TITLE,
|
||||
CONTENT_PIECE_CIRCULATION_PRIVACY,
|
||||
CONTENT_PIECE_CIRCULATION_READING,
|
||||
CONTENT_PIECE_CIRCULATION_UNAVAILABLE_BODY,
|
||||
CONTENT_PIECE_CIRCULATION_UNAVAILABLE_TITLE,
|
||||
CONTENT_PIECE_CIRCULATION_UNAVAILABLE_VALUE,
|
||||
CONTENT_PIECE_CIRCULATION_UNAVAILABLE_VALUE_LABEL,
|
||||
isContentPieceCirculationEmpty,
|
||||
showsContentPieceCirculationHistory,
|
||||
type ContentPieceCirculationMetric,
|
||||
type ContentPieceCirculationView,
|
||||
} from '@/lib/contentPieceCirculation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* C213 — the "Circulação" block of the piece ficha (approved design scenes
|
||||
* 02/05/06/07): four absolute counters in the ported responsive grid, then the
|
||||
* honest state — never published, historical after unpublishing, or counting
|
||||
* unavailable — then "Leitura:" (only when there are counters to compare) and
|
||||
* the privacy line. The form follows below.
|
||||
*/
|
||||
|
||||
const noteClasses = 'rounded-lg bg-muted text-muted-foreground'
|
||||
const notePaddingClasses = 'p-3 text-xs leading-5 sm:p-4 sm:text-sm sm:leading-6'
|
||||
|
||||
/** Cells 2–4 carry the grid's separators (design's port contract). */
|
||||
const cellDividerClasses = [
|
||||
undefined,
|
||||
'sm:border-l',
|
||||
'sm:border-t lg:border-l lg:border-t-0',
|
||||
'sm:border-l sm:border-t lg:border-t-0',
|
||||
] as const
|
||||
|
||||
const metricStrip = (
|
||||
renderValue: (metric: ContentPieceCirculationMetric) => ReactNode,
|
||||
): ReactNode => (
|
||||
<dl className="mt-5 grid grid-cols-1 divide-y overflow-hidden rounded-xl border sm:grid-cols-2 sm:divide-y-0 lg:grid-cols-4">
|
||||
{CONTENT_PIECE_CIRCULATION_METRICS.map((metric, index) => (
|
||||
<div
|
||||
key={metric.key}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center justify-between gap-4 p-4 sm:flex-col sm:items-start sm:justify-start sm:gap-1',
|
||||
cellDividerClasses[index],
|
||||
)}
|
||||
>
|
||||
<dt className="text-sm font-medium text-muted-foreground sm:text-xs">
|
||||
{metric.detailLabel}
|
||||
</dt>
|
||||
{renderValue(metric)}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
|
||||
const unavailableStrip = metricStrip((metric) => (
|
||||
<dd
|
||||
className="text-lg font-medium tabular-nums tracking-tight"
|
||||
aria-label={`${metric.detailLabel}: ${CONTENT_PIECE_CIRCULATION_UNAVAILABLE_VALUE_LABEL}`}
|
||||
>
|
||||
<span aria-hidden="true">{CONTENT_PIECE_CIRCULATION_UNAVAILABLE_VALUE}</span>
|
||||
</dd>
|
||||
))
|
||||
|
||||
const countersStrip = (view: Extract<ContentPieceCirculationView, { state: 'data' }>) =>
|
||||
metricStrip((metric) => (
|
||||
<dd className="text-lg font-medium tabular-nums tracking-tight">{view.counts[metric.key]}</dd>
|
||||
))
|
||||
|
||||
export const ContentPieceCirculationPanel = ({
|
||||
circulation,
|
||||
isPublished,
|
||||
}: {
|
||||
circulation: ContentPieceCirculationView
|
||||
isPublished: boolean
|
||||
}) => {
|
||||
const hasCounters =
|
||||
circulation.state === 'data' && !isContentPieceCirculationEmpty(circulation.counts)
|
||||
|
||||
return (
|
||||
<section aria-label="Circulação da peça">
|
||||
<h3 className="text-base font-semibold">Circulação</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{CONTENT_PIECE_CIRCULATION_DETAIL_INTRO}</p>
|
||||
|
||||
{circulation.state === 'unavailable' ? (
|
||||
<>
|
||||
{unavailableStrip}
|
||||
<p role="status" className={cn('mt-4', notePaddingClasses, noteClasses)}>
|
||||
<b className="text-foreground">{CONTENT_PIECE_CIRCULATION_UNAVAILABLE_TITLE}</b>{' '}
|
||||
{CONTENT_PIECE_CIRCULATION_UNAVAILABLE_BODY}
|
||||
</p>
|
||||
</>
|
||||
) : circulation.state === 'neverPublished' ? (
|
||||
<div className="mt-5 rounded-xl border bg-muted/40 p-5">
|
||||
<p className="text-sm font-semibold">{CONTENT_PIECE_CIRCULATION_NEVER_PUBLISHED_TITLE}</p>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
{CONTENT_PIECE_CIRCULATION_NEVER_PUBLISHED_BODY}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{countersStrip(circulation)}
|
||||
{showsContentPieceCirculationHistory(circulation, isPublished) ? (
|
||||
<p className={cn('mt-4', notePaddingClasses, noteClasses)}>
|
||||
<b className="text-foreground">{CONTENT_PIECE_CIRCULATION_HISTORY_TITLE}</b>{' '}
|
||||
{CONTENT_PIECE_CIRCULATION_HISTORY_BODY}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasCounters ? (
|
||||
<aside
|
||||
aria-label="Como ler os contadores"
|
||||
className={cn('mt-5', notePaddingClasses, noteClasses)}
|
||||
>
|
||||
<b className="text-foreground">Leitura:</b> {CONTENT_PIECE_CIRCULATION_READING}
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<p className="mt-3 text-xs leading-5 text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Privacidade:</span>{' '}
|
||||
{CONTENT_PIECE_CIRCULATION_PRIVACY}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { FilmIcon, ImageIcon, LinkIcon, MicIcon, TypeIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { ContentPieceCirculationCounters } from '@/components/campaign/content/ContentPieceCirculationCounters'
|
||||
import { ContentPieceRetryButton } from '@/components/campaign/content/ContentPieceRetryButton'
|
||||
import {
|
||||
ContentPieceProcessingBadge,
|
||||
|
|
@ -14,6 +15,7 @@ import {
|
|||
type ContentPieceType,
|
||||
type ContentPieceViewModel,
|
||||
} from '@/lib/contentPiece'
|
||||
import type { ContentPieceRowViewModel } from '@/lib/contentPieceCirculation'
|
||||
|
||||
const TYPE_ICON: Record<ContentPieceType, typeof FilmIcon> = {
|
||||
video: FilmIcon,
|
||||
|
|
@ -38,12 +40,16 @@ export const ContentPieceTable = ({
|
|||
columnVisibility,
|
||||
empty,
|
||||
}: {
|
||||
rows: readonly ContentPieceViewModel[]
|
||||
rows: readonly ContentPieceRowViewModel[]
|
||||
columnVisibility?: CampaignColumnVisibility
|
||||
empty?: React.ReactNode
|
||||
}) => (
|
||||
// C213 (design critique): from xl up the five columns share the width by
|
||||
// fixed percentages, so the mandatory "Próxima ação" is never pushed out of
|
||||
// frame by the new "Circulação" column. Below xl the table keeps its natural
|
||||
// layout and the shell's horizontal scroll (unchanged behavior).
|
||||
<CampaignTable
|
||||
className="hidden md:block"
|
||||
className="hidden md:block xl:[&_table]:table-fixed"
|
||||
caption="Uma linha por peça da Central de Conteúdos. Rascunhos não aparecem na Central pública."
|
||||
columnVisibility={columnVisibility}
|
||||
rows={rows}
|
||||
|
|
@ -54,7 +60,8 @@ export const ContentPieceTable = ({
|
|||
id: 'piece',
|
||||
label: 'Peça',
|
||||
mandatory: true,
|
||||
head: <CampaignTableHead>Peça</CampaignTableHead>,
|
||||
head: <CampaignTableHead className="xl:w-[25%]">Peça</CampaignTableHead>,
|
||||
cellClassName: 'xl:whitespace-normal',
|
||||
cell: (piece) => {
|
||||
const Icon = TYPE_ICON[piece.type]
|
||||
return (
|
||||
|
|
@ -81,6 +88,7 @@ export const ContentPieceTable = ({
|
|||
{
|
||||
id: 'processing',
|
||||
label: 'Processamento',
|
||||
head: <CampaignTableHead className="xl:w-[13%]">Processamento</CampaignTableHead>,
|
||||
cell: (piece) => (
|
||||
<div>
|
||||
<ContentPieceProcessingBadge status={piece.processingStatus} />
|
||||
|
|
@ -97,13 +105,30 @@ export const ContentPieceTable = ({
|
|||
{
|
||||
id: 'publication',
|
||||
label: 'Publicação',
|
||||
head: <CampaignTableHead className="xl:w-[11%]">Publicação</CampaignTableHead>,
|
||||
cell: (piece) => <ContentPiecePublicationBadge status={piece.status} />,
|
||||
},
|
||||
{
|
||||
id: 'circulation',
|
||||
label: 'Circulação',
|
||||
head: <CampaignTableHead className="xl:w-[39%]">Circulação</CampaignTableHead>,
|
||||
cell: (piece) => (
|
||||
<ContentPieceCirculationCounters
|
||||
circulation={piece.circulation}
|
||||
isPublished={piece.isPublished}
|
||||
layout="list"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'action',
|
||||
label: 'Próxima ação',
|
||||
mandatory: true,
|
||||
head: <CampaignTableHead align="right">Próxima ação</CampaignTableHead>,
|
||||
head: (
|
||||
<CampaignTableHead align="right" className="xl:w-[12%]">
|
||||
Próxima ação
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: 'text-right',
|
||||
cell: (piece) =>
|
||||
piece.canRetry ? (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { sendContentPieceEvent } from '@/lib/contentEvents'
|
||||
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -27,6 +28,16 @@ const originOrDurationFact = (item: ContentPiecePublicItem): Fact | null => {
|
|||
export const ContentPieceDetail = ({ item }: { item: ContentPiecePublicItem }) => {
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [sharing, setSharing] = useState(false)
|
||||
// C213 — one open is one anonymous counter. The ref keeps the dev
|
||||
// StrictMode's double effect from counting twice, and a client-side return
|
||||
// to the page mounts a fresh component (a real second open).
|
||||
const trackedOpen = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedOpen.current) return
|
||||
trackedOpen.current = true
|
||||
sendContentPieceEvent('abertura', item.slug)
|
||||
}, [item.slug])
|
||||
|
||||
const facts = [
|
||||
item.topicLabels.length > 0 ? { label: 'Tema', value: item.topicLabels.join(', ') } : null,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { createPortal } from 'react-dom'
|
|||
|
||||
import { CopyLinkButton } from '@/components/CopyLinkButton'
|
||||
import { WhatsAppIcon } from '@/components/socialIcons'
|
||||
import { sendContentPieceEvent } from '@/lib/contentEvents'
|
||||
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
|
||||
import {
|
||||
buildContentPieceWhatsAppUrl,
|
||||
|
|
@ -156,6 +157,7 @@ export const ContentPieceShareSheet = ({
|
|||
href={buildContentPieceWhatsAppUrl(message)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => sendContentPieceEvent('compartilhar_whatsapp', item.slug)}
|
||||
className={`${CONTENT_PIECE_PRIMARY_BUTTON} mt-3 w-full`}
|
||||
>
|
||||
<WhatsAppIcon className="size-4" />
|
||||
|
|
@ -173,7 +175,11 @@ export const ContentPieceShareSheet = ({
|
|||
{preparing ? 'Preparando…' : 'A mídia'}
|
||||
</button>
|
||||
) : null}
|
||||
<CopyLinkButton url={shareLink} className={CONTENT_PIECE_OUTLINE_BUTTON} />
|
||||
<CopyLinkButton
|
||||
url={shareLink}
|
||||
className={CONTENT_PIECE_OUTLINE_BUTTON}
|
||||
onCopied={() => sendContentPieceEvent('compartilhar_link', item.slug)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-center text-[11px] leading-4 text-(--campaign-muted)">
|
||||
|
|
|
|||
99
src/lib/contentEvents.ts
Normal file
99
src/lib/contentEvents.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* C213 — the anonymous content-event vocabulary and the public beacon.
|
||||
*
|
||||
* One mechanism for every content subject: `subjectType` says what the event
|
||||
* happened to (`peca` today; `card` is the additive extension of S32) and
|
||||
* `subjectId` is that subject's stable key. The event carries NOTHING about the
|
||||
* visitor — no IP, no cookie, no user-agent, no session — and the beacon below
|
||||
* is fire-and-forget by contract: it never throws, never blocks and never
|
||||
* delays the public page, the download or the share (fail-soft).
|
||||
*
|
||||
* Pure and client-safe: the public route's zod schema and the client
|
||||
* components import the same vocabulary, so a new event type is one edit.
|
||||
*/
|
||||
|
||||
export const CONTENT_EVENT_ENDPOINT = '/api/content-events'
|
||||
|
||||
export const CONTENT_EVENT_TYPES = [
|
||||
'abertura',
|
||||
'download',
|
||||
'compartilhar_whatsapp',
|
||||
'compartilhar_link',
|
||||
] as const
|
||||
|
||||
export type ContentEventType = (typeof CONTENT_EVENT_TYPES)[number]
|
||||
|
||||
/** Admin/REST labels only — the public surfaces own their own copy. */
|
||||
export const contentEventTypeLabels: Record<ContentEventType, string> = {
|
||||
abertura: 'Abertura da peça',
|
||||
download: 'Download',
|
||||
compartilhar_whatsapp: 'Clique em compartilhar (WhatsApp)',
|
||||
compartilhar_link: 'Clique em compartilhar (link)',
|
||||
}
|
||||
|
||||
export const isContentEventType = (value: unknown): value is ContentEventType =>
|
||||
typeof value === 'string' && (CONTENT_EVENT_TYPES as readonly string[]).includes(value)
|
||||
|
||||
export const CONTENT_EVENT_SUBJECT_TYPES = ['peca', 'card'] as const
|
||||
|
||||
export type ContentEventSubjectType = (typeof CONTENT_EVENT_SUBJECT_TYPES)[number]
|
||||
|
||||
export const contentEventSubjectTypeLabels: Record<ContentEventSubjectType, string> = {
|
||||
peca: 'Peça',
|
||||
card: 'Card',
|
||||
}
|
||||
|
||||
export const isContentEventSubjectType = (value: unknown): value is ContentEventSubjectType =>
|
||||
typeof value === 'string' && (CONTENT_EVENT_SUBJECT_TYPES as readonly string[]).includes(value)
|
||||
|
||||
/**
|
||||
* One grouped row of the anonymous-event aggregate, as the SQL reader returns
|
||||
* it and any subject-specific mapper consumes it. Generic on purpose: the same
|
||||
* shape serves the piece counters today and the card counters of S32.
|
||||
*/
|
||||
export type ContentEventAggregateRow = {
|
||||
subjectId: string
|
||||
type: string
|
||||
count: number
|
||||
}
|
||||
|
||||
type ContentEventBeaconBody = {
|
||||
type: ContentEventType
|
||||
pieceSlug: string
|
||||
}
|
||||
|
||||
const beaconBody = ({ type, pieceSlug }: ContentEventBeaconBody): string =>
|
||||
JSON.stringify({ type, pieceSlug })
|
||||
|
||||
/**
|
||||
* Sends one anonymous piece event and forgets it. `sendBeacon` survives the
|
||||
* navigation a click is about to trigger (the WhatsApp tab, the download);
|
||||
* `fetch` with `keepalive` is the fallback for browsers without it. Every
|
||||
* branch is swallowed: a lost count is acceptable, a broken action is not.
|
||||
*/
|
||||
export const sendContentPieceEvent = (type: ContentEventType, pieceSlug: string): void => {
|
||||
const body = beaconBody({ type, pieceSlug })
|
||||
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
||||
const sent = navigator.sendBeacon(
|
||||
CONTENT_EVENT_ENDPOINT,
|
||||
new Blob([body], { type: 'application/json' }),
|
||||
)
|
||||
if (sent) return
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the fetch fallback.
|
||||
}
|
||||
|
||||
try {
|
||||
void fetch(CONTENT_EVENT_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
keepalive: true,
|
||||
}).catch(() => undefined)
|
||||
} catch {
|
||||
// Fail-soft: the public action never depends on the count.
|
||||
}
|
||||
}
|
||||
145
src/lib/contentPieceCirculation.ts
Normal file
145
src/lib/contentPieceCirculation.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* C213 — the four circulation counters of a piece and the honest states they
|
||||
* render in. Pure and client-safe: the internal list/detail loaders resolve the
|
||||
* raw aggregate rows into these shapes and the RSC surfaces render them; the
|
||||
* rules (precedence, literals, labels) live here, once.
|
||||
*
|
||||
* The counters are absolute, anonymous and never summed: Aberturas, Downloads,
|
||||
* WhatsApp and Link measure different choices, and a piece that has been
|
||||
* unpublished keeps its history (the numbers happened while it was public).
|
||||
*/
|
||||
import type { ContentEventAggregateRow, ContentEventType } from '@/lib/contentEvents'
|
||||
import type { ContentPieceViewModel } from '@/lib/contentPiece'
|
||||
|
||||
export type ContentPieceCirculationCounts = {
|
||||
opens: number
|
||||
downloads: number
|
||||
whatsapp: number
|
||||
link: number
|
||||
}
|
||||
|
||||
export type ContentPieceCirculationMetric = {
|
||||
key: keyof ContentPieceCirculationCounts
|
||||
eventType: ContentEventType
|
||||
/** Table/card label — "Link" is the short form. */
|
||||
label: string
|
||||
/** Detail label — "Link copiado" names the action the counter measures. */
|
||||
detailLabel: string
|
||||
}
|
||||
|
||||
export const CONTENT_PIECE_CIRCULATION_METRICS: readonly ContentPieceCirculationMetric[] = [
|
||||
{ key: 'opens', eventType: 'abertura', label: 'Aberturas', detailLabel: 'Aberturas' },
|
||||
{ key: 'downloads', eventType: 'download', label: 'Downloads', detailLabel: 'Downloads' },
|
||||
{
|
||||
key: 'whatsapp',
|
||||
eventType: 'compartilhar_whatsapp',
|
||||
label: 'WhatsApp',
|
||||
detailLabel: 'WhatsApp',
|
||||
},
|
||||
{ key: 'link', eventType: 'compartilhar_link', label: 'Link', detailLabel: 'Link copiado' },
|
||||
]
|
||||
|
||||
export const EMPTY_CONTENT_PIECE_CIRCULATION: ContentPieceCirculationCounts = Object.freeze({
|
||||
opens: 0,
|
||||
downloads: 0,
|
||||
whatsapp: 0,
|
||||
link: 0,
|
||||
})
|
||||
|
||||
export const isContentPieceCirculationEmpty = (counts: ContentPieceCirculationCounts): boolean =>
|
||||
CONTENT_PIECE_CIRCULATION_METRICS.every((metric) => counts[metric.key] <= 0)
|
||||
|
||||
/**
|
||||
* What one surface renders for one piece:
|
||||
* - `data` — the four counters (zeros included: the piece was published);
|
||||
* - `neverPublished` — no counters to show, the piece never entered the public
|
||||
* Central;
|
||||
* - `unavailable` — the count could not be read right now (the design's "—").
|
||||
*/
|
||||
export type ContentPieceCirculationView =
|
||||
| { state: 'data'; counts: ContentPieceCirculationCounts }
|
||||
| { state: 'neverPublished' }
|
||||
| { state: 'unavailable' }
|
||||
|
||||
export type ContentPieceRowViewModel = ContentPieceViewModel & {
|
||||
circulation: ContentPieceCirculationView
|
||||
}
|
||||
|
||||
/**
|
||||
* Precedence is the honesty rule: a failed read says "unavailable"; real
|
||||
* counters win over everything (an unpublished piece keeps its history); a
|
||||
* published piece with no events shows the zero literal; and only a piece that
|
||||
* never had a `publishedAt` says "ainda não publicada".
|
||||
*/
|
||||
export const resolveContentPieceCirculation = ({
|
||||
counts,
|
||||
hasBeenPublished,
|
||||
unavailable,
|
||||
}: {
|
||||
counts?: ContentPieceCirculationCounts
|
||||
hasBeenPublished: boolean
|
||||
unavailable: boolean
|
||||
}): ContentPieceCirculationView => {
|
||||
if (unavailable) return { state: 'unavailable' }
|
||||
if (counts && !isContentPieceCirculationEmpty(counts)) return { state: 'data', counts }
|
||||
if (hasBeenPublished) return { state: 'data', counts: counts ?? EMPTY_CONTENT_PIECE_CIRCULATION }
|
||||
return { state: 'neverPublished' }
|
||||
}
|
||||
|
||||
/** Groups the aggregate rows into per-piece counters, ignoring unknown types. */
|
||||
export const contentPieceCirculationFromRows = (
|
||||
rows: readonly ContentEventAggregateRow[],
|
||||
): Map<number, ContentPieceCirculationCounts> => {
|
||||
const byPieceId = new Map<number, ContentPieceCirculationCounts>()
|
||||
|
||||
for (const row of rows) {
|
||||
const pieceId = Number(row.subjectId)
|
||||
if (!Number.isSafeInteger(pieceId) || pieceId <= 0) continue
|
||||
|
||||
const metric = CONTENT_PIECE_CIRCULATION_METRICS.find(
|
||||
(candidate) => candidate.eventType === row.type,
|
||||
)
|
||||
if (!metric) continue
|
||||
|
||||
const counts = byPieceId.get(pieceId) ?? { ...EMPTY_CONTENT_PIECE_CIRCULATION }
|
||||
const value = Number(row.count)
|
||||
counts[metric.key] = Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0
|
||||
byPieceId.set(pieceId, counts)
|
||||
}
|
||||
|
||||
return byPieceId
|
||||
}
|
||||
|
||||
/** True when the piece is off the air but the counters are its public history. */
|
||||
export const showsContentPieceCirculationHistory = (
|
||||
view: ContentPieceCirculationView,
|
||||
isPublished: boolean,
|
||||
): boolean => view.state === 'data' && !isPublished && !isContentPieceCirculationEmpty(view.counts)
|
||||
|
||||
export const CONTENT_PIECE_CIRCULATION_UNAVAILABLE_VALUE = '—'
|
||||
export const CONTENT_PIECE_CIRCULATION_UNAVAILABLE_LABEL = 'Contagem indisponível'
|
||||
export const CONTENT_PIECE_CIRCULATION_UNAVAILABLE_VALUE_LABEL = 'contagem indisponível'
|
||||
|
||||
/** The all-zero sentence of the list/card (zero takes the singular in pt-BR). */
|
||||
export const CONTENT_PIECE_CIRCULATION_ZERO_LABEL = '0 abertura · 0 download · 0 WhatsApp · 0 link'
|
||||
|
||||
export const CONTENT_PIECE_CIRCULATION_NEVER_PUBLISHED_LABEL = 'Sem dados — ainda não publicada'
|
||||
export const CONTENT_PIECE_CIRCULATION_NEVER_PUBLISHED_TITLE =
|
||||
'Sem dados — esta peça ainda não foi publicada.'
|
||||
export const CONTENT_PIECE_CIRCULATION_NEVER_PUBLISHED_BODY =
|
||||
'A contagem começa quando ela entrar na Central pública.'
|
||||
|
||||
export const CONTENT_PIECE_CIRCULATION_HISTORY_LABEL =
|
||||
'Histórico de quando esteve publicada · peça fora do ar'
|
||||
export const CONTENT_PIECE_CIRCULATION_HISTORY_TITLE = 'Histórico de quando esteve publicada.'
|
||||
export const CONTENT_PIECE_CIRCULATION_HISTORY_BODY = 'A peça está fora do ar.'
|
||||
|
||||
export const CONTENT_PIECE_CIRCULATION_DETAIL_INTRO =
|
||||
'Contagem anônima e acumulada desde a publicação.'
|
||||
export const CONTENT_PIECE_CIRCULATION_READING =
|
||||
'compare os sinais sem somá-los. WhatsApp e link ficam separados porque mostram escolhas diferentes.'
|
||||
export const CONTENT_PIECE_CIRCULATION_UNAVAILABLE_TITLE = 'Contagem indisponível agora.'
|
||||
export const CONTENT_PIECE_CIRCULATION_UNAVAILABLE_BODY =
|
||||
'A página pública, o download e o compartilhamento continuam funcionando.'
|
||||
export const CONTENT_PIECE_CIRCULATION_PRIVACY =
|
||||
'sem IP persistido, cookie de identidade ou rastreio entre peças.'
|
||||
|
|
@ -52,7 +52,8 @@ export const copyFeedbackLiveMessages = copyFeedbackLiveMessagesBySubject.link
|
|||
* The one copy-to-clipboard feedback contract of the share controls (C166 share
|
||||
* sheet, C167 cut share kit): the label per state, the screen-reader live
|
||||
* message and the auto-reset. `copy` never throws — a denied clipboard is the
|
||||
* `error` state.
|
||||
* `error` state — and answers whether the text landed, so a caller that counts
|
||||
* the action (C213's share-by-link) only counts a real copy.
|
||||
*/
|
||||
export const useCopyFeedback = () => {
|
||||
const [feedback, setFeedback] = useState<CopyFeedback>('idle')
|
||||
|
|
@ -63,12 +64,14 @@ export const useCopyFeedback = () => {
|
|||
return () => clearTimeout(reset)
|
||||
}, [feedback])
|
||||
|
||||
const copy = async (text: string): Promise<void> => {
|
||||
const copy = async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setFeedback('copied')
|
||||
return true
|
||||
} catch {
|
||||
setFeedback('error')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
26
src/lib/schemas/contentEvent.ts
Normal file
26
src/lib/schemas/contentEvent.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { CONTENT_EVENT_TYPES } from '@/lib/contentEvents'
|
||||
|
||||
/**
|
||||
* C213 — the wire contract of the public `POST /api/content-events`: the event
|
||||
* type from the shared vocabulary and the PUBLIC slug of a published piece.
|
||||
* The client never names the internal subject id — the route resolves the slug
|
||||
* against the published read, so an unpublished or unknown piece writes
|
||||
* nothing. A malformed body is refused before any query (and the beacon ignores
|
||||
* every response anyway).
|
||||
*/
|
||||
|
||||
/** Slugs are generated by `contentPieceSlugBase` (lowercase groups + hyphens). */
|
||||
const CONTENT_PIECE_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
const CONTENT_PIECE_SLUG_MAX_LENGTH = 120
|
||||
|
||||
export const contentEventRequestSchema = z.object({
|
||||
type: z.enum(CONTENT_EVENT_TYPES),
|
||||
pieceSlug: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(CONTENT_PIECE_SLUG_MAX_LENGTH)
|
||||
.regex(CONTENT_PIECE_SLUG_PATTERN),
|
||||
})
|
||||
18278
src/migrations/20260923_172437_add_content_events.json
Normal file
18278
src/migrations/20260923_172437_add_content_events.json
Normal file
File diff suppressed because it is too large
Load diff
34
src/migrations/20260923_172437_add_content_events.ts
Normal file
34
src/migrations/20260923_172437_add_content_events.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_content_event_type" AS ENUM('abertura', 'download', 'compartilhar_whatsapp', 'compartilhar_link');
|
||||
CREATE TYPE "public"."enum_content_event_subject_type" AS ENUM('peca', 'card');
|
||||
CREATE TABLE "content_event" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"type" "enum_content_event_type" NOT NULL,
|
||||
"subject_type" "enum_content_event_subject_type" NOT NULL,
|
||||
"subject_id" varchar NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "content_event_id" integer;
|
||||
CREATE INDEX "content_event_updated_at_idx" ON "content_event" USING btree ("updated_at");
|
||||
CREATE INDEX "content_event_created_at_idx" ON "content_event" USING btree ("created_at");
|
||||
CREATE INDEX "subjectType_subjectId_type_idx" ON "content_event" USING btree ("subject_type","subject_id","type");
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_content_event_fk" FOREIGN KEY ("content_event_id") REFERENCES "public"."content_event"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "payload_locked_documents_rels_content_event_id_idx" ON "payload_locked_documents_rels" USING btree ("content_event_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "content_event" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "content_event" CASCADE;
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_content_event_fk";
|
||||
|
||||
DROP INDEX "payload_locked_documents_rels_content_event_id_idx";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "content_event_id";
|
||||
DROP TYPE "public"."enum_content_event_type";
|
||||
DROP TYPE "public"."enum_content_event_subject_type";`)
|
||||
}
|
||||
|
|
@ -80,6 +80,7 @@ import * as migration_20260919_155232_add_share_link from './20260919_155232_add
|
|||
import * as migration_20260920_031320_add_jingle from './20260920_031320_add_jingle';
|
||||
import * as migration_20260923_032714_add_content_piece from './20260923_032714_add_content_piece';
|
||||
import * as migration_20260923_054145_add_share_link_announcement from './20260923_054145_add_share_link_announcement';
|
||||
import * as migration_20260923_172437_add_content_events from './20260923_172437_add_content_events';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -485,11 +486,16 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260923_032714_add_content_piece.up,
|
||||
down: migration_20260923_032714_add_content_piece.down,
|
||||
name: '20260923_032714_add_content_piece'
|
||||
name: '20260923_032714_add_content_piece',
|
||||
},
|
||||
{
|
||||
up: migration_20260923_054145_add_share_link_announcement.up,
|
||||
down: migration_20260923_054145_add_share_link_announcement.down,
|
||||
name: '20260923_054145_add_share_link_announcement'
|
||||
name: '20260923_054145_add_share_link_announcement',
|
||||
},
|
||||
{
|
||||
up: migration_20260923_172437_add_content_events.up,
|
||||
down: migration_20260923_172437_add_content_events.down,
|
||||
name: '20260923_172437_add_content_events'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export interface Config {
|
|||
reelMedia: ReelMedia;
|
||||
contentPiece: ContentPiece;
|
||||
contentMedia: ContentMedia;
|
||||
contentEvent: ContentEvent;
|
||||
recording: Recording;
|
||||
recordingMedia: RecordingMedia;
|
||||
recordingSegment: RecordingSegment;
|
||||
|
|
@ -146,6 +147,7 @@ export interface Config {
|
|||
reelMedia: ReelMediaSelect<false> | ReelMediaSelect<true>;
|
||||
contentPiece: ContentPieceSelect<false> | ContentPieceSelect<true>;
|
||||
contentMedia: ContentMediaSelect<false> | ContentMediaSelect<true>;
|
||||
contentEvent: ContentEventSelect<false> | ContentEventSelect<true>;
|
||||
recording: RecordingSelect<false> | RecordingSelect<true>;
|
||||
recordingMedia: RecordingMediaSelect<false> | RecordingMediaSelect<true>;
|
||||
recordingSegment: RecordingSegmentSelect<false> | RecordingSegmentSelect<true>;
|
||||
|
|
@ -1276,6 +1278,23 @@ export interface ContentMedia {
|
|||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* Eventos anônimos de circulação (abertura, download, compartilhamento).
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "contentEvent".
|
||||
*/
|
||||
export interface ContentEvent {
|
||||
id: number;
|
||||
type: 'abertura' | 'download' | 'compartilhar_whatsapp' | 'compartilhar_link';
|
||||
subjectType: 'peca' | 'card';
|
||||
/**
|
||||
* Id interno da peça (peça) ou do modelo (card). Nunca um identificador de visitante.
|
||||
*/
|
||||
subjectId: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Gravações próprias da equipe no acervo. O arquivo é privado; a transcrição é somente leitura.
|
||||
*
|
||||
|
|
@ -2045,6 +2064,10 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'contentMedia';
|
||||
value: number | ContentMedia;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'contentEvent';
|
||||
value: number | ContentEvent;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'recording';
|
||||
value: number | Recording;
|
||||
|
|
@ -2733,6 +2756,17 @@ export interface ContentMediaSelect<T extends boolean = true> {
|
|||
focalX?: T;
|
||||
focalY?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "contentEvent_select".
|
||||
*/
|
||||
export interface ContentEventSelect<T extends boolean = true> {
|
||||
type?: T;
|
||||
subjectType?: T;
|
||||
subjectId?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "recording_select".
|
||||
|
|
@ -3568,6 +3602,7 @@ export interface TaskCreateCollectionExport {
|
|||
| 'reelMedia'
|
||||
| 'contentPiece'
|
||||
| 'contentMedia'
|
||||
| 'contentEvent'
|
||||
| 'recording'
|
||||
| 'recordingMedia'
|
||||
| 'recordingSegment'
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { CampaignVoteSummarySnapshot } from './collections/CampaignVoteSummarySn
|
|||
import { CampaignWebAuthnCredential } from './collections/CampaignWebAuthnCredential'
|
||||
import { Consent } from './collections/Consent'
|
||||
import { Contact } from './collections/Contact'
|
||||
import { ContentEvent } from './collections/ContentEvent'
|
||||
import { ContentMedia } from './collections/ContentMedia'
|
||||
import { ContentPiece } from './collections/ContentPiece'
|
||||
import { ElectionCandidate } from './collections/ElectionCandidate'
|
||||
|
|
@ -130,6 +131,7 @@ export default buildConfig({
|
|||
ReelMedia,
|
||||
ContentPiece,
|
||||
ContentMedia,
|
||||
ContentEvent,
|
||||
Recording,
|
||||
RecordingMedia,
|
||||
RecordingSegment,
|
||||
|
|
|
|||
69
src/utilities/content/contentEventAggregate.ts
Normal file
69
src/utilities/content/contentEventAggregate.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import 'server-only'
|
||||
|
||||
import { sql } from '@payloadcms/db-postgres'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { ContentEventAggregateRow, ContentEventSubjectType } from '@/lib/contentEvents'
|
||||
import { drizzleResultRows, getPayloadDrizzle } from '@/utilities/drizzleBulk'
|
||||
|
||||
/**
|
||||
* C213 — reads the anonymous counters of a page of subjects in ONE query (a
|
||||
* `payload.count` per row would be an N+1 on a 25-row list). Grouping is by
|
||||
* `subject_id` + `type`, so the caller gets the four counters per subject; the
|
||||
* subject type is a parameter, which is what lets S32 read card counters from
|
||||
* the same mechanism.
|
||||
*
|
||||
* The raw SQL has no access control of its own: the gate is the CALLER — the
|
||||
* internal loaders run after the page actor gate (`requireCampaignPageActor`)
|
||||
* with `overrideAccess: false`, and only ever ask for the ids they just read.
|
||||
* The read never throws: a failure answers `{ ok: false }` and the surfaces
|
||||
* render the design's "contagem indisponível" state instead of failing the
|
||||
* page (the list is the assessoria's workbench — it must never depend on the
|
||||
* counters it displays).
|
||||
*/
|
||||
|
||||
export type ContentEventAggregateResult =
|
||||
| { ok: true; rows: ContentEventAggregateRow[] }
|
||||
| { ok: false }
|
||||
|
||||
export const loadContentEventCountsBySubject = async (
|
||||
payload: Pick<Payload, 'db'>,
|
||||
{
|
||||
subjectType,
|
||||
subjectIds,
|
||||
}: { subjectType: ContentEventSubjectType; subjectIds: readonly string[] },
|
||||
): Promise<ContentEventAggregateResult> => {
|
||||
if (subjectIds.length === 0) return { ok: true, rows: [] }
|
||||
|
||||
const drizzle = getPayloadDrizzle(payload)
|
||||
if (!drizzle) return { ok: false }
|
||||
|
||||
try {
|
||||
const result = await drizzle.execute(sql`
|
||||
SELECT "subject_id", "type", COUNT(*)::int AS "count"
|
||||
FROM "content_event"
|
||||
WHERE "subject_type" = ${subjectType}
|
||||
AND "subject_id" IN (${sql.join(
|
||||
subjectIds.map((subjectId) => sql`${subjectId}`),
|
||||
sql`, `,
|
||||
)})
|
||||
GROUP BY "subject_id", "type"
|
||||
`)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
rows: drizzleResultRows(result).map((row) => ({
|
||||
subjectId: String(row.subject_id ?? ''),
|
||||
type: String(row.type ?? ''),
|
||||
count: Number(row.count ?? 0),
|
||||
})),
|
||||
}
|
||||
} catch (error) {
|
||||
// Fail-soft, but not silent: the "—" state on screen traces back to this.
|
||||
console.warn('[contentEvent] falha ao agregar contadores anônimos', {
|
||||
subjectType,
|
||||
error,
|
||||
})
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
73
src/utilities/content/contentEventRateLimit.ts
Normal file
73
src/utilities/content/contentEventRateLimit.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import 'server-only'
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* C213 — the best-effort throttle of the public beacon endpoint. The counters
|
||||
* are advisory, so this is a bar-raiser, not a quota: the window is generous
|
||||
* (a real visitor produces a handful of events; carrier NAT can hold many
|
||||
* visitors behind one IP), it fails OPEN when no client IP is trustworthy, and
|
||||
* it never throws — a limiter failure must not turn a count into an error.
|
||||
*
|
||||
* The IP is NEVER persisted: it is hashed with a per-process random salt and
|
||||
* the digest lives only in this process's memory, resetting on deploy. No IP,
|
||||
* no digest and no header ever reaches the database or a log.
|
||||
*/
|
||||
|
||||
const WINDOW_MS = 10 * 60 * 1000
|
||||
const MAX_EVENTS_PER_WINDOW = 120
|
||||
|
||||
/**
|
||||
* Ceiling for the in-memory registry: a spoofed-IP flood cannot grow it
|
||||
* without bound. Past the ceiling, a NEW key is simply not tracked (fail-open
|
||||
* for that client) while the already-tracked ones keep their windows.
|
||||
*/
|
||||
const MAX_TRACKED_CLIENTS = 10_000
|
||||
|
||||
const processSalt = randomBytes(16).toString('hex')
|
||||
|
||||
type RateLimitWindow = { count: number; resetAt: number }
|
||||
|
||||
const store = new Map<string, RateLimitWindow>()
|
||||
|
||||
/** The tunnel sets `cf-connecting-ip`; the generic proxies use the X- family. */
|
||||
export const contentEventClientKey = (headers: Headers): string | null => {
|
||||
const forwarded = headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
const ip =
|
||||
headers.get('cf-connecting-ip')?.trim() || forwarded || headers.get('x-real-ip')?.trim()
|
||||
if (!ip) return null
|
||||
|
||||
return createHash('sha256').update(`${processSalt}:${ip}`).digest('hex')
|
||||
}
|
||||
|
||||
const cleanup = (now: number): void => {
|
||||
for (const [key, window] of store) {
|
||||
if (now >= window.resetAt) store.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `true` when the event may be recorded. A null key (no trustworthy IP) and
|
||||
* any internal failure answer `true` — the fail-open contract.
|
||||
*/
|
||||
export const checkContentEventRateLimit = (key: string | null): boolean => {
|
||||
if (!key) return true
|
||||
|
||||
try {
|
||||
const now = Date.now()
|
||||
cleanup(now)
|
||||
|
||||
const existing = store.get(key)
|
||||
if (!existing || now >= existing.resetAt) {
|
||||
if (!existing && store.size >= MAX_TRACKED_CLIENTS) return true
|
||||
store.set(key, { count: 1, resetAt: now + WINDOW_MS })
|
||||
return true
|
||||
}
|
||||
if (existing.count >= MAX_EVENTS_PER_WINDOW) return false
|
||||
|
||||
existing.count += 1
|
||||
return true
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
59
src/utilities/content/contentEventWrite.ts
Normal file
59
src/utilities/content/contentEventWrite.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import 'server-only'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import {
|
||||
isContentEventSubjectType,
|
||||
isContentEventType,
|
||||
type ContentEventSubjectType,
|
||||
type ContentEventType,
|
||||
} from '@/lib/contentEvents'
|
||||
|
||||
/**
|
||||
* C213 — the single writer of anonymous content events. Both public call sites
|
||||
* (the validated beacon route and the media route's `?download=1`) go through
|
||||
* here, and this function NEVER throws and never delays its caller: a lost
|
||||
* count is acceptable, a visitor-visible error is not (fail-soft).
|
||||
*
|
||||
* The write is an intentional Local API admin bypass: the collection denies
|
||||
* every create through access control, and the gate is the caller's (the
|
||||
* published-slug read / the served file). No visitor identifier is ever part of
|
||||
* the row.
|
||||
*/
|
||||
|
||||
export type ContentEventInput = {
|
||||
type: ContentEventType
|
||||
subjectType: ContentEventSubjectType
|
||||
subjectId: string
|
||||
}
|
||||
|
||||
export const recordContentEvent = async (event: ContentEventInput): Promise<boolean> => {
|
||||
if (!isContentEventType(event.type) || !isContentEventSubjectType(event.subjectType)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
await payload.create({
|
||||
collection: 'contentEvent',
|
||||
data: {
|
||||
type: event.type,
|
||||
subjectType: event.subjectType,
|
||||
subjectId: event.subjectId,
|
||||
},
|
||||
// Intentional admin bypass: the caller already resolved the public gate
|
||||
// (a published piece / a served file); `create` is denied by design.
|
||||
overrideAccess: true,
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
// No PII to redact: the event carries the subject key only.
|
||||
console.warn('[contentEvent] falha ao registrar evento anônimo', {
|
||||
type: event.type,
|
||||
subjectType: event.subjectType,
|
||||
error,
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,17 @@ import 'server-only'
|
|||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { toContentPieceViewModel, type ContentPieceViewModel } from '@/lib/contentPiece'
|
||||
import { toContentPieceViewModel } from '@/lib/contentPiece'
|
||||
import {
|
||||
contentPieceCirculationFromRows,
|
||||
resolveContentPieceCirculation,
|
||||
type ContentPieceCirculationCounts,
|
||||
type ContentPieceRowViewModel,
|
||||
} from '@/lib/contentPieceCirculation'
|
||||
import { CONTENT_PIECE_NOT_FOUND_MESSAGE } from '@/lib/schemas/contentPiece'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { type RawSearchParams } from '@/utilities/campaignListUrl'
|
||||
import { loadContentEventCountsBySubject } from '@/utilities/content/contentEventAggregate'
|
||||
import {
|
||||
buildContentPieceListWhere,
|
||||
contentPiecePageSize,
|
||||
|
|
@ -74,8 +81,42 @@ export const loadContentPieceFormOptions = async (
|
|||
return { municipalities: result.docs.map((doc) => ({ id: doc.id, name: doc.name })) }
|
||||
}
|
||||
|
||||
type ContentPieceCirculationLookup =
|
||||
| { ok: true; byPieceId: Map<number, ContentPieceCirculationCounts> }
|
||||
| { ok: false }
|
||||
|
||||
/**
|
||||
* C213 — the counters of the pieces on screen, read in ONE aggregate query. A
|
||||
* failed read is not a failed page: every piece resolves to the design's
|
||||
* `unavailable` state instead (the list never depends on the numbers it shows).
|
||||
*/
|
||||
const loadCirculationLookup = async (
|
||||
payload: Payload,
|
||||
pieces: readonly { id: number }[],
|
||||
): Promise<ContentPieceCirculationLookup> => {
|
||||
const result = await loadContentEventCountsBySubject(payload, {
|
||||
subjectType: 'peca',
|
||||
subjectIds: pieces.map((piece) => String(piece.id)),
|
||||
})
|
||||
if (!result.ok) return { ok: false }
|
||||
|
||||
return { ok: true, byPieceId: contentPieceCirculationFromRows(result.rows) }
|
||||
}
|
||||
|
||||
const toContentPieceRow = (
|
||||
record: Parameters<typeof toContentPieceViewModel>[0],
|
||||
lookup: ContentPieceCirculationLookup,
|
||||
): ContentPieceRowViewModel => ({
|
||||
...toContentPieceViewModel(record),
|
||||
circulation: resolveContentPieceCirculation({
|
||||
counts: lookup.ok ? lookup.byPieceId.get(record.id) : undefined,
|
||||
hasBeenPublished: Boolean(record.publishedAt),
|
||||
unavailable: !lookup.ok,
|
||||
}),
|
||||
})
|
||||
|
||||
export type ContentPieceListPageData = {
|
||||
rows: ContentPieceViewModel[]
|
||||
rows: ContentPieceRowViewModel[]
|
||||
state: ContentPieceListState
|
||||
redirectHref?: string
|
||||
totalDocs: number
|
||||
|
|
@ -110,9 +151,10 @@ export const loadContentPieceListPageData = async (
|
|||
overrideAccess: false,
|
||||
})
|
||||
const resolvedUrl = resolveContentPieceListUrl(rawSearchParams, result.totalPages)
|
||||
const circulation = await loadCirculationLookup(payload, result.docs)
|
||||
|
||||
return {
|
||||
rows: result.docs.map((doc) => toContentPieceViewModel(doc)),
|
||||
rows: result.docs.map((doc) => toContentPieceRow(doc, circulation)),
|
||||
state: resolvedUrl.state,
|
||||
redirectHref: resolvedUrl.redirectHref,
|
||||
totalDocs: result.totalDocs,
|
||||
|
|
@ -121,7 +163,7 @@ export const loadContentPieceListPageData = async (
|
|||
}
|
||||
|
||||
export type ContentPieceDetailPageData = {
|
||||
piece: ContentPieceViewModel & {
|
||||
piece: ContentPieceRowViewModel & {
|
||||
description: string | null
|
||||
transcript: string | null
|
||||
institution: string | null
|
||||
|
|
@ -157,7 +199,8 @@ export const loadContentPieceDetailPageData = async (
|
|||
const piece = result.docs[0]
|
||||
if (!piece) throw new ContentPieceNotFoundError()
|
||||
|
||||
const viewModel = toContentPieceViewModel(piece)
|
||||
const circulation = await loadCirculationLookup(payload, [piece])
|
||||
const viewModel = toContentPieceRow(piece, circulation)
|
||||
return {
|
||||
piece: {
|
||||
...viewModel,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'server-only'
|
||||
|
||||
import { sql } from '@payloadcms/db-postgres'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
export type DrizzleTables = Record<string, Record<string, unknown>>
|
||||
|
|
@ -8,6 +9,26 @@ type PayloadDbTables = {
|
|||
tables: DrizzleTables
|
||||
}
|
||||
|
||||
/** The raw-SQL executor Payload's Postgres adapter exposes (drizzle session). */
|
||||
export type PayloadDrizzle = {
|
||||
execute: (query: ReturnType<typeof sql>) => Promise<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* The drizzle session of the Postgres adapter, or null when the database is
|
||||
* not PostgreSQL or the session is unavailable. Single owner of the
|
||||
* `payload.db` narrowing the raw-SQL readers share; callers decide whether
|
||||
* that is an error (throw) or a degraded read (fail soft).
|
||||
*/
|
||||
export const getPayloadDrizzle = (payload: Pick<Payload, 'db'>): PayloadDrizzle | null => {
|
||||
if (payload.db.name !== 'postgres') return null
|
||||
|
||||
const database = payload.db as unknown as { drizzle?: PayloadDrizzle }
|
||||
return database.drizzle && typeof database.drizzle.execute === 'function'
|
||||
? database.drizzle
|
||||
: null
|
||||
}
|
||||
|
||||
/** ~15-16 columns × 500 ≈ 7500-8000 bind params — well under PG's 65535 param limit. */
|
||||
export const INSERT_CHUNK_SIZE = 500
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { Payload } from 'payload'
|
|||
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { getAdvisorMunicipalityIds, isCampaignCoordinator } from '@/utilities/campaignAccess'
|
||||
import { drizzleResultRows } from '@/utilities/drizzleBulk'
|
||||
import { drizzleResultRows, getPayloadDrizzle } from '@/utilities/drizzleBulk'
|
||||
import { toAggregateSqlConditions } from '@/utilities/supporter/supporterListSqlFilters'
|
||||
import type { SupporterListState } from '@/utilities/supporter/supporterUi'
|
||||
import type { SupporterListOverviewViewModel } from '@/utilities/supporter/supporterViewModels'
|
||||
|
|
@ -15,10 +15,6 @@ type AccessConstraint =
|
|||
| { kind: 'none' }
|
||||
| { kind: 'municipalitySet'; ids: number[] }
|
||||
|
||||
type PostgresDb = {
|
||||
execute: (query: ReturnType<typeof sql>) => Promise<unknown>
|
||||
}
|
||||
|
||||
const resolveAccessConstraint = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
|
|
@ -92,9 +88,8 @@ export const computeSupporterListOverviewAggregate = async (
|
|||
if (payload.db.name !== 'postgres') {
|
||||
throw new Error('O overview de apoiadores exige o adaptador PostgreSQL.')
|
||||
}
|
||||
const database = payload.db as unknown as { drizzle?: PostgresDb }
|
||||
const drizzle = database.drizzle
|
||||
if (!drizzle || typeof drizzle.execute !== 'function') {
|
||||
const drizzle = getPayloadDrizzle(payload)
|
||||
if (!drizzle) {
|
||||
throw new Error('A sessão PostgreSQL do overview de apoiadores não está disponível.')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1002,6 +1002,81 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
expect(afterUnpublish.media).toBeTruthy()
|
||||
})
|
||||
|
||||
test('the list and the ficha show the anonymous circulation counters', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('pecacirc')
|
||||
const draftMarker = campaign.fixtures.value('pecacircrascunho')
|
||||
const offAirMarker = campaign.fixtures.value('pecacircoff')
|
||||
const { piece } = await createPiece(campaign, { marker, status: 'publicado' })
|
||||
await createPiece(campaign, { marker: draftMarker })
|
||||
const offAir = await createPiece(campaign, { marker: offAirMarker, status: 'publicado' })
|
||||
|
||||
for (const type of ['abertura', 'abertura', 'download', 'compartilhar_link'] as const) {
|
||||
await campaign.fixtures.payload.create({
|
||||
collection: 'contentEvent',
|
||||
data: { type, subjectType: 'peca', subjectId: String(piece.id) },
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
// Unpublished AFTER circulating: the history stays visible.
|
||||
await campaign.fixtures.payload.update({
|
||||
collection: 'contentPiece',
|
||||
id: offAir.piece.id,
|
||||
data: { status: 'rascunho' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
await campaign.fixtures.payload.create({
|
||||
collection: 'contentEvent',
|
||||
data: { type: 'abertura', subjectType: 'peca', subjectId: String(offAir.piece.id) },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
|
||||
const list = rendered(
|
||||
await (await request.get(`/campanha/comunicacao/conteudos?q=${marker}`)).text(),
|
||||
)
|
||||
expect(list).toContain('Circulação')
|
||||
expect(list).toMatch(/Aberturas<\/span><span[^>]*>2<\/span>/)
|
||||
expect(list).toMatch(/Downloads<\/span><span[^>]*>1<\/span>/)
|
||||
expect(list).toMatch(/WhatsApp<\/span><span[^>]*>0<\/span>/)
|
||||
expect(list).toMatch(/Link<\/span><span[^>]*>1<\/span>/)
|
||||
|
||||
const draftList = rendered(
|
||||
await (await request.get(`/campanha/comunicacao/conteudos?q=${draftMarker}`)).text(),
|
||||
)
|
||||
expect(draftList).toContain('Sem dados — ainda não publicada')
|
||||
|
||||
const offAirList = rendered(
|
||||
await (await request.get(`/campanha/comunicacao/conteudos?q=${offAirMarker}`)).text(),
|
||||
)
|
||||
expect(offAirList).toContain('Histórico de quando esteve publicada · peça fora do ar')
|
||||
|
||||
const ficha = rendered(
|
||||
await (await request.get(`/campanha/comunicacao/conteudos/${piece.id}`)).text(),
|
||||
)
|
||||
expect(ficha).toContain('Circulação')
|
||||
expect(ficha).toMatch(/Aberturas<\/dt><dd[^>]*>2<\/dd>/)
|
||||
expect(ficha).toMatch(/Downloads<\/dt><dd[^>]*>1<\/dd>/)
|
||||
expect(ficha).toMatch(/WhatsApp<\/dt><dd[^>]*>0<\/dd>/)
|
||||
expect(ficha).toMatch(/Link copiado<\/dt><dd[^>]*>1<\/dd>/)
|
||||
expect(ficha).toContain('compare os sinais sem somá-los')
|
||||
expect(ficha).toContain('sem IP persistido, cookie de identidade ou rastreio entre peças')
|
||||
|
||||
for (const pieceId of [piece.id, offAir.piece.id]) {
|
||||
await campaign.fixtures.payload
|
||||
.delete({
|
||||
collection: 'contentEvent',
|
||||
where: { subjectId: { equals: String(pieceId) } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('the private file answers only the Central roles, with range and download', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
|
|
|
|||
|
|
@ -145,6 +145,10 @@ test.afterAll(async ({ request }) => {
|
|||
const headers = await adminHeaders(request, BASE_URL).catch(() => null)
|
||||
if (!headers) return
|
||||
for (const id of createdPieceIds.splice(0)) {
|
||||
// C213 — the anonymous events of the piece go with it (the spec owns both).
|
||||
await request
|
||||
.delete(`${BASE_URL}/api/contentEvent?where[subjectId][equals]=${id}`, { headers })
|
||||
.catch(() => undefined)
|
||||
await request.delete(`${BASE_URL}/api/contentPiece/${id}`, { headers }).catch(() => undefined)
|
||||
}
|
||||
for (const id of createdMediaIds.splice(0)) {
|
||||
|
|
@ -327,6 +331,81 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
|
|||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('counts the anonymous circulation of the piece', async ({ page, request, context }) => {
|
||||
const headers = await adminHeaders(request, BASE_URL)
|
||||
await unpublishEveryPiece(request, headers)
|
||||
const title = `Circulação anônima ${uniqueMarker()}`
|
||||
const piece = await createPiece(request, headers, { title, type: 'video' })
|
||||
|
||||
const eventCount = async (type: string): Promise<number> => {
|
||||
const response = await request.get(
|
||||
`${BASE_URL}/api/contentEvent?limit=0&depth=0&where[and][0][subjectId][equals]=${piece.id}&where[and][1][type][equals]=${type}`,
|
||||
{ headers },
|
||||
)
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
return ((await response.json()) as { totalDocs: number }).totalDocs
|
||||
}
|
||||
|
||||
const beaconFor = (type: string) =>
|
||||
page.waitForRequest((candidate) => {
|
||||
if (!candidate.url().includes('/api/content-events')) return false
|
||||
try {
|
||||
return (candidate.postDataJSON() as { type?: string } | null)?.type === type
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Abertura: the piece page mount beacons once, carrying the public slug.
|
||||
const openBeacon = beaconFor('abertura')
|
||||
await page.goto(`/conteudos/${piece.slug}`)
|
||||
expect((await openBeacon).postDataJSON()).toEqual({ type: 'abertura', pieceSlug: piece.slug })
|
||||
await expect.poll(() => eventCount('abertura')).toBe(1)
|
||||
|
||||
// Download: the SERVER counts the full file request (the click alone would
|
||||
// count a cancelled download).
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.getByRole('link', { name: `Baixar ${title}` }).click(),
|
||||
])
|
||||
expect(download.suggestedFilename()).toBe(`jorge-solla-1313-${piece.slug}.mp4`)
|
||||
await expect.poll(() => eventCount('download')).toBe(1)
|
||||
|
||||
// WhatsApp and link stay apart, each counted on its own control.
|
||||
await page.getByRole('button', { name: `Compartilhar ${title}` }).click()
|
||||
const sheet = page.getByRole('dialog', { name: 'Compartilhar peça' })
|
||||
await context.route('https://wa.me/**', (route) =>
|
||||
route.fulfill({ body: '', contentType: 'text/html' }),
|
||||
)
|
||||
const whatsAppBeacon = beaconFor('compartilhar_whatsapp')
|
||||
await sheet.getByRole('link', { name: 'Abrir no WhatsApp' }).click()
|
||||
await whatsAppBeacon
|
||||
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
const linkBeacon = beaconFor('compartilhar_link')
|
||||
await sheet.getByRole('button', { name: 'Copiar link' }).click()
|
||||
await linkBeacon
|
||||
|
||||
await expect.poll(() => eventCount('compartilhar_whatsapp')).toBe(1)
|
||||
await expect.poll(() => eventCount('compartilhar_link')).toBe(1)
|
||||
|
||||
// The stored event is the subject only: no IP, cookie, user-agent or any
|
||||
// other visitor identifier — the privacy contract, pinned row by row.
|
||||
const listed = await request.get(
|
||||
`${BASE_URL}/api/contentEvent?limit=1&depth=0&where[subjectId][equals]=${piece.id}`,
|
||||
{ headers },
|
||||
)
|
||||
const doc = ((await listed.json()) as { docs: Record<string, unknown>[] }).docs[0]!
|
||||
expect(Object.keys(doc).sort()).toEqual([
|
||||
'createdAt',
|
||||
'id',
|
||||
'subjectId',
|
||||
'subjectType',
|
||||
'type',
|
||||
'updatedAt',
|
||||
])
|
||||
})
|
||||
|
||||
test('flips the kill switch, the discovery and the piece page without a deploy', async ({
|
||||
page,
|
||||
request,
|
||||
|
|
|
|||
404
tests/int/contentEvents.int.spec.ts
Normal file
404
tests/int/contentEvents.int.spec.ts
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('next/cache', () => ({
|
||||
revalidatePath: () => undefined,
|
||||
revalidateTag: () => undefined,
|
||||
unstable_cache: (fn: unknown) => fn,
|
||||
}))
|
||||
|
||||
const afterCallbacks: Array<Promise<unknown>> = []
|
||||
|
||||
vi.mock('next/server', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('next/server')>()
|
||||
return {
|
||||
...actual,
|
||||
// The media route keeps the count off the response path; the test runs the
|
||||
// deferred work explicitly and asserts the row.
|
||||
after: (callback: () => unknown) => {
|
||||
afterCallbacks.push(Promise.resolve().then(callback))
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const { rateLimitMock } = vi.hoisted(() => ({ rateLimitMock: vi.fn(() => true) }))
|
||||
|
||||
vi.mock('@/utilities/content/contentEventRateLimit', () => ({
|
||||
contentEventClientKey: () => 'content-event-test-key',
|
||||
checkContentEventRateLimit: rateLimitMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/utilities/content/contentEventAggregate', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/utilities/content/contentEventAggregate')>()
|
||||
return { loadContentEventCountsBySubject: vi.fn(actual.loadContentEventCountsBySubject) }
|
||||
})
|
||||
|
||||
import { POST as postContentEvent } from '@/app/(frontend)/api/content-events/route'
|
||||
import { GET as getPublicPieceMedia } from '@/app/(frontend)/conteudos/[slug]/midia/route'
|
||||
import { contentPieceCirculationFromRows } from '@/lib/contentPieceCirculation'
|
||||
import type { CampaignUser, ContentMedia } from '@/payload-types'
|
||||
import config from '@/payload.config'
|
||||
import { loadContentEventCountsBySubject } from '@/utilities/content/contentEventAggregate'
|
||||
import {
|
||||
loadContentPieceDetailPageData,
|
||||
loadContentPieceListPageData,
|
||||
} from '@/utilities/content/contentPiecePageData'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
const VIDEO_BYTES = Buffer.from('fixture-content-event-bytes')
|
||||
|
||||
let payload: Payload
|
||||
const createdPieceIds = new Set<number>()
|
||||
const createdMediaIds = new Set<number>()
|
||||
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const flushAfterCallbacks = async (): Promise<void> => {
|
||||
await Promise.all(afterCallbacks.splice(0))
|
||||
}
|
||||
|
||||
const eventRowsOf = async (subjectIds: number[]) =>
|
||||
payload.find({
|
||||
collection: 'contentEvent',
|
||||
where: { subjectId: { in: subjectIds.map(String) } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'id',
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const createMedia = async (name: string): Promise<ContentMedia> => {
|
||||
const media = await payload.create({
|
||||
collection: 'contentMedia',
|
||||
data: { alt: `Arquivo ${name}` },
|
||||
file: { data: VIDEO_BYTES, mimetype: 'video/mp4', name, size: VIDEO_BYTES.length },
|
||||
overrideAccess: true,
|
||||
})
|
||||
createdMediaIds.add(media.id)
|
||||
return media
|
||||
}
|
||||
|
||||
const createPiece = async ({
|
||||
title = `Peça de eventos ${Date.now()}`,
|
||||
status = 'publicado',
|
||||
withMedia = true,
|
||||
}: {
|
||||
title?: string
|
||||
status?: 'rascunho' | 'publicado'
|
||||
withMedia?: boolean
|
||||
} = {}) => {
|
||||
const media = withMedia ? await createMedia(`${title}.mp4`) : null
|
||||
const piece = await payload.create({
|
||||
collection: 'contentPiece',
|
||||
data: {
|
||||
title,
|
||||
type: 'video',
|
||||
status,
|
||||
processingStatus: 'pronto',
|
||||
origin: 'arquivo',
|
||||
...(media ? { media: media.id } : {}),
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
createdPieceIds.add(piece.id)
|
||||
return piece
|
||||
}
|
||||
|
||||
const slugOf = async (pieceId: number): Promise<string> =>
|
||||
(
|
||||
await payload.findByID({
|
||||
collection: 'contentPiece',
|
||||
id: pieceId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
).slug!
|
||||
|
||||
const postEvent = (body: unknown, init: RequestInit = {}): Promise<Response> =>
|
||||
postContentEvent(
|
||||
new Request('http://localhost/api/content-events', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
...init,
|
||||
}),
|
||||
)
|
||||
|
||||
const callPublicMedia = (
|
||||
slug: string,
|
||||
{ download, range }: { download?: boolean; range?: string },
|
||||
) =>
|
||||
getPublicPieceMedia(
|
||||
new Request(`http://localhost/conteudos/${slug}/midia${download ? '?download=1' : ''}`, {
|
||||
headers: range ? { range } : undefined,
|
||||
}),
|
||||
{ params: Promise.resolve({ slug }) },
|
||||
)
|
||||
|
||||
describe('content events (C213)', () => {
|
||||
let communicator: CampaignUser
|
||||
let leader: CampaignUser
|
||||
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
;[communicator, leader] = await Promise.all([
|
||||
fixtures.createCampaignUser('communicator'),
|
||||
fixtures.createCampaignUser('leader'),
|
||||
])
|
||||
afterCallbacks.length = 0
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await payload
|
||||
.delete({
|
||||
collection: 'contentEvent',
|
||||
where: { id: { greater_than: 0 } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
for (const id of createdPieceIds) {
|
||||
await payload
|
||||
.delete({ collection: 'contentPiece', id, overrideAccess: true })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
for (const id of createdMediaIds) {
|
||||
await payload
|
||||
.delete({ collection: 'contentMedia', id, overrideAccess: true })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
it('records an event for a published piece only', async () => {
|
||||
const piece = await createPiece()
|
||||
const slug = await slugOf(piece.id)
|
||||
|
||||
const accepted = await postEvent({ type: 'abertura', pieceSlug: slug })
|
||||
expect(accepted.status).toBe(204)
|
||||
expect(accepted.headers.get('Cache-Control')).toBe('no-store')
|
||||
|
||||
const rows = await eventRowsOf([piece.id])
|
||||
expect(rows.docs).toHaveLength(1)
|
||||
expect(rows.docs[0]).toMatchObject({
|
||||
type: 'abertura',
|
||||
subjectType: 'peca',
|
||||
subjectId: String(piece.id),
|
||||
})
|
||||
|
||||
// An unknown slug is indistinguishable from a recorded event.
|
||||
expect(
|
||||
(await postEvent({ type: 'download', pieceSlug: `nao-existe-${Date.now()}` })).status,
|
||||
).toBe(204)
|
||||
|
||||
// Unpublishing closes the door immediately: the slug survives, the write does not.
|
||||
await payload.update({
|
||||
collection: 'contentPiece',
|
||||
id: piece.id,
|
||||
data: { status: 'rascunho' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect((await postEvent({ type: 'download', pieceSlug: slug })).status).toBe(204)
|
||||
expect((await eventRowsOf([piece.id])).docs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('refuses malformed bodies, unknown types, oversized bodies and cross-origin requests', async () => {
|
||||
const piece = await createPiece()
|
||||
const slug = await slugOf(piece.id)
|
||||
|
||||
expect((await postEvent('not-json')).status).toBe(400)
|
||||
expect((await postEvent({ type: 'pageview', pieceSlug: slug })).status).toBe(400)
|
||||
expect((await postEvent({ type: 'download', pieceSlug: '../etc/passwd' })).status).toBe(400)
|
||||
// The byte ceiling is enforced on the stream, not on a declared header.
|
||||
expect((await postEvent(`{"padding":"${'x'.repeat(5 * 1024)}"}`)).status).toBe(400)
|
||||
expect(
|
||||
(
|
||||
await postEvent(
|
||||
{ type: 'download', pieceSlug: slug },
|
||||
{ headers: { 'content-type': 'application/json', origin: 'https://evil.example' } },
|
||||
)
|
||||
).status,
|
||||
).toBe(403)
|
||||
|
||||
expect((await eventRowsOf([piece.id])).docs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('aggregates counts per subject and never leaks one subject type into another', async () => {
|
||||
const first = await createPiece({ title: `Eventos A ${Date.now()}` })
|
||||
const second = await createPiece({ title: `Eventos B ${Date.now()}` })
|
||||
|
||||
await payload.create({
|
||||
collection: 'contentEvent',
|
||||
data: { type: 'abertura', subjectType: 'peca', subjectId: String(first.id) },
|
||||
overrideAccess: true,
|
||||
})
|
||||
await payload.create({
|
||||
collection: 'contentEvent',
|
||||
data: { type: 'abertura', subjectType: 'peca', subjectId: String(first.id) },
|
||||
overrideAccess: true,
|
||||
})
|
||||
await payload.create({
|
||||
collection: 'contentEvent',
|
||||
data: { type: 'compartilhar_link', subjectType: 'peca', subjectId: String(second.id) },
|
||||
overrideAccess: true,
|
||||
})
|
||||
await payload.create({
|
||||
collection: 'contentEvent',
|
||||
data: { type: 'download', subjectType: 'card', subjectId: String(first.id) },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const result = await loadContentEventCountsBySubject(payload, {
|
||||
subjectType: 'peca',
|
||||
subjectIds: [String(first.id), String(second.id)],
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
|
||||
const byPieceId = contentPieceCirculationFromRows(result.rows)
|
||||
expect(byPieceId.get(first.id)).toEqual({ opens: 2, downloads: 0, whatsapp: 0, link: 0 })
|
||||
expect(byPieceId.get(second.id)).toEqual({ opens: 0, downloads: 0, whatsapp: 0, link: 1 })
|
||||
})
|
||||
|
||||
it('answers unavailable when the aggregate cannot run', async () => {
|
||||
const brokenPayload = {
|
||||
db: {
|
||||
name: 'postgres',
|
||||
drizzle: {
|
||||
execute: () => {
|
||||
throw new Error('boom')
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Pick<Payload, 'db'>
|
||||
|
||||
await expect(
|
||||
loadContentEventCountsBySubject(brokenPayload, { subjectType: 'peca', subjectIds: ['1'] }),
|
||||
).resolves.toEqual({ ok: false })
|
||||
})
|
||||
|
||||
it('counts one full public download and ignores ranges and plain reads', async () => {
|
||||
const piece = await createPiece({ title: `Download ${Date.now()}` })
|
||||
const slug = await slugOf(piece.id)
|
||||
|
||||
const plain = await callPublicMedia(slug, {})
|
||||
expect(plain.status).toBe(200)
|
||||
await flushAfterCallbacks()
|
||||
|
||||
const ranged = await callPublicMedia(slug, { download: true, range: 'bytes=0-3' })
|
||||
expect(ranged.status).toBe(206)
|
||||
await flushAfterCallbacks()
|
||||
|
||||
const download = await callPublicMedia(slug, { download: true })
|
||||
expect(download.status).toBe(200)
|
||||
await flushAfterCallbacks()
|
||||
|
||||
const rows = await eventRowsOf([piece.id])
|
||||
expect(rows.docs).toHaveLength(1)
|
||||
expect(rows.docs[0]).toMatchObject({ type: 'download', subjectId: String(piece.id) })
|
||||
})
|
||||
|
||||
it('attaches the circulation counters to the list and the detail loaders', async () => {
|
||||
const published = await createPiece({ title: `Circulação ${Date.now()}` })
|
||||
const untouched = await createPiece({ title: `Circulação zerada ${Date.now()}` })
|
||||
const draft = await createPiece({ title: `Sem publicar ${Date.now()}`, status: 'rascunho' })
|
||||
await payload.create({
|
||||
collection: 'contentEvent',
|
||||
data: { type: 'download', subjectType: 'peca', subjectId: String(published.id) },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const list = await loadContentPieceListPageData(payload, communicator, { q: 'Circulação' })
|
||||
const listRow = list.rows.find((row) => row.id === published.id)
|
||||
expect(listRow?.circulation).toEqual({
|
||||
state: 'data',
|
||||
counts: { opens: 0, downloads: 1, whatsapp: 0, link: 0 },
|
||||
})
|
||||
// Published without any event is data with zeros — not "never published".
|
||||
expect(list.rows.find((row) => row.id === untouched.id)?.circulation).toEqual({
|
||||
state: 'data',
|
||||
counts: { opens: 0, downloads: 0, whatsapp: 0, link: 0 },
|
||||
})
|
||||
|
||||
const detail = await loadContentPieceDetailPageData(payload, communicator, published.id)
|
||||
expect(detail.piece.circulation).toEqual({
|
||||
state: 'data',
|
||||
counts: { opens: 0, downloads: 1, whatsapp: 0, link: 0 },
|
||||
})
|
||||
|
||||
// A piece that never had a slug has nothing to count, honestly.
|
||||
const draftDetail = await loadContentPieceDetailPageData(payload, communicator, draft.id)
|
||||
expect(draftDetail.piece.circulation).toEqual({ state: 'neverPublished' })
|
||||
})
|
||||
|
||||
it('degrades the loaders to the unavailable state when the aggregate fails', async () => {
|
||||
const piece = await createPiece({ title: `Circulação indisponível ${Date.now()}` })
|
||||
|
||||
vi.mocked(loadContentEventCountsBySubject).mockResolvedValueOnce({ ok: false })
|
||||
const list = await loadContentPieceListPageData(payload, communicator, {
|
||||
q: 'Circulação indisponível',
|
||||
})
|
||||
expect(list.rows.find((row) => row.id === piece.id)?.circulation).toEqual({
|
||||
state: 'unavailable',
|
||||
})
|
||||
|
||||
vi.mocked(loadContentEventCountsBySubject).mockResolvedValueOnce({ ok: false })
|
||||
const detail = await loadContentPieceDetailPageData(payload, communicator, piece.id)
|
||||
expect(detail.piece.circulation).toEqual({ state: 'unavailable' })
|
||||
})
|
||||
|
||||
it('answers silently and writes nothing when the client is throttled', async () => {
|
||||
const piece = await createPiece()
|
||||
const slug = await slugOf(piece.id)
|
||||
|
||||
rateLimitMock.mockReturnValueOnce(false)
|
||||
const throttled = await postEvent({ type: 'abertura', pieceSlug: slug })
|
||||
|
||||
expect(throttled.status).toBe(204)
|
||||
expect((await eventRowsOf([piece.id])).docs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps the collection closed to every campaign write and open to communication reads', async () => {
|
||||
const piece = await createPiece()
|
||||
await payload.create({
|
||||
collection: 'contentEvent',
|
||||
data: { type: 'abertura', subjectType: 'peca', subjectId: String(piece.id) },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: 'contentEvent',
|
||||
data: { type: 'abertura', subjectType: 'peca', subjectId: String(piece.id) },
|
||||
overrideAccess: false,
|
||||
user: communicator,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
const readAs = (user?: CampaignUser) =>
|
||||
payload.find({
|
||||
collection: 'contentEvent',
|
||||
where: { subjectId: { equals: String(piece.id) } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
...(user ? { user } : {}),
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
await expect(readAs()).rejects.toThrow()
|
||||
await expect(readAs(leader)).rejects.toThrow()
|
||||
expect((await readAs(communicator)).docs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -210,6 +210,10 @@ describe('campaign JSON mutation route convention', () => {
|
|||
'src/app/(frontend)/api/social-feed/sync/route.ts',
|
||||
'admin-session authenticated (payload.auth, users collection) + explicit isSameOriginRequest; bodyless action returning the sync status — the campaignJsonMutationRoute envelope is campaign-scoped ({status:error})',
|
||||
],
|
||||
[
|
||||
'src/app/(frontend)/api/content-events/route.ts',
|
||||
'anonymous public beacon (C213): reads no cookie and no session, so there is no CSRF surface to guard — the write is gated by the published-slug read and throttled in memory; explicit isSameOriginRequest is still a bar-raiser, and the campaign-scoped error envelope does not apply',
|
||||
],
|
||||
[
|
||||
'src/app/(campaign)/campanha/api/ai-chat/route.ts',
|
||||
'streaming AI endpoint (ReadableStream, not JSON) — cookie-authenticated via campaign-token, origin-checked by cookie path',
|
||||
|
|
|
|||
61
tests/unit/contentEventRateLimit.unit.spec.ts
Normal file
61
tests/unit/contentEventRateLimit.unit.spec.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
checkContentEventRateLimit,
|
||||
contentEventClientKey,
|
||||
} from '@/utilities/content/contentEventRateLimit'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('contentEventClientKey', () => {
|
||||
it('reads the tunnel header first, then the first forwarded hop, then the real IP', () => {
|
||||
const keyOf = (headers: Record<string, string>) => contentEventClientKey(new Headers(headers))
|
||||
|
||||
const tunnelKey = keyOf({ 'cf-connecting-ip': '203.0.113.9' })
|
||||
expect(tunnelKey).toMatch(/^[a-f0-9]{64}$/)
|
||||
|
||||
// The same client behind the same header always hashes to the same key.
|
||||
expect(keyOf({ 'cf-connecting-ip': '203.0.113.9' })).toBe(tunnelKey)
|
||||
expect(keyOf({ 'cf-connecting-ip': '203.0.113.10' })).not.toBe(tunnelKey)
|
||||
|
||||
expect(keyOf({ 'x-forwarded-for': '198.51.100.4, 10.0.0.1' })).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(keyOf({ 'x-real-ip': '192.0.2.7' })).toMatch(/^[a-f0-9]{64}$/)
|
||||
|
||||
// No IP header at all: no key, never a shared bucket.
|
||||
expect(keyOf({})).toBeNull()
|
||||
expect(keyOf({ 'x-forwarded-for': ' ' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkContentEventRateLimit', () => {
|
||||
it('fails open without a key and never throttles an unkeyed client', () => {
|
||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||
expect(checkContentEventRateLimit(null)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('throttles the 121st event of one client inside the window and leaves others alone', () => {
|
||||
const key = 'unit-window-client'
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
expect(checkContentEventRateLimit(key)).toBe(true)
|
||||
}
|
||||
expect(checkContentEventRateLimit(key)).toBe(false)
|
||||
expect(checkContentEventRateLimit(`${key}-other`)).toBe(true)
|
||||
})
|
||||
|
||||
it('resets when the window expires', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-09-23T12:00:00.000Z'))
|
||||
|
||||
const key = 'unit-reset-client'
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) checkContentEventRateLimit(key)
|
||||
expect(checkContentEventRateLimit(key)).toBe(false)
|
||||
|
||||
vi.setSystemTime(new Date('2026-09-23T12:10:00.001Z'))
|
||||
expect(checkContentEventRateLimit(key)).toBe(true)
|
||||
})
|
||||
})
|
||||
96
tests/unit/contentEvents.unit.spec.ts
Normal file
96
tests/unit/contentEvents.unit.spec.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CONTENT_EVENT_ENDPOINT, sendContentPieceEvent } from '@/lib/contentEvents'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ContentEvent collection contract', () => {
|
||||
it('carries the subject only — no visitor identifier — and never hooks the public cache', async () => {
|
||||
const { ContentEvent } = await import('@/collections/ContentEvent')
|
||||
|
||||
const fieldNames = ContentEvent.fields.map((field) => ('name' in field ? field.name : ''))
|
||||
expect(fieldNames).toEqual(['type', 'subjectType', 'subjectId'])
|
||||
for (const forbidden of ['ip', 'useragent', 'cookie', 'session', 'visitor', 'fingerprint']) {
|
||||
expect(fieldNames.some((name) => name.toLowerCase().includes(forbidden))).toBe(false)
|
||||
}
|
||||
|
||||
// A write must never bust the public `contentPieces` listing tag.
|
||||
expect(ContentEvent.hooks ?? {}).toEqual({})
|
||||
expect(ContentEvent.admin?.hidden).toBeTypeOf('function')
|
||||
// No access predicate grants a create; the writer bypasses explicitly.
|
||||
const createAccess = ContentEvent.access?.create
|
||||
expect(createAccess).toBeTypeOf('function')
|
||||
const decision = await (
|
||||
createAccess as (args: { req: { user: null } }) => boolean | Promise<boolean>
|
||||
)({ req: { user: null } })
|
||||
expect(decision).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendContentPieceEvent', () => {
|
||||
it('prefers sendBeacon with a JSON blob and does not touch fetch', async () => {
|
||||
const sendBeacon = vi.fn().mockReturnValue(true)
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('navigator', { sendBeacon })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
sendContentPieceEvent('abertura', 'fim-da-escala-6x1')
|
||||
|
||||
expect(sendBeacon).toHaveBeenCalledTimes(1)
|
||||
const [endpoint, blob] = sendBeacon.mock.calls[0]!
|
||||
expect(endpoint).toBe(CONTENT_EVENT_ENDPOINT)
|
||||
expect(blob).toBeInstanceOf(Blob)
|
||||
expect((blob as Blob).type).toBe('application/json')
|
||||
await expect((blob as Blob).text()).resolves.toBe(
|
||||
JSON.stringify({ type: 'abertura', pieceSlug: 'fim-da-escala-6x1' }),
|
||||
)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to a keepalive fetch when the beacon refuses the body', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
|
||||
vi.stubGlobal('navigator', { sendBeacon: vi.fn().mockReturnValue(false) })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
sendContentPieceEvent('compartilhar_link', 'solla-no-sus')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
CONTENT_EVENT_ENDPOINT,
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
body: JSON.stringify({ type: 'compartilhar_link', pieceSlug: 'solla-no-sus' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('never throws when the beacon and the fetch both fail', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('offline'))
|
||||
vi.stubGlobal('navigator', {
|
||||
sendBeacon: vi.fn().mockImplementation(() => {
|
||||
throw new Error('beacon exploded')
|
||||
}),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
expect(() => sendContentPieceEvent('download', 'peca')).not.toThrow()
|
||||
await Promise.resolve()
|
||||
|
||||
vi.stubGlobal('fetch', undefined)
|
||||
expect(() => sendContentPieceEvent('download', 'peca')).not.toThrow()
|
||||
})
|
||||
|
||||
it('reaches the fetch fallback when there is no navigator at all', () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
|
||||
vi.stubGlobal('navigator', undefined)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
sendContentPieceEvent('download', 'peca')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
125
tests/unit/contentPieceCirculation.unit.spec.ts
Normal file
125
tests/unit/contentPieceCirculation.unit.spec.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
CONTENT_PIECE_CIRCULATION_HISTORY_LABEL,
|
||||
CONTENT_PIECE_CIRCULATION_METRICS,
|
||||
CONTENT_PIECE_CIRCULATION_NEVER_PUBLISHED_LABEL,
|
||||
CONTENT_PIECE_CIRCULATION_ZERO_LABEL,
|
||||
EMPTY_CONTENT_PIECE_CIRCULATION,
|
||||
contentPieceCirculationFromRows,
|
||||
isContentPieceCirculationEmpty,
|
||||
resolveContentPieceCirculation,
|
||||
showsContentPieceCirculationHistory,
|
||||
type ContentPieceCirculationCounts,
|
||||
} from '@/lib/contentPieceCirculation'
|
||||
|
||||
const counts = (
|
||||
overrides: Partial<ContentPieceCirculationCounts> = {},
|
||||
): ContentPieceCirculationCounts => ({ ...EMPTY_CONTENT_PIECE_CIRCULATION, ...overrides })
|
||||
|
||||
describe('content piece circulation vocabulary', () => {
|
||||
it('maps the four event types to the four counters, once each', () => {
|
||||
expect(CONTENT_PIECE_CIRCULATION_METRICS.map((metric) => metric.eventType)).toEqual([
|
||||
'abertura',
|
||||
'download',
|
||||
'compartilhar_whatsapp',
|
||||
'compartilhar_link',
|
||||
])
|
||||
expect(new Set(CONTENT_PIECE_CIRCULATION_METRICS.map((metric) => metric.key)).size).toBe(4)
|
||||
// The detail names the action the fourth counter measures.
|
||||
expect(
|
||||
CONTENT_PIECE_CIRCULATION_METRICS.find((metric) => metric.key === 'link')?.detailLabel,
|
||||
).toBe('Link copiado')
|
||||
})
|
||||
|
||||
it('pins the honest literals of the design', () => {
|
||||
expect(CONTENT_PIECE_CIRCULATION_ZERO_LABEL).toBe(
|
||||
'0 abertura · 0 download · 0 WhatsApp · 0 link',
|
||||
)
|
||||
expect(CONTENT_PIECE_CIRCULATION_NEVER_PUBLISHED_LABEL).toBe('Sem dados — ainda não publicada')
|
||||
expect(CONTENT_PIECE_CIRCULATION_HISTORY_LABEL).toBe(
|
||||
'Histórico de quando esteve publicada · peça fora do ar',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('contentPieceCirculationFromRows', () => {
|
||||
it('groups the aggregate rows per piece, ignoring unknown types and subjects', () => {
|
||||
const byPieceId = contentPieceCirculationFromRows([
|
||||
{ subjectId: '7', type: 'abertura', count: 3 },
|
||||
{ subjectId: '7', type: 'download', count: 1 },
|
||||
{ subjectId: '7', type: 'compartilhar_whatsapp', count: 2 },
|
||||
{ subjectId: '8', type: 'compartilhar_link', count: 4 },
|
||||
// Unknown event type and non-numeric subject (card model ids arrive with
|
||||
// subjectType 'card', never through the piece mapper).
|
||||
{ subjectId: '7', type: 'card_download', count: 9 },
|
||||
{ subjectId: 'time-de-voce', type: 'download', count: 9 },
|
||||
{ subjectId: '0', type: 'download', count: 9 },
|
||||
])
|
||||
|
||||
expect(byPieceId.get(7)).toEqual({ opens: 3, downloads: 1, whatsapp: 2, link: 0 })
|
||||
expect(byPieceId.get(8)).toEqual({ opens: 0, downloads: 0, whatsapp: 0, link: 4 })
|
||||
expect(byPieceId.size).toBe(2)
|
||||
})
|
||||
|
||||
it('normalizes hostile counts to non-negative integers', () => {
|
||||
const byPieceId = contentPieceCirculationFromRows([
|
||||
{ subjectId: '7', type: 'download', count: Number.NaN },
|
||||
{ subjectId: '7', type: 'abertura', count: 2.7 },
|
||||
{ subjectId: '8', type: 'download', count: -4 },
|
||||
])
|
||||
|
||||
expect(byPieceId.get(7)).toEqual({ opens: 2, downloads: 0, whatsapp: 0, link: 0 })
|
||||
expect(byPieceId.get(8)).toEqual({ opens: 0, downloads: 0, whatsapp: 0, link: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveContentPieceCirculation', () => {
|
||||
it('a failed read is unavailable, whatever the counts and publication say', () => {
|
||||
expect(
|
||||
resolveContentPieceCirculation({
|
||||
counts: counts({ opens: 5 }),
|
||||
hasBeenPublished: true,
|
||||
unavailable: true,
|
||||
}),
|
||||
).toEqual({ state: 'unavailable' })
|
||||
})
|
||||
|
||||
it('real counters win over everything — an unpublished piece keeps its history', () => {
|
||||
expect(
|
||||
resolveContentPieceCirculation({
|
||||
counts: counts({ downloads: 1 }),
|
||||
hasBeenPublished: true,
|
||||
unavailable: false,
|
||||
}),
|
||||
).toEqual({ state: 'data', counts: counts({ downloads: 1 }) })
|
||||
})
|
||||
|
||||
it('a published piece with no events shows zeros; a never published one does not', () => {
|
||||
expect(resolveContentPieceCirculation({ hasBeenPublished: true, unavailable: false })).toEqual({
|
||||
state: 'data',
|
||||
counts: EMPTY_CONTENT_PIECE_CIRCULATION,
|
||||
})
|
||||
expect(resolveContentPieceCirculation({ hasBeenPublished: false, unavailable: false })).toEqual(
|
||||
{ state: 'neverPublished' },
|
||||
)
|
||||
})
|
||||
|
||||
it('flags the historical counters of an unpublished piece only', () => {
|
||||
const withHistory = { state: 'data', counts: counts({ opens: 2 }) } as const
|
||||
const zeros = { state: 'data', counts: EMPTY_CONTENT_PIECE_CIRCULATION } as const
|
||||
|
||||
expect(showsContentPieceCirculationHistory(withHistory, false)).toBe(true)
|
||||
expect(showsContentPieceCirculationHistory(withHistory, true)).toBe(false)
|
||||
expect(showsContentPieceCirculationHistory(zeros, false)).toBe(false)
|
||||
expect(showsContentPieceCirculationHistory({ state: 'neverPublished' }, false)).toBe(false)
|
||||
expect(showsContentPieceCirculationHistory({ state: 'unavailable' }, false)).toBe(false)
|
||||
})
|
||||
|
||||
it('knows an all-zero piece from a piece with any signal', () => {
|
||||
expect(isContentPieceCirculationEmpty(EMPTY_CONTENT_PIECE_CIRCULATION)).toBe(true)
|
||||
expect(isContentPieceCirculationEmpty(counts({ link: 1 }))).toBe(false)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue