mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(S32): contadores anônimos de download dos cards na home staff (Closes #1273)
This commit is contained in:
parent
68065032f5
commit
e8a190005a
26 changed files with 19272 additions and 46 deletions
1
docs/changelog/2026-09-23-s32.md
Normal file
1
docs/changelog/2026-09-23-s32.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**S32 — Analytics dos cards personalizados: quantos e quais (2026-09-23):** o download de qualquer card personalizado passa a contar **um** evento anônimo "Download do card" no **mesmo mecanismo do C213** — nenhuma collection, rota ou serviço paralelo: o card é `subjectType: 'card'` da `contentEvent` e o evento carrega o model id (`subjectId`) e, nos modelos com escolha estadual (S30/S31), o slug do estadual escolhido numa coluna nova `variant` (migration aditiva `20260923_190414_add_content_event_variant`, nullable, sem índice). O `POST /api/content-events` ganhou uma variante discriminada **estrita** (`{ type: 'download', subjectType: 'card', cardModelId, stateDeputySlug? }`): o cliente manda as chaves públicas e o servidor valida contra os catálogos commitados (`CARD_MODELS`/`stateDeputyCatalog`) — id de modelo inventado, slug em modelo sem picker, picker sem slug e slug desconhecido respondem 400 **sem gravar**; o contrato de peça (`{ type, pieceSlug }`) fica intacto (objetos estritos: exatamente um assunto por body). O beacon `sendCardDownloadEvent` sai do ponto único de download do `CardComposer.handleDownload` logo após `downloadBlob` (fire-and-forget, nunca lança/atrasa; o transporte `sendBeacon` → `fetch keepalive` virou um envio compartilhado no dono `src/lib/contentEvents.ts`). A home staff `/campanha` ganhou o bloco compacto **"Cards"** no resumo (`CampaignHomeCardDownloads`, porte classe-a-classe do artefato aprovado): um contador absoluto por modelo, na ordem natural do catálogo, 6 colunas no desktop e uma linha por modelo no mobile, com a caption de honestidade "Os números contam downloads, não pessoas ou visitantes únicos." e um vazio honesto antes do primeiro download; a agregação reusa `loadContentEventCountsBySubject` (uma query, 6 ids) e o mapper puro `resolveCardDownloadCounts` (`data | empty | unavailable`), e a falha da leitura **omite a seção** — nunca renderiza "Nenhum download ainda" (o estado indisponível fica distinto do vazio). Sem PII/cookie/IP/user-agent/rastreio, sem `Consent` novo, sem total/%/série/ranking/dashboard/rota nova; o bloco herda o gate de staff da home (leader nunca vê). Verificação: unit (`cardDownloadCounts` com ordem/estados/literais/normalização, `contentEvents` com o pin do campo `variant` e o beacon de card, `campaignHomeCardDownloads` com dados/vazio/indisponível), int (`contentEvents`: grava com `variant`, recusas de catálogo sem gravar, agregação `card` sem vazar `peca` e vice-versa, loader data/empty/unavailable), e2e (`frontend`: o beacon observado no download real de `eu-sou-solla`, `time-do-estadual` e `minha-colinha` com o slug; `campaignHomeActions`: o bloco com os seis rótulos na ordem e o leader sem o bloco), `pnpm gate:fast`, `pnpm test:int` e os e2e afetados locais. A crítica final do `designer` (trigger c) reprovou a primeira passada e os ajustes entraram antes do push: o divisor saiu de baixo do título "Cards" para o fim do resumo (o `h2` do bloco neutraliza a regra global `h2 { border-b pb-2 }` e o `CampaignHomeSummary` ganhou `border-b`), o bloco ganhou respiro inferior (`pb-4 md:pb-6`) e o `summarySlot` passou a rolar no mobile (`max-md:min-h-0 max-md:overflow-y-auto` no `CampaignHomeLayout`) — as seis linhas e a caption são alcançáveis acima do dock de ações; re-capturado e **certificado em tier primário** (`Design tier: openai/gpt-5.6-sol`).
|
||||
191
docs/plans/cards-analytics-impl.md
Normal file
191
docs/plans/cards-analytics-impl.md
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
# Impl: S32 — Analytics dos cards personalizados: quantos e quais
|
||||
|
||||
Status: aprovado (--auto)
|
||||
Atualizado em: 2026-09-23
|
||||
Issue: #1273
|
||||
Intenção: docs/plans/cards-analytics.md
|
||||
Design UI (gate): docs/plans/cards-analytics-ui-design.html
|
||||
Appetite restante: herdado (~0,5–1 dia eng). Corte explícito para caber: um contador por modelo (sem drill-down por dobradinha no v1), sem estado visual novo para a falha da agregação (D7), uma query de agregação por render da home e nenhum widget compartilhado alterado. Fases: server/schema ~55%, UI ~30%, gates ~15%.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** o download de qualquer card (6 modelos) conta **um** evento anônimo "Download do card" com o model id — e, nos modelos com escolha estadual (`time-do-estadual`, `minha-colinha`), também o slug do estadual escolhido; a home staff `/campanha` mostra um bloco compacto "Cards" com o contador absoluto de cada modelo, lado a lado, na ordem natural do catálogo.
|
||||
- **O que NÃO negociar:**
|
||||
- **Anonimato fail-closed:** sem PII, sem IP persistido, sem cookie/identidade, sem user-agent, sem rastreio entre páginas/modelos; nenhum `Consent` novo.
|
||||
- **Fail-soft absoluto:** o download nunca é bloqueado nem atrasado; falha da contagem é invisível ao visitante; contador a menos é aceitável.
|
||||
- **Mesmo mecanismo do C213:** nenhuma collection/endpoint/serviço paralelo; o card é `subjectType: 'card'` da mesma `contentEvent` e da mesma rota.
|
||||
- **Só o download concluído:** abertura de galeria, prévia renderizada e estúdio não contam; a foto/recorte local não é tocada.
|
||||
- **Honestidade:** os números medem downloads, não pessoas; sem total, %, série, ranking, funil/pageview, dashboard ou rota nova.
|
||||
- **O que reavaliar (hipóteses da "Direção no codebase", checadas):**
|
||||
- O ponto observável do download é único: `handleDownload` (`CardComposer.tsx:576-590`) depois de `downloadBlob`, e os dois CTAs (`:1180-1193` colinha, `:1200-1207` step result) convergem nele; `model.id` e `selectedDeputy?.slug` estão no closure.
|
||||
- O slug do estadual **não tem onde viver** hoje: a `contentEvent` (`ContentEvent.ts:50-81`) só tem `type`/`subjectType`/`subjectId` e a migration `20260923_172437_add_content_events.ts:6-15` não tem `variant` → este item exige coluna nova (D2).
|
||||
- `CampaignMetricStrip` **não** entrega a composição do artefato (só até 4 colunas, células `flex-col`, sem caixa de vazio/caption) — é molde, não contrato (D6).
|
||||
- A superfície e o gate existem: `page.tsx:49-56` só carrega o resumo para `staff = isStaffCampaignRole`; leader nunca vê; communicator é redirecionado antes (D9).
|
||||
- O manifest e2e precisa de **uma** entrada nova: o mapper da home nasce em `src/lib/card*` (prefixo que acorda só `frontend`, o site público) e não acorda a família da home (D8).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
V["Visitante anônimo"] -->|"baixa o PNG (2 CTAs convergem)"| H["CardComposer.handleDownload"]
|
||||
H -->|"após downloadBlob · sendCardDownloadEvent(model.id, slug?)"| API["POST /api/content-events"]
|
||||
API -->|"same-origin → schema → limiter → catálogo (getCardModel/getStateDeputyCard)"| REC["recordContentEvent · Local API overrideAccess"]
|
||||
REC --> CE[("contentEvent · subjectType=card · subjectId=modelId · variant=slug do estadual")]
|
||||
CE --> AGG["loadContentEventCountsBySubject — 1 query · 6 ids"]
|
||||
AGG --> L["loadCampaignHomeSummary → CampaignHomeSummaryView.cardDownloads"]
|
||||
L --> UI["CampaignHomeCardDownloads · bloco 'Cards' no summarySlot (staff)"]
|
||||
API -.->|"variante peça: inalterada"| REC
|
||||
```
|
||||
|
||||
**Opções consideradas (abordagem geral):** A) estender o mecanismo C213 ponta a ponta (variante aditiva na mesma rota + coluna `variant` nullable + wrapper de beacon + mapper/agregação reusados + bloco novo na home); B) analytics de cards próprio (collection/rota/agregação novos); C) serviço externo de eventos customizados.
|
||||
**Recomendação:** **A** — o C213 já entregou a collection, o writer, o limiter, a rota e a agregação parametrizada por `subjectType`; o S32 é a extensão aditiva prevista (o vocabulário já tem `card`). Cabe no appetite sem nenhum serviço novo e mantém a privacidade fail-closed.
|
||||
**Rejeitadas:** **B** porque a intenção veda explicitamente o segundo analytics e o C213 nasceu genérico para não haver retrabalho; **C** porque soma um serviço ao homeserver e ainda exigiria eventos customizados por modelo — reavaliar só se a pergunta virar funil/pageview (fora desta fatia).
|
||||
|
||||
### D1 — Extensão do contrato público
|
||||
|
||||
**Opções:** A) variante aditiva na mesma rota `POST /api/content-events` (card discriminado por `cardModelId`), reusando same-origin, limiter e teto de body; B) rota nova `/api/card-events`; C) segunda collection; D) aceitar `subjectType`/`subjectId` arbitrários do cliente.
|
||||
**Recomendação:** **A** — o cliente manda chaves públicas (`cardModelId` + `stateDeputySlug?`) e o servidor valida contra o catálogo fechado (`isCardModelId`/`getCardModel`, `getStateDeputyCard`) — nunca aceita id arbitrário. O contrato de peça (`{ type, pieceSlug }`) fica byte-compatível; exatamente um assunto por body; card só aceita `type: 'download'` no v1.
|
||||
**Rejeitadas:** **B** porque duplicaria same-origin/limiter/schema/body bounded sem cobertura nova; **C** porque a intenção e o C213 vedam collection paralela; **D** porque qualquer um inflaria contador de modelo arbitrário (o catálogo é enum de 6; o gate de publicação da peça não existe aqui, o catálogo é o gate).
|
||||
|
||||
### D2 — Persistência do slug do estadual
|
||||
|
||||
**Opções:** A) coluna nullable `variant` (text) em `content_event` via migration; B) compor o slug no `subjectId` (ex. `time-do-estadual:julio`); C) não persistir (só o model id).
|
||||
**Recomendação:** **A** — o C213 previu explicitamente a extensão ("S32 adiciona coluna nullable por migration quando precisar"); `subjectId` continua sendo o model id (chave do contador por modelo e do índice composto) e o slug vive ao lado. Migration aditiva, sem índice novo — nada lê `variant` no v1 e índice para leitura futura é migration própria. **Item que continua parando mesmo em `--auto`:** criar/aplicar migration exige confirmação humana (§Modo autônomo da skill) — o executor pausa antes de `pnpm migrate:create`/`pnpm migrate` e só segue com o OK.
|
||||
**Rejeitadas:** **B** porque quebraria o agrupamento por modelo (a agregação é `GROUP BY subject_id, type`) e transformaria o slug em parte da chave; **C** porque viola o aceite ("o evento leva também o slug do estadual escolhido") e joga fora o sinal que a coordenação pediu.
|
||||
|
||||
### D3 — Tipo do evento
|
||||
|
||||
**Opções:** A) reusar `download` do vocabulário existente (subjectType `card` distingue); B) novo tipo `download_card` no enum (migration de enum).
|
||||
**Recomendação:** **A** — o tipo descreve o ato e o assunto descreve o objeto; a agregação já agrupa por `(subject_id, type)` dentro de `subject_type`, então não há ambiguidade e o vocabulário fica estável. Zero migration de enum.
|
||||
**Rejeitadas:** **B** porque infla o vocabulário sem ganho de leitura e exigiria migration de enum desnecessária (mais um stop).
|
||||
|
||||
### D4 — Transporte client
|
||||
|
||||
**Opções:** A) estender o dono `src/lib/contentEvents.ts` com um wrapper tipado de card (DRY do beacon sendBeacon/keepalive); B) módulo paralelo `src/lib/cardEvents.ts`; C) `fetch` direto no `CardComposer`.
|
||||
**Recomendação:** **A** — o transporte (sendBeacon → fallback `fetch keepalive`, nunca lança, nunca atrasa) vira um envio único privado usado pelos dois wrappers; `sendContentPieceEvent` fica intacto e `sendCardDownloadEvent(modelId, stateDeputySlug?)` é novo. Uma fonte de comportamento de rede, um pin de "nunca lança".
|
||||
**Rejeitadas:** **B** porque duplicaria o dono do mesmo beacon (segundo caminho para o mesmo endpoint); **C** porque espalharia a regra de fail-soft no componente e o pin de transporte ficaria sem dono.
|
||||
|
||||
### D5 — Agregação e mapper
|
||||
|
||||
**Opções:** A) reusar `loadContentEventCountsBySubject` (1 query, 6 ids, `subjectType: 'card'`) + mapper puro novo em `src/lib/` (ordem natural do catálogo, estados); B) reusar `contentPieceCirculation` (mapper de peça); C) rollup persistido.
|
||||
**Recomendação:** **A** — a agregação do C213 é parametrizada por `subjectType` justamente para o S32; o mapper de peça tem semântica de peça (id numérico, quatro contadores, `neverPublished`, zeros × histórico) e não serve ao card (id é o model id, um contador só, o vazio tem desenho próprio). O mapper novo é puro: `cardDownloadCountsFromRows` (ignora `type ≠ download` e ids fora do catálogo; normaliza inteiros ≥ 0) e `resolveCardDownloadCounts` com `data | empty | unavailable`; os literais do design moram nele (uma fonte, unit-pinned).
|
||||
**Rejeitadas:** **B** porque forçaria condicionais de card dentro do mapper de peça (acoplamento errado e teste de peça a mexer); **C** porque perde o evento cru e adiciona upsert/lock para um contador de 6 linhas.
|
||||
|
||||
### D6 — Superfície
|
||||
|
||||
**Opções:** A) estender `loadCampaignHomeSummary` + componente novo em `src/components/campaign/dashboard/` portando o artefato classe-a-classe; B) estender `CampaignMetricStrip` (6 colunas + layout em linha + vazio); C) improvisar no markup.
|
||||
**Recomendação:** **A** — `CampaignMetricStrip` é molde, não contrato: é `flex-col` (rótulo acima do valor), conhece no máximo 4 colunas (`gridColsBySize`) e não tem caixa de vazio nem caption; estendê-lo mexeria num widget compartilhado (E8 etc.) para um caller. O componente novo (`CampaignHomeCardDownloads`) porta o artefato: `dl` com 6 colunas ≥ `sm`, linhas rótulo-esquerda/valor-direita `< sm` com `min-height: 58px` e separadores, `tabular-nums`, caption de honestidade sempre visível, caixa vazia honesta; tokens `bg-card`, `ring-foreground/10`, `text-muted-foreground`, `rounded-xl`. A seção entra **depois** do resumo, dentro do mesmo `summarySlot` (a página compõe os dois componentes), sem reestruturar o `CampaignHomeSummary` — o resumo no artefato é contexto e a crítica final (trigger c) decide qualquer ajuste de separação. As pílulas "VALORES FICTÍCIOS DO GATE"/"EXEMPLO" do artefato **não** são copy de produção.
|
||||
**Rejeitadas:** **B** pelo churn no widget compartilhado e pela estrutura errada (não produz o layout do artefato); **C** porque o implementador não decide estrutura visual (§Design da execution-pipeline).
|
||||
|
||||
### D7 — Estado de falha da agregação
|
||||
|
||||
**Opções:** A) estado "indisponível" desenhado — o designer estende o artefato (trigger a) antes do markup; B) omitir a seção inteira quando a agregação falha (fail-soft sem estado novo); C) renderizar o vazio ("Nenhum download ainda") na falha.
|
||||
**Recomendação:** **B** — a falha não é um dado: o bloco é acessório e a ausência não afirma nada (não pode ser confundida com "Nenhum download ainda", que segue reservado aos zeros reais); evita um estado visual novo para uma leitura degradada rara e não inventa estrutura. O domínio mantém `unavailable` distinto de `empty` (unit-pinned) e o componente devolve `null` — quando o produto quiser ver "indisponível", o estado já existe e o designer estende (trigger a).
|
||||
**Rejeitadas:** **C** porque é mentira (a intenção veda); **A** porque custa uma extensão do artefato + estado novo para um caso raro que o appetite não pede — gatilho de revisitação registrado: leitura degradando com frequência ou pedido explícito de produto → trigger a.
|
||||
|
||||
### D8 — Testes e manifest
|
||||
|
||||
**Opções:** A) unit (mapper/transporte/componente) + int (rota/loader) + e2e estendendo `frontend` (download real) e `campaignHomeActions` (bloco na home), com entry nova no manifest para o mapper da home; B) família e2e nova `cardDownloads`; C) sem e2e (só unit/int).
|
||||
**Recomendação:** **A** — as duas famílias existem e já cobrem as superfícies: `frontend` tem os downloads reais de card (`frontend.e2e.spec.ts:1848`, `:2035-2037`, `:2411`) e `campaignHomeActions` tem a home staff com sessão compartilhada e `campaign.fixtures.payload`. Unit cobre mapper (ordem/estados/literais) e transporte (nunca lança); int cobre o contrato HTTP (grava com `variant`; model id inválido, slug em modelo sem `stateDeputyPicker`, picker sem slug e slug desconhecido → 400 sem gravar; agregação `card` não vaza para `peca` nem o inverso) e o loader (data/empty/unavailable). E2E do download observa o POST com `page.waitForRequest`/`postDataJSON` (um modelo sem picker + `minha-colinha`/`time-do-estadual` com slug). E2E da home pina os 6 rótulos na ordem, a caption e o contador **≥ semeado** — contadores de card são globais por model id no DB compartilhado e a família `frontend` também dispara beacons de card no mesmo run; número exato seria flake, então exato fica no unit/int. Manifest: adicionar `src/lib/cardDownloadCounts.ts` aos prefixes da entry da home (`scripts/lib/e2e-affected-manifest.mjs:296-312`) — `src/lib/card` sozinho acorda só `frontend`, que não testa a home; schema/vocabulário/rota/collection já caem em `frontend`/`frontendConteudos` e `src/lib/schemas` é risk prefix já mapeado (sem `unmapped-risk`). Nota honesta: o PR inclui migration → `ci-scope` classifica `curated`; as famílias afetadas rodam local (discricionário) e no full do `verify` do deploy.
|
||||
**Rejeitadas:** **B** porque pagaria project/manifest/fixtures por cobertura que A já dá (C213 fixou "estender, sem família nova"); **C** porque o POST na mesma rota é contrato HTTP alterado e o wiring do beacon só existe de ponta a ponta no browser.
|
||||
|
||||
### D9 — Acesso e papéis
|
||||
|
||||
**Opções:** A) herdar o gate de staff da home (bloco só no `summarySlot` de coordinator/advisor/candidate; leader nunca; communicator redirecionado antes); B) gate novo por role no loader; C) bloco para qualquer sessão de campanha.
|
||||
**Recomendação:** **A** — a home já calcula `staff = isStaffCampaignRole(user.role)` e só carrega o resumo para staff; leader é lockdown e nunca vê o `summarySlot`; communicator é redirecionado para a vertical antes (`page.tsx:25-56`). O agregado é SQL cru sem access control (precedente C213) e o caller é o gate; advisor vê o bloco (é staff; a intenção pede coordenação **e** assessoria). Nenhum `Consent` novo — não há PII a consentir.
|
||||
**Rejeitadas:** **B** porque duplicaria um gate existente e divergiria do comportamento da home; **C** porque exporia o bloco ao leader e a papéis sem home staff.
|
||||
|
||||
### D10 — Fechamento
|
||||
|
||||
**Opções:** A) uma entrada curta em `docs/changelog/2026-09-23-s32.md` + `Design tier:` no PR com a crítica final (trigger c) certificada antes do push; B) sem changelog; C) editar o agregado/HISTORY.
|
||||
**Recomendação:** **A** — OPS44/OPS85: uma entrada por entrega, nunca o agregado (gerado/gitignored); e todo diff que muda estrutura visual fecha com a crítica do designer contra o app renderizado (390/1280 + estados críticos), com o tier no PR. `DEGRADED`/tier não-primário ⇒ **para** (não abre PR; comenta a Issue e flipa `blocked`).
|
||||
**Rejeitadas:** **B** porque o registro commitado é o changelog; **C** porque o agregado é artefato gerado.
|
||||
|
||||
## Componentes / mudanças
|
||||
|
||||
**Schema / dados**
|
||||
|
||||
- **`src/collections/ContentEvent.ts`** (editar, `:50-81`): campo `variant` (text, opcional, label/description pt-BR — "slug do estadual nos modelos com escolha estadual; nunca um identificador de visitante"); nada de PII; sem hooks (não busta a tag pública); access intacto.
|
||||
- **Migration:** `pnpm migrate:create add_content_event_variant` — coluna nullable `variant` em `content_event` (aditiva, sem índice novo); revisar o SQL, commitar `.ts`/`.json`/`index.ts`, rodar `pnpm migrate` + `pnpm generate:types` local. **STOP humano mesmo em `--auto`** antes de criar/aplicar.
|
||||
- **`src/utilities/content/contentEventWrite.ts`** (editar, `:25-48`): `ContentEventInput` ganha `variant?: string`; grava `variant: event.variant ?? null`; o contrato "nunca lança/nunca atrasa" fica intacto.
|
||||
|
||||
**Contrato público**
|
||||
|
||||
- **`src/lib/contentEvents.ts`** (editar, `:60-99`): extrai o envio (sendBeacon → fallback `fetch keepalive`) para um único caminho privado e adiciona `sendCardDownloadEvent(modelId: CardModelId, stateDeputySlug?: string | null)` com corpo `{ type: 'download', cardModelId, stateDeputySlug? }`; `sendContentPieceEvent` inalterado; nunca lança.
|
||||
- **`src/lib/schemas/contentEvent.ts`** (editar, `:18-25`): variante card aditiva — `{ type: 'download', cardModelId }` validado com `isCardModelId` (uma fonte) e `stateDeputySlug?` bounded (padrão de slug); contrato de peça inalterado; exatamente um assunto por body.
|
||||
- **`src/app/(frontend)/api/content-events/route.ts`** (editar, `:70-99`): ramo card — `getCardModel` (id fora do catálogo → 400), `stateDeputyPicker` exige slug resolvido por `getStateDeputyCard` (faltando/desconhecido → 400), modelo sem picker rejeita slug → 400; grava `recordContentEvent({ type: 'download', subjectType: 'card', subjectId: modelId, variant: slug ?? null })`; mesmo `isSameOriginRequest`, limiter, body ≤ 4 KB e `204 no-store`; ramo peça intacto.
|
||||
|
||||
**Instrumentação (ponto único)**
|
||||
|
||||
- **`src/components/cards/CardComposer.tsx`** (editar, `handleDownload` `:576-590`): após `downloadBlob(blob, cardFileName(model.id))`, `sendCardDownloadEvent(model.id, selectedDeputy?.slug ?? null)` — fire-and-forget, sem bloquear/atrasar; erro de geração não conta; os dois CTAs (`:1180-1193`, `:1200-1207`) seguem convergindo no mesmo handler.
|
||||
|
||||
**Leitura / home**
|
||||
|
||||
- **`src/lib/cardDownloadCounts.ts`** (novo, puro/client-safe): ordem = `CARD_MODELS`; `cardDownloadCountsFromRows` (type-only de `contentEvents`); `resolveCardDownloadCounts` → `data | empty | unavailable`; literais do design (`CARD_DOWNLOAD_COUNTS_TITLE/SUBTITLE/CAPTION/EMPTY_TITLE/EMPTY_BODY/EMPTY_CAPTION`). Reusa `CARD_MODELS` (rótulo/ordem) e `ContentEventAggregateRow` (tipo).
|
||||
- **`src/utilities/campaignDashboardData.ts`** (editar, `:231-264`): `loadCampaignHomeCardDownloads(payload)` exportado (1 query com `subjectType: 'card'`, os 6 ids; falha → `{ state: 'unavailable' }`); `CampaignHomeSummaryView` ganha `cardDownloads`; `loadCampaignHomeSummary` anexa. Reusa `loadContentEventCountsBySubject` e o mapper novo.
|
||||
- **`src/components/campaign/dashboard/CampaignHomeCardDownloads.tsx`** (novo): porte classe-a-classe do artefato (strip/linhas/caption/caixa vazia); `unavailable` → `null` (D7); formata contagens com `formatElectionNumber` (mesmo formatador da home); sem `CampaignMetricStrip`.
|
||||
- **`src/app/(campaign)/campanha/(app)/page.tsx`** (editar, `:49-56`): `summarySlot` passa a compor `CampaignHomeSummary` + `CampaignHomeCardDownloads view={summaryView.cardDownloads}`.
|
||||
|
||||
**Testes / manifest / changelog**
|
||||
|
||||
- **Unit:** `tests/unit/cardDownloadCounts.unit.spec.ts` (novo; ordem/estados/literais/normalização/ids fora do catálogo); `tests/unit/contentEvents.unit.spec.ts` (editar: pin de campos passa a `['type','subjectType','subjectId','variant']` sem PII; `sendCardDownloadEvent` corpo com/sem slug, beacon preferido, fallback, nunca lança); `tests/unit/campaignHomeCardDownloads.unit.spec.tsx` (novo; 6 rótulos na ordem, vazio desenhado, `unavailable` não renderiza "Nenhum download ainda"); `tests/unit/campaignHomeSummary.unit.spec.tsx` (editar: `view` ganha `cardDownloads`).
|
||||
- **Int:** `tests/int/contentEvents.int.spec.ts` (editar, reusa o harness existente): POST card grava com `variant`; id inválido / slug em modelo sem picker / picker sem slug / slug desconhecido → 400 sem gravar; `card` não vaza para `peca` nem vice-versa; `loadCampaignHomeCardDownloads` data/empty/unavailable (mock de falha, padrão `mockResolvedValueOnce` do arquivo).
|
||||
- **E2E:** `tests/e2e/frontend.e2e.spec.ts` (editar; família `frontend`) — observar o POST no download real (`waitForRequest` + `postDataJSON`) para um modelo sem picker e para `minha-colinha`/`time-do-estadual` com o slug; `tests/e2e/campaignHomeActions.e2e.spec.ts` (editar; família `campaignHomeActions`) — semear eventos card via `campaign.fixtures.payload.create({ collection: 'contentEvent', overrideAccess: true })`, pinar bloco/rótulos/caption e contador ≥ semeado, e líder sem o bloco.
|
||||
- **`scripts/lib/e2e-affected-manifest.mjs`** (editar, entry da home `:296-312`): acrescentar `src/lib/cardDownloadCounts.ts` (o resto já cai nas entries existentes).
|
||||
- **Changelog:** `docs/changelog/2026-09-23-s32.md` (uma entrada curta; nunca o agregado/HISTORY).
|
||||
|
||||
**Access / Consent:** nenhum gate novo — o bloco herda `isStaffCampaignRole` da home; leader nunca; agregado SQL sem access control com o caller como gate (precedente C213); **sem `Consent` novo** (não há PII a consentir) — fail-closed de privacidade.
|
||||
|
||||
### Dados → forma (pergunta 3 de `data-presentation`)
|
||||
|
||||
- **Forma escolhida:** seis contadores **absolutos** lado a lado, na ordem natural do catálogo — 6 colunas no desktop, linhas rótulo-esquerda/valor-direita no mobile, caption "Os números contam downloads, não pessoas ou visitantes únicos." sempre visível; antes do primeiro download, a caixa vazia honesta ("Nenhum download ainda") em vez de seis zeros. É a forma mais pobre que ainda desbloqueia as decisões da intenção (qual modelo divulgar/reforçar; se o funil de cards está sendo usado).
|
||||
- **Rejeitadas:** total/soma de "usos" (esconde o modelo — a decisão é por modelo); %/participação (base anônima e desconhecida; sugere precisão inexistente); série/janela/gráfico (vira dashboard; o `createdAt` já habilita a janela como próximo passo, via migration); ranking (a ordem é a do catálogo, nunca placar); barra/heatmap (mesma razão do %); **drill-down por dobradinha no v1** (o slug é capturado para leitura futura, mas a superfície são os 6 modelos — ver design "Só seis modelos").
|
||||
- **Honestidade do dado:** downloads ≠ pessoas; a superfície não sugere visitante único e não soma sinais.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / schema+server — quota ~55%:** campo `variant` + migration (**STOP humano mesmo em `--auto`**) + `generate:types`; `recordContentEvent` com `variant`; variante card no schema/rota com validação de catálogo; wrapper `sendCardDownloadEvent` + call site no `CardComposer`; units e int verdes antes de seguir. Tracer bullet: baixar um card no dev gera 1 linha `subjectType: card`/`variant` e a agregação `card` devolve o contador.
|
||||
2. **UI — quota ~30%:** mapper puro + `loadCampaignHomeCardDownloads` + `CampaignHomeSummaryView.cardDownloads`; componente novo portando o artefato classe-a-classe; composição no `summarySlot`; unit do mapper/componente verdes. `unavailable` não renderiza (D7).
|
||||
3. **Gates — quota ~15%:** `pnpm gate:fast` por iteração; `pnpm test:int`; e2e local afetado (discricionário: `frontend` + `campaignHomeActions --workers=1`); **crítica final do designer (trigger c) certificada antes do push** com `Design tier:` no PR; changelog commitado; `pnpm push`; PR com `Closes #1273`; `*-impl.md` no commit da entrega.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Drill-down por dobradinha no v1** — o `variant` é persistido (aceite) mas não vira segunda superfície/rota; leitura por slug é item próprio (gatilho: produto pedir "qual dobradinha").
|
||||
- **Contar abertura de galeria/prévia/estúdio ou o clique** — só o pós-`downloadBlob` conta; o resto é abertura, não download.
|
||||
- **Segundo mecanismo/collection/rota/serviço** — proibido; o card é `subjectType` da mesma `contentEvent`.
|
||||
- **Índice em `variant` / rollup persistido / janela-retenção** — YAGNI no v1; leitura futura ganha migration própria.
|
||||
- **Retry/queue/entrega garantida do beacon** — fail-soft: contador a menos é aceitável; nada de fila/outbox.
|
||||
- **Visitor id/cookie/IP/UA/fingerprint/Consent** — proibido pela intenção; nenhum campo desses (pin de teste).
|
||||
- **Segunda superfície "para a comunicação"** — home staff agora; a vertical depois, se o produto pedir.
|
||||
- **Alterar `CampaignMetricStrip`/`contentPieceCirculation`/agregação** — o widget é de outros callers e o mapper de peça tem outra semântica; nada de acoplar card ali.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Migration:** aditiva (coluna nullable), aplicada pelo deploy antes do build; SQL revisado e `pnpm migrate` local; **stop humano mesmo em `--auto`** — sem confirmação, a execução pausa (não é bloqueio silencioso).
|
||||
- **Flake de e2e por contador global:** contadores de card são por model id no DB compartilhado e a família `frontend` dispara beacons no mesmo run — a home pina **≥ semeado**; número exato fica no unit/int (isolados).
|
||||
- **Beacon perdido (aba fechada/processo morto/400 por drift):** aceito (contador a menos); `sendBeacon` sobrevive ao clique e o fallback `keepalive` cobre o resto.
|
||||
- **Drift do catálogo de estaduais:** slug desconhecido → 400 sem gravar; o catálogo é commitado e snapshot-pinned (`state-deputy-catalog.snapshot.json`).
|
||||
- **Falha da agregação:** `{ ok: false }` → `unavailable` no domínio → seção omitida (D7); a home nunca depende do contador.
|
||||
- **Cache/tag pública:** nenhum hook na collection e nenhum write toca `contentPieces`; a home é `force-dynamic`.
|
||||
- **PR com migration = `curated` no CI:** as famílias afetadas rodam no e2e local discricionário e no full do `verify` do deploy — registrado honestamente (D8).
|
||||
- **Estados não cobertos pelo artefato:** só a falha (D7-b, sem markup novo); data/empty são portados do artefato; a crítica final (trigger c) é fail-closed (`DEGRADED` ⇒ para antes do push).
|
||||
|
||||
## Desvios registrados na execução
|
||||
|
||||
- **Crítica final do design (trigger c):** a primeira passada foi **reprovada** pelo `designer` e os ajustes entraram antes do push — o divisor saiu de baixo do título "Cards" para o fim do resumo (`CampaignHomeSummary` ganhou `border-b`; o `h2` do bloco neutraliza a regra global `h2 { border-b pb-2 }` do stylesheet), o bloco ganhou respiro inferior (`pb-4 md:pb-6`) e o `summarySlot` passou a rolar no mobile (`max-md:min-h-0 max-md:overflow-y-auto` no `CampaignHomeLayout`) para as seis linhas e a caption ficarem alcançáveis acima do dock de ações. Re-capturado (390/1280, dados/vazio, com o estado rolado) e **certificado em tier primário** (`Design tier: openai/gpt-5.6-sol`).
|
||||
- **Simplify:** schema da rota com objetos **estritos** (um body com dois assuntos vira 400, não uma peça silenciosa); write único do ramo card depois dos gates de catálogo (sem duplicar o `recordContentEvent`); `resolveCardDownloadCounts` (nome do plano) no lugar de `cardDownloadCountsView`; `CARD_DOWNLOAD_EVENT_TYPE` privado (knip).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: 1 evento anônimo por download com o model id (+ slug do estadual nos modelos com escolha estadual); bloco "Cards" na home staff com 6 contadores absolutos na ordem do catálogo, sem total/%/série/ranking/dashboard/rota nova.
|
||||
- [ ] Guardrails: sem PII (sem IP persistido, cookie, user-agent, identidade ou rastreio entre modelos); sem `Consent` novo; fail-soft (o download nunca depende da contagem); só o download concluído conta.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: mesmo mecanismo do C213 (sem collection/rota paralela); Local API com `overrideAccess: true` documentado no writer; identificadores em inglês/copy pt-BR; migrations existentes intocadas; nenhum top-level novo em `src/utilities`.
|
||||
- [ ] Testes de domínio previstos (unit/int) onde o contrato/write path muda: rota card (grava com `variant`; recusas sem gravar), isolamento `card`×`peca`, mapper/estados/literais, transporte nunca lança, loader data/empty/unavailable.
|
||||
- [ ] Migration `add_content_event_variant` + `payload-types.ts` commitados; SQL revisado (aditivo); `pnpm migrate` local verde; **stop humano respeitado**.
|
||||
- [ ] Manifest e2e com a entry do mapper da home; e2e local afetado rodado (discricionário); `pnpm gate:fast` verde.
|
||||
- [ ] Design: porte classe-a-classe do artefato (sem as pílulas do gate); crítica final (trigger c) certificada antes do push com `Design tier:` no PR.
|
||||
- [ ] Changelog `docs/changelog/2026-09-23-s32.md`; PR com `Closes #1273`; impl plan incluído no commit da entrega.
|
||||
|
||||
## Self-score decision-quality
|
||||
|
||||
**5/5** — (1) decisões caras com rejeitadas: contrato público (D1), coluna/`variant` (D2), tipo (D3), transporte (D4), agregação/mapper (D5), superfície (D6), estados (D7), testes/manifest (D8), acesso (D9) e fechamento (D10); (2) cabe no appetite herdado: reusa o mecanismo C213 ponta a ponta e o que nasce é um campo nullable, um wrapper de beacon, um mapper puro e um bloco — sem collection/rota/serviço; (3) rabbit holes nomeados (drill-down, abertura/prévia, segundo mecanismo, rollup/índice, retry, PII/Consent, segunda superfície); (4) depth check reusa `loadContentEventCountsBySubject`, `recordContentEvent`, limiter/same-origin/body bounded, `CARD_MODELS`/`getCardModel`/`getStateDeputyCard`, o gate de staff da home e os harnesses de teste — `CampaignMetricStrip` foi deliberadamente não reusado, com o motivo registrado; (5) outcome intacto: contadores anônimos por modelo na home staff, honestos, fail-soft, sem total/%/série/dashboard, com o slug do estadual capturado sem virar segunda superfície.
|
||||
|
|
@ -299,6 +299,9 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
'src/components/campaign/dashboard',
|
||||
'src/utilities/homeSearch',
|
||||
'src/utilities/campaignDashboardData',
|
||||
// S32 — the pure card-download counters view model of the home block;
|
||||
// `src/lib/card` alone only wakes the public `frontend` spec.
|
||||
'src/lib/cardDownloadCounts',
|
||||
'src/lib/campaignHomeSearchHits',
|
||||
'src/lib/homeSearchShare',
|
||||
'src/lib/homeSearchExcludeCurrentEntity',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { getPayload } from 'payload'
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { CampaignHomeActions } from '@/components/campaign/dashboard/CampaignHomeActions'
|
||||
import { CampaignHomeCardDownloads } from '@/components/campaign/dashboard/CampaignHomeCardDownloads'
|
||||
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
|
||||
import { CampaignHomeStaffChrome } from '@/components/campaign/dashboard/CampaignHomeStaffChrome'
|
||||
import { CampaignHomeSummary } from '@/components/campaign/dashboard/CampaignHomeSummary'
|
||||
|
|
@ -51,7 +52,12 @@ export default async function CampaignHomePage() {
|
|||
loadCampaignHomeSummary(payload, user),
|
||||
loadHomeSearchSuggestions(payload, user),
|
||||
])
|
||||
summarySlot = <CampaignHomeSummary view={summaryView} />
|
||||
summarySlot = (
|
||||
<>
|
||||
<CampaignHomeSummary view={summaryView} />
|
||||
<CampaignHomeCardDownloads view={summaryView.cardDownloads} />
|
||||
</>
|
||||
)
|
||||
initialSuggest = suggest
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { getCardModel } from '@/lib/cardModels'
|
||||
import { contentEventRequestSchema } from '@/lib/schemas/contentEvent'
|
||||
import { getStateDeputyCard } from '@/lib/stateDeputyCatalog'
|
||||
import {
|
||||
checkContentEventRateLimit,
|
||||
contentEventClientKey,
|
||||
|
|
@ -12,18 +14,19 @@ 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.
|
||||
* C213/S32 — the public anonymous beacon of the Central de Conteúdos and of the
|
||||
* personalized cards. The visitor's browser fires one event (abertura, download,
|
||||
* compartilhar WhatsApp/link; the card download) 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.
|
||||
* third-party page or a trivial loop, never a security boundary. Each variant
|
||||
* has its own public gate: the piece writes nothing when the slug does not
|
||||
* resolve to a PUBLISHED piece, and the card writes nothing when the model id
|
||||
* or the state-deputy slug is not in the committed catalogs. 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
|
||||
|
|
@ -87,6 +90,31 @@ export const POST = async (request: Request): Promise<NextResponse> => {
|
|||
return silentResponse(204)
|
||||
}
|
||||
|
||||
// S32 — the card download: the model and the state deputy are validated
|
||||
// against the committed catalogs; the route never trusts an arbitrary id.
|
||||
if ('cardModelId' in parsed.data) {
|
||||
const model = getCardModel(parsed.data.cardModelId)
|
||||
if (!model) return silentResponse(400)
|
||||
|
||||
const requestedDeputySlug = parsed.data.stateDeputySlug
|
||||
let variant: string | null = null
|
||||
if (model.stateDeputyPicker === true) {
|
||||
const deputy = requestedDeputySlug ? getStateDeputyCard(requestedDeputySlug) : undefined
|
||||
if (!deputy) return silentResponse(400)
|
||||
variant = deputy.slug
|
||||
} else if (requestedDeputySlug) {
|
||||
return silentResponse(400)
|
||||
}
|
||||
|
||||
await recordContentEvent({
|
||||
type: parsed.data.type,
|
||||
subjectType: 'card',
|
||||
subjectId: model.id,
|
||||
variant,
|
||||
})
|
||||
return silentResponse(204)
|
||||
}
|
||||
|
||||
const piece = await getPublishedContentPieceBySlug(parsed.data.pieceSlug).catch(() => null)
|
||||
if (!piece) return silentResponse(204)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,10 +15,11 @@ import { canReadContentPiece } from '@/utilities/campaignAccess'
|
|||
* 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.
|
||||
* (the internal piece id; the card model id) — plus the optional `variant`
|
||||
* (S32: the state-deputy slug chosen on the team/colinha card models), 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
|
||||
|
|
@ -78,6 +79,18 @@ export const ContentEvent: CollectionConfig = {
|
|||
'Id interno da peça (peça) ou do modelo (card). Nunca um identificador de visitante.',
|
||||
},
|
||||
},
|
||||
{
|
||||
// S32 — the sub-key of the subject, when it has one: the state-deputy
|
||||
// slug chosen on the team/colinha card models. It is a public catalog
|
||||
// slug, never a visitor identifier.
|
||||
name: 'variant',
|
||||
type: 'text',
|
||||
label: 'Variante',
|
||||
admin: {
|
||||
description:
|
||||
'Slug do estadual escolhido (modelos de card com escolha estadual). 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import { ArrowDownIcon } from 'lucide-react'
|
||||
|
||||
import {
|
||||
CARD_DOWNLOAD_COUNTS_CAPTION,
|
||||
CARD_DOWNLOAD_COUNTS_EMPTY_BODY,
|
||||
CARD_DOWNLOAD_COUNTS_EMPTY_CAPTION,
|
||||
CARD_DOWNLOAD_COUNTS_EMPTY_TITLE,
|
||||
CARD_DOWNLOAD_COUNTS_SUBTITLE,
|
||||
CARD_DOWNLOAD_COUNTS_TITLE,
|
||||
type CardDownloadCountsView,
|
||||
} from '@/lib/cardDownloadCounts'
|
||||
import { formatElectionNumber } from '@/lib/electionFormat'
|
||||
|
||||
/**
|
||||
* S32 — the "Cards" block of the staff home: one absolute download counter per
|
||||
* card model, in the natural catalog order (six columns on desktop, one row per
|
||||
* model on mobile). It counts downloads, never people — no total, no
|
||||
* percentage, no series — and an unavailable aggregate omits the whole block
|
||||
* instead of claiming "no downloads yet".
|
||||
*/
|
||||
export const CampaignHomeCardDownloads = ({ view }: { view: CardDownloadCountsView }) => {
|
||||
if (view.state === 'unavailable') return null
|
||||
|
||||
return (
|
||||
<section aria-label={CARD_DOWNLOAD_COUNTS_TITLE} className="mt-6 pb-4 md:pb-6">
|
||||
{/* `border-b-0 pb-0` neutralize the global `h2 { border-b pb-2 }` rule: the
|
||||
approved divider sits at the end of the summary above, not under this
|
||||
heading. */}
|
||||
<h2 className="border-b-0 pb-0 text-base font-semibold">{CARD_DOWNLOAD_COUNTS_TITLE}</h2>
|
||||
|
||||
{view.state === 'empty' ? (
|
||||
<>
|
||||
<div className="mt-3 rounded-xl bg-card p-4 ring-1 ring-foreground/10 sm:px-5 sm:py-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="grid size-10 shrink-0 place-items-center rounded-full bg-muted text-muted-foreground"
|
||||
>
|
||||
<ArrowDownIcon className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{CARD_DOWNLOAD_COUNTS_EMPTY_TITLE}</p>
|
||||
<p className="mt-1 text-sm leading-5 text-muted-foreground">
|
||||
{CARD_DOWNLOAD_COUNTS_EMPTY_BODY}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
{CARD_DOWNLOAD_COUNTS_EMPTY_CAPTION}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{CARD_DOWNLOAD_COUNTS_SUBTITLE}</p>
|
||||
<dl className="mt-3 grid grid-cols-1 overflow-hidden rounded-xl bg-card ring-1 ring-foreground/10 sm:grid-cols-6">
|
||||
{view.counts.map((entry) => (
|
||||
<div
|
||||
key={entry.modelId}
|
||||
className="flex min-h-[58px] min-w-0 items-center justify-between gap-4 border-t border-border px-4 py-2.5 first:border-t-0 sm:block sm:min-h-0 sm:border-t-0 sm:border-l sm:first:border-l-0"
|
||||
>
|
||||
<dt className="text-xs font-medium leading-4 text-muted-foreground">
|
||||
{entry.label}
|
||||
</dt>
|
||||
<dd className="text-lg font-medium tracking-tight tabular-nums sm:mt-1">
|
||||
{formatElectionNumber(entry.count)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
{CARD_DOWNLOAD_COUNTS_CAPTION}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -51,7 +51,12 @@ export const CampaignHomeLayout = ({
|
|||
>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{summarySlot ? (
|
||||
<div className="min-w-0" data-slot="home-summary">
|
||||
// The summary may outgrow the chrome on phones (S32): scroll it
|
||||
// inside the slot instead of clipping it behind the actions dock.
|
||||
<div
|
||||
className="min-w-0 max-md:min-h-0 max-md:overflow-y-auto"
|
||||
data-slot="home-summary"
|
||||
>
|
||||
{summarySlot}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,10 @@ const HomeSummaryDelta = ({ delta }: { delta: number | null }) => {
|
|||
}
|
||||
|
||||
export const CampaignHomeSummary = ({ view }: { view: CampaignHomeSummaryView }) => (
|
||||
<section aria-label="Resumo da campanha" className="flex flex-col gap-4 pb-4 md:pb-6">
|
||||
<section
|
||||
aria-label="Resumo da campanha"
|
||||
className="flex flex-col gap-4 border-b pb-4 md:pb-6"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm text-muted-foreground">Votos estimados</p>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import {
|
|||
renderPhotoCard,
|
||||
renderTeamCard,
|
||||
} from '@/lib/cardRender'
|
||||
import { sendCardDownloadEvent } from '@/lib/contentEvents'
|
||||
import { getStateDeputyCard, type StateDeputyCatalogEntry } from '@/lib/stateDeputyCatalog'
|
||||
|
||||
const primaryButtonClassName =
|
||||
|
|
@ -582,6 +583,9 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
try {
|
||||
const blob = await canvasToPngBlob(canvas)
|
||||
downloadBlob(blob, cardFileName(model.id))
|
||||
// S32 — anonymous download count (fire-and-forget, never blocks/throws);
|
||||
// the state-deputy slug rides along on the models that have a picker.
|
||||
sendCardDownloadEvent(model.id, selectedDeputy?.slug ?? null)
|
||||
} catch {
|
||||
setDownloadError('Não foi possível gerar o arquivo agora. Tente de novo.')
|
||||
} finally {
|
||||
|
|
|
|||
71
src/lib/cardDownloadCounts.ts
Normal file
71
src/lib/cardDownloadCounts.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* S32 — the pure view model of the anonymous card-download counters of the
|
||||
* staff home. One absolute counter per card model, in the natural catalog
|
||||
* order; no total, no percentage, no series: the numbers count downloads,
|
||||
* never people (there is no visitor identity to count).
|
||||
*
|
||||
* Client-safe and free of Payload/Next: the loader feeds it the aggregate rows
|
||||
* (or `null` when the read failed) and the component renders the view.
|
||||
*/
|
||||
|
||||
import { CARD_MODELS, type CardModelId } from '@/lib/cardModels'
|
||||
import type { ContentEventAggregateRow } from '@/lib/contentEvents'
|
||||
|
||||
export const CARD_DOWNLOAD_COUNTS_TITLE = 'Cards'
|
||||
export const CARD_DOWNLOAD_COUNTS_SUBTITLE = 'Downloads anônimos acumulados desde o lançamento.'
|
||||
export const CARD_DOWNLOAD_COUNTS_CAPTION =
|
||||
'Os números contam downloads, não pessoas ou visitantes únicos.'
|
||||
export const CARD_DOWNLOAD_COUNTS_EMPTY_TITLE = 'Nenhum download ainda'
|
||||
export const CARD_DOWNLOAD_COUNTS_EMPTY_BODY =
|
||||
'Os contadores aparecem quando alguém baixar um card.'
|
||||
export const CARD_DOWNLOAD_COUNTS_EMPTY_CAPTION =
|
||||
'A contagem será anônima e mostrará downloads por modelo, nunca pessoas.'
|
||||
|
||||
/** The one event type counted per card model (S32 v1). */
|
||||
const CARD_DOWNLOAD_EVENT_TYPE = 'download'
|
||||
|
||||
export type CardDownloadCount = {
|
||||
modelId: CardModelId
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export type CardDownloadCountsView =
|
||||
| { state: 'data'; counts: CardDownloadCount[] }
|
||||
| { state: 'empty' }
|
||||
| { state: 'unavailable' }
|
||||
|
||||
/**
|
||||
* Counts per model id, ignoring anything that is not the card-download event
|
||||
* and any id outside the catalog (the mapping below is the catalog itself).
|
||||
*/
|
||||
export const cardDownloadCountsFromRows = (
|
||||
rows: readonly ContentEventAggregateRow[],
|
||||
): CardDownloadCount[] => {
|
||||
const countByModelId = new Map<string, number>()
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.type !== CARD_DOWNLOAD_EVENT_TYPE) continue
|
||||
const count = Number.isFinite(row.count) ? Math.max(0, Math.trunc(row.count)) : 0
|
||||
countByModelId.set(row.subjectId, (countByModelId.get(row.subjectId) ?? 0) + count)
|
||||
}
|
||||
|
||||
return CARD_MODELS.map((model) => ({
|
||||
modelId: model.id,
|
||||
label: model.label,
|
||||
count: countByModelId.get(model.id) ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* `null` means the aggregate read failed — the view is `unavailable`, which
|
||||
* never renders as "no downloads yet" (that state is only for real zeros).
|
||||
*/
|
||||
export const resolveCardDownloadCounts = (
|
||||
rows: readonly ContentEventAggregateRow[] | null,
|
||||
): CardDownloadCountsView => {
|
||||
if (rows === null) return { state: 'unavailable' }
|
||||
|
||||
const counts = cardDownloadCountsFromRows(rows)
|
||||
return counts.some((entry) => entry.count > 0) ? { state: 'data', counts } : { state: 'empty' }
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@
|
|||
* components import the same vocabulary, so a new event type is one edit.
|
||||
*/
|
||||
|
||||
import type { CardModelId } from '@/lib/cardModels'
|
||||
|
||||
export const CONTENT_EVENT_ENDPOINT = '/api/content-events'
|
||||
|
||||
export const CONTENT_EVENT_TYPES = [
|
||||
|
|
@ -62,18 +64,20 @@ type ContentEventBeaconBody = {
|
|||
pieceSlug: string
|
||||
}
|
||||
|
||||
const beaconBody = ({ type, pieceSlug }: ContentEventBeaconBody): string =>
|
||||
JSON.stringify({ type, pieceSlug })
|
||||
type CardDownloadBeaconBody = {
|
||||
type: 'download'
|
||||
subjectType: 'card'
|
||||
cardModelId: CardModelId
|
||||
stateDeputySlug?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one anonymous piece event and forgets it. `sendBeacon` survives the
|
||||
* Sends one anonymous event body 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 })
|
||||
|
||||
const postContentEventBody = (body: string): void => {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
||||
const sent = navigator.sendBeacon(
|
||||
|
|
@ -97,3 +101,29 @@ export const sendContentPieceEvent = (type: ContentEventType, pieceSlug: string)
|
|||
// Fail-soft: the public action never depends on the count.
|
||||
}
|
||||
}
|
||||
|
||||
/** One anonymous event of a published piece (C213). */
|
||||
export const sendContentPieceEvent = (type: ContentEventType, pieceSlug: string): void => {
|
||||
const body: ContentEventBeaconBody = { type, pieceSlug }
|
||||
postContentEventBody(JSON.stringify(body))
|
||||
}
|
||||
|
||||
/**
|
||||
* S32 — one anonymous download event of a personalized card: the model id (the
|
||||
* public catalog key) and, on the models with a state-deputy picker, the slug
|
||||
* of the chosen state deputy. Same transport and same fail-soft contract as the
|
||||
* piece beacon: it never throws and never delays the download.
|
||||
*/
|
||||
export const sendCardDownloadEvent = (
|
||||
modelId: CardModelId,
|
||||
stateDeputySlug?: string | null,
|
||||
): void => {
|
||||
const body: CardDownloadBeaconBody = {
|
||||
type: 'download',
|
||||
subjectType: 'card',
|
||||
cardModelId: modelId,
|
||||
}
|
||||
if (stateDeputySlug) body.stateDeputySlug = stateDeputySlug
|
||||
|
||||
postContentEventBody(JSON.stringify(body))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,44 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { isCardModelId, type CardModelId } from '@/lib/cardModels'
|
||||
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).
|
||||
* C213/S32 — the wire contract of the public `POST /api/content-events`.
|
||||
*
|
||||
* Two additive variants, exactly one subject per body (strict objects: an
|
||||
* extra key is a malformed body, never a silently ignored one):
|
||||
* - a published piece (`{ type, pieceSlug }`): 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 personalized card download (`{ type: 'download', subjectType: 'card',
|
||||
* cardModelId, stateDeputySlug? }`): the client names the public catalog keys
|
||||
* and the route validates both against the committed catalogs (the card model
|
||||
* and the state deputy), so an arbitrary id or slug 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
|
||||
/** Slugs are generated by `contentPieceSlugBase` and by the deputy catalog. */
|
||||
const PUBLIC_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
const PUBLIC_SLUG_MAX_LENGTH = 120
|
||||
|
||||
export const contentEventRequestSchema = z.object({
|
||||
const publicSlug = z.string().trim().min(1).max(PUBLIC_SLUG_MAX_LENGTH).regex(PUBLIC_SLUG_PATTERN)
|
||||
|
||||
const pieceEventRequestSchema = z.strictObject({
|
||||
type: z.enum(CONTENT_EVENT_TYPES),
|
||||
pieceSlug: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(CONTENT_PIECE_SLUG_MAX_LENGTH)
|
||||
.regex(CONTENT_PIECE_SLUG_PATTERN),
|
||||
pieceSlug: publicSlug,
|
||||
})
|
||||
|
||||
const cardDownloadEventRequestSchema = z.strictObject({
|
||||
type: z.literal('download'),
|
||||
subjectType: z.literal('card'),
|
||||
cardModelId: z.custom<CardModelId>(isCardModelId),
|
||||
stateDeputySlug: publicSlug.optional(),
|
||||
})
|
||||
|
||||
export const contentEventRequestSchema = z.union([
|
||||
pieceEventRequestSchema,
|
||||
cardDownloadEventRequestSchema,
|
||||
])
|
||||
|
|
|
|||
18284
src/migrations/20260923_190414_add_content_event_variant.json
Normal file
18284
src/migrations/20260923_190414_add_content_event_variant.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260923_190414_add_content_event_variant.ts
Normal file
11
src/migrations/20260923_190414_add_content_event_variant.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "content_event" ADD COLUMN "variant" varchar;`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "content_event" DROP COLUMN "variant";`)
|
||||
}
|
||||
|
|
@ -81,6 +81,7 @@ import * as migration_20260920_031320_add_jingle from './20260920_031320_add_jin
|
|||
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';
|
||||
import * as migration_20260923_190414_add_content_event_variant from './20260923_190414_add_content_event_variant';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -496,6 +497,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260923_172437_add_content_events.up,
|
||||
down: migration_20260923_172437_add_content_events.down,
|
||||
name: '20260923_172437_add_content_events'
|
||||
name: '20260923_172437_add_content_events',
|
||||
},
|
||||
{
|
||||
up: migration_20260923_190414_add_content_event_variant.up,
|
||||
down: migration_20260923_190414_add_content_event_variant.down,
|
||||
name: '20260923_190414_add_content_event_variant'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1292,6 +1292,10 @@ export interface ContentEvent {
|
|||
* Id interno da peça (peça) ou do modelo (card). Nunca um identificador de visitante.
|
||||
*/
|
||||
subjectId: string;
|
||||
/**
|
||||
* Slug do estadual escolhido (modelos de card com escolha estadual). Nunca um identificador de visitante.
|
||||
*/
|
||||
variant?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
@ -2764,6 +2768,7 @@ export interface ContentEventSelect<T extends boolean = true> {
|
|||
type?: T;
|
||||
subjectType?: T;
|
||||
subjectId?: T;
|
||||
variant?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import 'server-only'
|
|||
import type { Payload } from 'payload'
|
||||
|
||||
import { isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
|
||||
import { resolveCardDownloadCounts, type CardDownloadCountsView } from '@/lib/cardDownloadCounts'
|
||||
import { CARD_MODELS } from '@/lib/cardModels'
|
||||
import type { AccessibleMunicipality } from '@/lib/municipalityProximity'
|
||||
import { relationshipId, requireRelationshipId } from '@/lib/relationship'
|
||||
import type { VoteEstimateScenario } from '@/lib/voteEstimate'
|
||||
|
|
@ -12,6 +14,7 @@ import {
|
|||
loadStatewideStaffVoteTotalCentral,
|
||||
recordCampaignVoteSummarySnapshotIfNeeded,
|
||||
} from '@/utilities/campaignVoteSummarySnapshot'
|
||||
import { loadContentEventCountsBySubject } from '@/utilities/content/contentEventAggregate'
|
||||
import {
|
||||
pickDashboardPriorityMunicipalities,
|
||||
type DashboardPriorityMunicipality,
|
||||
|
|
@ -232,6 +235,23 @@ export type CampaignHomeSummaryView = Pick<StaffDashboardView, 'goalCoverage'> &
|
|||
staffVoteTotalCentral: StaffDashboardView['staffVoteTotalByScenario']['central']
|
||||
/** Statewide Δ over 7 Bahia civil days; null until enough daily snapshots exist. */
|
||||
homeSummaryDelta: number | null
|
||||
/** S32 — anonymous download counters per card model (unavailable omits the block). */
|
||||
cardDownloads: CardDownloadCountsView
|
||||
}
|
||||
|
||||
/**
|
||||
* S32 — one aggregate query for the six card models; a failed read answers
|
||||
* `unavailable` and the home omits the block (the counters are accessory).
|
||||
*/
|
||||
export const loadCampaignHomeCardDownloads = async (
|
||||
payload: Pick<Payload, 'db'>,
|
||||
): Promise<CardDownloadCountsView> => {
|
||||
const result = await loadContentEventCountsBySubject(payload, {
|
||||
subjectType: 'card',
|
||||
subjectIds: CARD_MODELS.map((model) => model.id),
|
||||
})
|
||||
|
||||
return resolveCardDownloadCounts(result.ok ? result.rows : null)
|
||||
}
|
||||
|
||||
/** Slim loader for `/campanha` — scope + rollup + coverage + B57 delta. */
|
||||
|
|
@ -252,8 +272,9 @@ export const loadCampaignHomeSummary = async (
|
|||
: await loadStatewideStaffVoteTotalCentral(payload)
|
||||
|
||||
// B57 option A: hero stays actor-scoped; delta compares statewide live vs T−7d snapshot.
|
||||
const [homeSummaryDelta] = await Promise.all([
|
||||
const [homeSummaryDelta, cardDownloads] = await Promise.all([
|
||||
loadCampaignHomeSummaryDelta(payload, statewideTotal),
|
||||
loadCampaignHomeCardDownloads(payload),
|
||||
recordCampaignVoteSummarySnapshotIfNeeded(payload, statewideTotal),
|
||||
])
|
||||
|
||||
|
|
@ -261,5 +282,6 @@ export const loadCampaignHomeSummary = async (
|
|||
staffVoteTotalCentral: rollup.staffVoteTotalByScenario.central,
|
||||
goalCoverage: goalCoverageBundle.aggregateByScenario.central,
|
||||
homeSummaryDelta,
|
||||
cardDownloads,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ export type ContentEventInput = {
|
|||
type: ContentEventType
|
||||
subjectType: ContentEventSubjectType
|
||||
subjectId: string
|
||||
/**
|
||||
* S32 — the optional sub-key of the subject: the state-deputy slug chosen on
|
||||
* the card models with a state-deputy picker. A public catalog slug, never a
|
||||
* visitor identifier.
|
||||
*/
|
||||
variant?: string | null
|
||||
}
|
||||
|
||||
export const recordContentEvent = async (event: ContentEventInput): Promise<boolean> => {
|
||||
|
|
@ -41,6 +47,7 @@ export const recordContentEvent = async (event: ContentEventInput): Promise<bool
|
|||
type: event.type,
|
||||
subjectType: event.subjectType,
|
||||
subjectId: event.subjectId,
|
||||
variant: event.variant ?? null,
|
||||
},
|
||||
// Intentional admin bypass: the caller already resolved the public gate
|
||||
// (a published piece / a served file); `create` is denied by design.
|
||||
|
|
|
|||
|
|
@ -457,3 +457,61 @@ test.describe('Wizard — mudar tendência (B97 / B168)', () => {
|
|||
await expect(page.getByText('Tendência política registrada.')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Início — contadores anônimos dos cards (S32)', () => {
|
||||
test('staff reads the Cards block with the six models in catalog order', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { payload } = campaign
|
||||
const createdEventIds: number[] = []
|
||||
|
||||
try {
|
||||
for (const subjectId of ['eu-sou-solla', 'minha-colinha']) {
|
||||
const event = await payload.create({
|
||||
collection: 'contentEvent',
|
||||
data: { type: 'download', subjectType: 'card', subjectId },
|
||||
overrideAccess: true,
|
||||
})
|
||||
createdEventIds.push(event.id)
|
||||
}
|
||||
|
||||
await page.goto('/campanha')
|
||||
|
||||
const block = page.getByRole('region', { name: 'Cards' })
|
||||
await expect(block).toBeVisible({ timeout: 15000 })
|
||||
await expect(
|
||||
block.getByText('Downloads anônimos acumulados desde o lançamento.'),
|
||||
).toBeVisible()
|
||||
await expect(block.locator('dt')).toHaveText([
|
||||
'Card com seu nome',
|
||||
'Moldura quadrada',
|
||||
'Moldura vertical',
|
||||
'Time de você',
|
||||
'Time do estadual',
|
||||
'Minha colinha',
|
||||
])
|
||||
// The counters are global per model id and the public card spec also
|
||||
// beacons in the same database: pin the floor, never an exact number.
|
||||
const firstValue = (await block.locator('dd').first().textContent()) ?? ''
|
||||
expect(Number(firstValue.replace(/\D/g, '') || '0')).toBeGreaterThanOrEqual(1)
|
||||
await expect(
|
||||
block.getByText('Os números contam downloads, não pessoas ou visitantes únicos.'),
|
||||
).toBeVisible()
|
||||
} finally {
|
||||
for (const id of createdEventIds) {
|
||||
await payload
|
||||
.delete({ collection: 'contentEvent', id, overrideAccess: true })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('leader never sees the Cards block', async ({ campaign, context, page }) => {
|
||||
await seedCampaignSession(context, campaign.baseURL, leaderToken)
|
||||
await page.goto('/campanha')
|
||||
|
||||
await expect(page.getByLabel('Buscar na campanha')).toHaveCount(0)
|
||||
await expect(page.getByRole('region', { name: 'Cards' })).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1843,11 +1843,21 @@ test.describe('Cards personalizados (S14)', () => {
|
|||
await primary.click()
|
||||
await expect(dialog.getByText('Seu card está pronto para compartilhar.')).toBeVisible()
|
||||
|
||||
const [download] = await Promise.all([
|
||||
const [download, downloadEvent] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
// S32 — the download beacons one anonymous event with the model id; the
|
||||
// model without a picker never carries a state-deputy slug.
|
||||
page.waitForRequest(
|
||||
(request) => request.url().includes('/api/content-events') && request.method() === 'POST',
|
||||
),
|
||||
dialog.getByRole('button', { name: 'Baixar meu card' }).click(),
|
||||
])
|
||||
expect(download.suggestedFilename()).toBe('card-jorge-solla-eu-sou-solla.png')
|
||||
expect(downloadEvent.postDataJSON()).toEqual({
|
||||
type: 'download',
|
||||
subjectType: 'card',
|
||||
cardModelId: 'eu-sou-solla',
|
||||
})
|
||||
const downloadPath = await download.path()
|
||||
expect(downloadPath).toBeTruthy()
|
||||
expect(readFileSync(downloadPath!).subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true)
|
||||
|
|
@ -2266,11 +2276,21 @@ test.describe('Cards personalizados (S30 — Time do estadual)', () => {
|
|||
await expect(dialog.getByText('Seu card está pronto para compartilhar.')).toBeVisible()
|
||||
await expect(dialog.getByText('Julio Pinheiro · 13999')).toBeVisible()
|
||||
|
||||
const [download] = await Promise.all([
|
||||
const [download, downloadEvent] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
// S32 — the picker model carries the chosen state-deputy slug.
|
||||
page.waitForRequest(
|
||||
(request) => request.url().includes('/api/content-events') && request.method() === 'POST',
|
||||
),
|
||||
dialog.getByRole('button', { name: 'Baixar meu card' }).click(),
|
||||
])
|
||||
expect(download.suggestedFilename()).toBe('card-jorge-solla-time-do-estadual.png')
|
||||
expect(downloadEvent.postDataJSON()).toEqual({
|
||||
type: 'download',
|
||||
subjectType: 'card',
|
||||
cardModelId: 'time-do-estadual',
|
||||
stateDeputySlug: 'julio',
|
||||
})
|
||||
expect(
|
||||
readFileSync((await download.path())!)
|
||||
.subarray(0, 8)
|
||||
|
|
@ -2407,8 +2427,21 @@ test.describe('Cards personalizados (S31 — Minha colinha)', () => {
|
|||
// The picked row is filled: the first box carries the `1` glyph ink.
|
||||
await expect.poll(() => colinhaBoxPixel(canvas)).toEqual([20, 20, 20, 255])
|
||||
|
||||
const [download] = await Promise.all([page.waitForEvent('download'), primary.click()])
|
||||
const [download, downloadEvent] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
// S32 — the picker model carries the chosen state-deputy slug.
|
||||
page.waitForRequest(
|
||||
(request) => request.url().includes('/api/content-events') && request.method() === 'POST',
|
||||
),
|
||||
primary.click(),
|
||||
])
|
||||
expect(download.suggestedFilename()).toBe('card-jorge-solla-minha-colinha.png')
|
||||
expect(downloadEvent.postDataJSON()).toEqual({
|
||||
type: 'download',
|
||||
subjectType: 'card',
|
||||
cardModelId: 'minha-colinha',
|
||||
stateDeputySlug: 'julio',
|
||||
})
|
||||
expect(
|
||||
readFileSync((await download.path())!)
|
||||
.subarray(0, 8)
|
||||
|
|
|
|||
|
|
@ -38,9 +38,11 @@ vi.mock('@/utilities/content/contentEventAggregate', async (importOriginal) => {
|
|||
|
||||
import { POST as postContentEvent } from '@/app/(frontend)/api/content-events/route'
|
||||
import { GET as getPublicPieceMedia } from '@/app/(frontend)/conteudos/[slug]/midia/route'
|
||||
import { resolveCardDownloadCounts } from '@/lib/cardDownloadCounts'
|
||||
import { contentPieceCirculationFromRows } from '@/lib/contentPieceCirculation'
|
||||
import type { CampaignUser, ContentMedia } from '@/payload-types'
|
||||
import config from '@/payload.config'
|
||||
import { loadCampaignHomeCardDownloads } from '@/utilities/campaignDashboardData'
|
||||
import { loadContentEventCountsBySubject } from '@/utilities/content/contentEventAggregate'
|
||||
import {
|
||||
loadContentPieceDetailPageData,
|
||||
|
|
@ -66,7 +68,7 @@ const flushAfterCallbacks = async (): Promise<void> => {
|
|||
await Promise.all(afterCallbacks.splice(0))
|
||||
}
|
||||
|
||||
const eventRowsOf = async (subjectIds: number[]) =>
|
||||
const eventRowsOf = async (subjectIds: Array<number | string>) =>
|
||||
payload.find({
|
||||
collection: 'contentEvent',
|
||||
where: { subjectId: { in: subjectIds.map(String) } },
|
||||
|
|
@ -160,6 +162,12 @@ describe('content events (C213)', () => {
|
|||
fixtures.createCampaignUser('leader'),
|
||||
])
|
||||
afterCallbacks.length = 0
|
||||
// The card counters are global by model id — each test starts from zero.
|
||||
await payload.delete({
|
||||
collection: 'contentEvent',
|
||||
where: { subjectType: { equals: 'card' } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
@ -370,6 +378,142 @@ describe('content events (C213)', () => {
|
|||
expect((await eventRowsOf([piece.id])).docs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('records a card download with the state-deputy slug on the picker models', async () => {
|
||||
const accepted = await postEvent({
|
||||
type: 'download',
|
||||
subjectType: 'card',
|
||||
cardModelId: 'minha-colinha',
|
||||
stateDeputySlug: 'julio',
|
||||
})
|
||||
expect(accepted.status).toBe(204)
|
||||
expect(accepted.headers.get('Cache-Control')).toBe('no-store')
|
||||
|
||||
const rows = await eventRowsOf(['minha-colinha'])
|
||||
expect(rows.docs).toHaveLength(1)
|
||||
expect(rows.docs[0]).toMatchObject({
|
||||
type: 'download',
|
||||
subjectType: 'card',
|
||||
subjectId: 'minha-colinha',
|
||||
variant: 'julio',
|
||||
})
|
||||
})
|
||||
|
||||
it('records a card download without a slug on the models without a picker', async () => {
|
||||
expect(
|
||||
(await postEvent({ type: 'download', subjectType: 'card', cardModelId: 'eu-sou-solla' }))
|
||||
.status,
|
||||
).toBe(204)
|
||||
|
||||
const rows = await eventRowsOf(['eu-sou-solla'])
|
||||
expect(rows.docs).toHaveLength(1)
|
||||
expect(rows.docs[0]).toMatchObject({
|
||||
type: 'download',
|
||||
subjectType: 'card',
|
||||
subjectId: 'eu-sou-solla',
|
||||
variant: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses the card bodies that cannot come from the committed catalogs', async () => {
|
||||
// Unknown model id.
|
||||
expect(
|
||||
(
|
||||
await postEvent({
|
||||
type: 'download',
|
||||
subjectType: 'card',
|
||||
cardModelId: 'modelo-inventado',
|
||||
})
|
||||
).status,
|
||||
).toBe(400)
|
||||
// Picker model without the chosen deputy.
|
||||
expect(
|
||||
(await postEvent({ type: 'download', subjectType: 'card', cardModelId: 'minha-colinha' }))
|
||||
.status,
|
||||
).toBe(400)
|
||||
// Unknown deputy slug.
|
||||
expect(
|
||||
(
|
||||
await postEvent({
|
||||
type: 'download',
|
||||
subjectType: 'card',
|
||||
cardModelId: 'minha-colinha',
|
||||
stateDeputySlug: 'nao-existe',
|
||||
})
|
||||
).status,
|
||||
).toBe(400)
|
||||
// Slug on a model without a picker.
|
||||
expect(
|
||||
(
|
||||
await postEvent({
|
||||
type: 'download',
|
||||
subjectType: 'card',
|
||||
cardModelId: 'eu-sou-solla',
|
||||
stateDeputySlug: 'julio',
|
||||
})
|
||||
).status,
|
||||
).toBe(400)
|
||||
// The card only counts the download in v1.
|
||||
expect(
|
||||
(await postEvent({ type: 'abertura', subjectType: 'card', cardModelId: 'eu-sou-solla' }))
|
||||
.status,
|
||||
).toBe(400)
|
||||
// The piece contract is untouched by the card variant.
|
||||
expect((await postEvent({ type: 'abertura', pieceSlug: 'peca-qualquer' })).status).toBe(204)
|
||||
|
||||
expect(
|
||||
(await eventRowsOf(['modelo-inventado', 'minha-colinha', 'eu-sou-solla'])).docs,
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('aggregates the card counters through the same reader, apart from the pieces', async () => {
|
||||
await payload.create({
|
||||
collection: 'contentEvent',
|
||||
data: {
|
||||
type: 'download',
|
||||
subjectType: 'card',
|
||||
subjectId: 'minha-colinha',
|
||||
variant: 'julio',
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
// Same subject key under the piece type must never leak into the cards.
|
||||
await payload.create({
|
||||
collection: 'contentEvent',
|
||||
data: { type: 'download', subjectType: 'peca', subjectId: 'minha-colinha' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const result = await loadContentEventCountsBySubject(payload, {
|
||||
subjectType: 'card',
|
||||
subjectIds: ['minha-colinha', 'eu-sou-solla'],
|
||||
})
|
||||
const view = resolveCardDownloadCounts(result.ok ? result.rows : null)
|
||||
|
||||
expect(view.state).toBe('data')
|
||||
if (view.state !== 'data') return
|
||||
expect(view.counts.filter((entry) => entry.count > 0)).toEqual([
|
||||
{ modelId: 'minha-colinha', label: 'Minha colinha', count: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('loads the home card counters as data, empty and unavailable', async () => {
|
||||
await expect(loadCampaignHomeCardDownloads(payload)).resolves.toEqual({ state: 'empty' })
|
||||
|
||||
await payload.create({
|
||||
collection: 'contentEvent',
|
||||
data: { type: 'download', subjectType: 'card', subjectId: 'time-de-voce' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
const view = await loadCampaignHomeCardDownloads(payload)
|
||||
expect(view.state).toBe('data')
|
||||
if (view.state !== 'data') return
|
||||
expect(view.counts.find((entry) => entry.modelId === 'time-de-voce')?.count).toBe(1)
|
||||
expect(view.counts).toHaveLength(6)
|
||||
|
||||
vi.mocked(loadContentEventCountsBySubject).mockResolvedValueOnce({ ok: false })
|
||||
await expect(loadCampaignHomeCardDownloads(payload)).resolves.toEqual({ state: 'unavailable' })
|
||||
})
|
||||
|
||||
it('keeps the collection closed to every campaign write and open to communication reads', async () => {
|
||||
const piece = await createPiece()
|
||||
await payload.create({
|
||||
|
|
|
|||
59
tests/unit/campaignHomeCardDownloads.unit.spec.tsx
Normal file
59
tests/unit/campaignHomeCardDownloads.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { CampaignHomeCardDownloads } from '@/components/campaign/dashboard/CampaignHomeCardDownloads'
|
||||
import {
|
||||
CARD_DOWNLOAD_COUNTS_CAPTION,
|
||||
CARD_DOWNLOAD_COUNTS_EMPTY_BODY,
|
||||
CARD_DOWNLOAD_COUNTS_EMPTY_TITLE,
|
||||
CARD_DOWNLOAD_COUNTS_SUBTITLE,
|
||||
CARD_DOWNLOAD_COUNTS_TITLE,
|
||||
cardDownloadCountsFromRows,
|
||||
} from '@/lib/cardDownloadCounts'
|
||||
import { CARD_MODELS } from '@/lib/cardModels'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('CampaignHomeCardDownloads', () => {
|
||||
it('renders one counter per model, in the catalog order, with the honesty caption', () => {
|
||||
render(
|
||||
<CampaignHomeCardDownloads
|
||||
view={{
|
||||
state: 'data',
|
||||
counts: cardDownloadCountsFromRows([
|
||||
{ subjectId: 'eu-sou-solla', type: 'download', count: 1_234 },
|
||||
{ subjectId: 'minha-colinha', type: 'download', count: 76 },
|
||||
]),
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('region', { name: CARD_DOWNLOAD_COUNTS_TITLE })).toBeTruthy()
|
||||
expect(screen.getByText(CARD_DOWNLOAD_COUNTS_SUBTITLE)).toBeTruthy()
|
||||
expect(screen.getByText('1.234')).toBeTruthy()
|
||||
expect(screen.getByText('76')).toBeTruthy()
|
||||
expect(screen.getByText(CARD_DOWNLOAD_COUNTS_CAPTION)).toBeTruthy()
|
||||
|
||||
const terms = screen.getAllByRole('term').map((node) => node.textContent)
|
||||
expect(terms).toEqual(CARD_MODELS.map((model) => model.label))
|
||||
})
|
||||
|
||||
it('renders the honest empty box instead of six zeros', () => {
|
||||
render(<CampaignHomeCardDownloads view={{ state: 'empty' }} />)
|
||||
|
||||
expect(screen.getByText(CARD_DOWNLOAD_COUNTS_EMPTY_TITLE)).toBeTruthy()
|
||||
expect(screen.getByText(CARD_DOWNLOAD_COUNTS_EMPTY_BODY)).toBeTruthy()
|
||||
expect(screen.queryByText(CARD_DOWNLOAD_COUNTS_SUBTITLE)).toBeNull()
|
||||
expect(screen.queryByText('Card com seu nome')).toBeNull()
|
||||
expect(screen.queryByText(CARD_DOWNLOAD_COUNTS_CAPTION)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing when the aggregate is unavailable — never "no downloads yet"', () => {
|
||||
const { container } = render(<CampaignHomeCardDownloads view={{ state: 'unavailable' }} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(screen.queryByText(CARD_DOWNLOAD_COUNTS_EMPTY_TITLE)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -14,6 +14,7 @@ describe('CampaignHomeSummary', () => {
|
|||
view={{
|
||||
staffVoteTotalCentral: 125_430,
|
||||
homeSummaryDelta: 5_000,
|
||||
cardDownloads: { state: 'empty' },
|
||||
goalCoverage: {
|
||||
goal: 200_000,
|
||||
committed: 80_000,
|
||||
|
|
@ -40,6 +41,7 @@ describe('CampaignHomeSummary', () => {
|
|||
view={{
|
||||
staffVoteTotalCentral: 0,
|
||||
homeSummaryDelta: null,
|
||||
cardDownloads: { state: 'empty' },
|
||||
goalCoverage: {
|
||||
goal: 0,
|
||||
committed: 0,
|
||||
|
|
@ -60,6 +62,7 @@ describe('CampaignHomeSummary', () => {
|
|||
view={{
|
||||
staffVoteTotalCentral: 12_345,
|
||||
homeSummaryDelta: 0,
|
||||
cardDownloads: { state: 'empty' },
|
||||
goalCoverage: {
|
||||
goal: 0,
|
||||
committed: 0,
|
||||
|
|
|
|||
91
tests/unit/cardDownloadCounts.unit.spec.ts
Normal file
91
tests/unit/cardDownloadCounts.unit.spec.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
CARD_DOWNLOAD_COUNTS_CAPTION,
|
||||
CARD_DOWNLOAD_COUNTS_EMPTY_BODY,
|
||||
CARD_DOWNLOAD_COUNTS_EMPTY_CAPTION,
|
||||
CARD_DOWNLOAD_COUNTS_EMPTY_TITLE,
|
||||
CARD_DOWNLOAD_COUNTS_SUBTITLE,
|
||||
CARD_DOWNLOAD_COUNTS_TITLE,
|
||||
cardDownloadCountsFromRows,
|
||||
resolveCardDownloadCounts,
|
||||
} from '@/lib/cardDownloadCounts'
|
||||
import { CARD_MODELS } from '@/lib/cardModels'
|
||||
|
||||
describe('card download counts vocabulary', () => {
|
||||
it('pins the honest literals of the design', () => {
|
||||
expect(CARD_DOWNLOAD_COUNTS_TITLE).toBe('Cards')
|
||||
expect(CARD_DOWNLOAD_COUNTS_SUBTITLE).toBe('Downloads anônimos acumulados desde o lançamento.')
|
||||
expect(CARD_DOWNLOAD_COUNTS_CAPTION).toBe(
|
||||
'Os números contam downloads, não pessoas ou visitantes únicos.',
|
||||
)
|
||||
expect(CARD_DOWNLOAD_COUNTS_EMPTY_TITLE).toBe('Nenhum download ainda')
|
||||
expect(CARD_DOWNLOAD_COUNTS_EMPTY_BODY).toBe(
|
||||
'Os contadores aparecem quando alguém baixar um card.',
|
||||
)
|
||||
expect(CARD_DOWNLOAD_COUNTS_EMPTY_CAPTION).toBe(
|
||||
'A contagem será anônima e mostrará downloads por modelo, nunca pessoas.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cardDownloadCountsFromRows', () => {
|
||||
it('keeps the natural catalog order and labels, zero-filling the silent models', () => {
|
||||
const counts = cardDownloadCountsFromRows([
|
||||
{ subjectId: 'minha-colinha', type: 'download', count: 7 },
|
||||
{ subjectId: 'eu-sou-solla', type: 'download', count: 3 },
|
||||
])
|
||||
|
||||
expect(counts.map((entry) => entry.modelId)).toEqual(CARD_MODELS.map((model) => model.id))
|
||||
expect(counts.map((entry) => entry.label)).toEqual(CARD_MODELS.map((model) => model.label))
|
||||
expect(counts.map((entry) => entry.count)).toEqual([3, 0, 0, 0, 0, 7])
|
||||
})
|
||||
|
||||
it('ignores every other event type, ids outside the catalog and non-positive counts', () => {
|
||||
const counts = cardDownloadCountsFromRows([
|
||||
{ subjectId: 'time-de-voce', type: 'abertura', count: 9 },
|
||||
{ subjectId: 'time-de-voce', type: 'compartilhar_whatsapp', count: 9 },
|
||||
{ subjectId: 'peca-123', type: 'download', count: 9 },
|
||||
{ subjectId: 'time-do-estadual', type: 'download', count: -4 },
|
||||
{ subjectId: 'time-do-estadual', type: 'download', count: Number.NaN },
|
||||
])
|
||||
|
||||
expect(counts.every((entry) => entry.count === 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('sums duplicate rows of the same model instead of losing one', () => {
|
||||
const counts = cardDownloadCountsFromRows([
|
||||
{ subjectId: 'perfil-quadrado', type: 'download', count: 2 },
|
||||
{ subjectId: 'perfil-quadrado', type: 'download', count: 5 },
|
||||
])
|
||||
|
||||
expect(counts.find((entry) => entry.modelId === 'perfil-quadrado')?.count).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCardDownloadCounts', () => {
|
||||
it('resolves an unavailable read — never an empty state', () => {
|
||||
expect(resolveCardDownloadCounts(null)).toEqual({ state: 'unavailable' })
|
||||
})
|
||||
|
||||
it('resolves empty only for real zeros', () => {
|
||||
expect(resolveCardDownloadCounts([])).toEqual({ state: 'empty' })
|
||||
expect(
|
||||
resolveCardDownloadCounts([{ subjectId: 'eu-sou-solla', type: 'download', count: 0 }]),
|
||||
).toEqual({ state: 'empty' })
|
||||
})
|
||||
|
||||
it('resolves data as soon as one model has a download', () => {
|
||||
const view = resolveCardDownloadCounts([
|
||||
{ subjectId: 'eu-sou-solla', type: 'download', count: 1 },
|
||||
])
|
||||
|
||||
expect(view.state).toBe('data')
|
||||
if (view.state === 'data') {
|
||||
expect(view.counts).toHaveLength(CARD_MODELS.length)
|
||||
expect(view.counts.find((entry) => entry.modelId === 'eu-sou-solla')?.count).toBe(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -2,7 +2,11 @@
|
|||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CONTENT_EVENT_ENDPOINT, sendContentPieceEvent } from '@/lib/contentEvents'
|
||||
import {
|
||||
CONTENT_EVENT_ENDPOINT,
|
||||
sendCardDownloadEvent,
|
||||
sendContentPieceEvent,
|
||||
} from '@/lib/contentEvents'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
|
|
@ -13,7 +17,7 @@ describe('ContentEvent collection contract', () => {
|
|||
const { ContentEvent } = await import('@/collections/ContentEvent')
|
||||
|
||||
const fieldNames = ContentEvent.fields.map((field) => ('name' in field ? field.name : ''))
|
||||
expect(fieldNames).toEqual(['type', 'subjectType', 'subjectId'])
|
||||
expect(fieldNames).toEqual(['type', 'subjectType', 'subjectId', 'variant'])
|
||||
for (const forbidden of ['ip', 'useragent', 'cookie', 'session', 'visitor', 'fingerprint']) {
|
||||
expect(fieldNames.some((name) => name.toLowerCase().includes(forbidden))).toBe(false)
|
||||
}
|
||||
|
|
@ -94,3 +98,51 @@ describe('sendContentPieceEvent', () => {
|
|||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendCardDownloadEvent', () => {
|
||||
it('carries the model id and the state-deputy slug on the picker models', async () => {
|
||||
const sendBeacon = vi.fn().mockReturnValue(true)
|
||||
vi.stubGlobal('navigator', { sendBeacon })
|
||||
|
||||
sendCardDownloadEvent('minha-colinha', 'julio')
|
||||
|
||||
expect(sendBeacon).toHaveBeenCalledTimes(1)
|
||||
const [endpoint, blob] = sendBeacon.mock.calls[0]!
|
||||
expect(endpoint).toBe(CONTENT_EVENT_ENDPOINT)
|
||||
await expect((blob as Blob).text()).resolves.toBe(
|
||||
JSON.stringify({
|
||||
type: 'download',
|
||||
subjectType: 'card',
|
||||
cardModelId: 'minha-colinha',
|
||||
stateDeputySlug: 'julio',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the slug key on models without a picker (null or undefined)', async () => {
|
||||
const sendBeacon = vi.fn().mockReturnValue(true)
|
||||
vi.stubGlobal('navigator', { sendBeacon })
|
||||
|
||||
sendCardDownloadEvent('eu-sou-solla', null)
|
||||
|
||||
const [, blob] = sendBeacon.mock.calls[0]!
|
||||
await expect((blob as Blob).text()).resolves.toBe(
|
||||
JSON.stringify({ type: 'download', subjectType: 'card', cardModelId: 'eu-sou-solla' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('never throws when the beacon and the fetch both fail', async () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
sendBeacon: vi.fn().mockImplementation(() => {
|
||||
throw new Error('beacon exploded')
|
||||
}),
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
|
||||
|
||||
expect(() => sendCardDownloadEvent('time-do-estadual', 'julio')).not.toThrow()
|
||||
await Promise.resolve()
|
||||
|
||||
vi.stubGlobal('fetch', undefined)
|
||||
expect(() => sendCardDownloadEvent('time-do-estadual', 'julio')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue