mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:45:18 +00:00
feat(S19): links de compartilhamento com miniatura OG (Closes #1207)
This commit is contained in:
parent
cde96b26d0
commit
468f33e5b2
20 changed files with 18276 additions and 25 deletions
1
docs/changelog/2026-09-19-s19.md
Normal file
1
docs/changelog/2026-09-19-s19.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**S19 — Links de compartilhamento com miniatura personalizada (2026-09-19):** a comunicação do mandato passa a criar no admin (`Publicações › Links de compartilhamento`) um `shareLink` — slug, destino externo, título, descrição e imagem — e a URL curta `jorgesolla1313.com.br/<slug>` serve HTML próprio com o cartão OG configurado (o crawler do WhatsApp lê título, descrição e imagem do nosso HTML) enquanto a pessoa que clica cai no destino sem interstício: 200 com `generateMetadata` (Open Graph `website`, Twitter `summary_large_image`, canonical da URL curta, `noindex,nofollow`) + `<meta http-equiv="refresh">` e `location.replace` inline (o 307 server-side faria o crawler seguir e perder o cartão); sem imagem configurada, o `resolveShareLinkOgImageUrl` cai na imagem padrão do global `metadata` e nunca emite URL relativa. A rota vive dentro do segmento dinâmico existente `[type]` (o Next 15.4.11 recusa `[slug]` paralelo, E337): quando o valor não é um post type, o dono do segmento consulta `getCachedPublishedShareLinkBySlug` (`unstable_cache` sob a tag de listing `shareLinks`, revalidada nos hooks `afterChange`/`afterDelete` da collection) e responde a mesma tela 404 para link despublicado, inexistente ou destino inválido. Modelo: `slug` único/indexado (slugify do título quando vazio, formato kebab-case, reservados com drift test contra os segmentos estáticos de `src/app/(frontend)` e contra `POST_TYPES`), `destination` só `http`/`https` (`^https?://` + parse, mensagens pt-BR claras), `title` ≤90, `description` ≤160 e `published` default `false` (kill switch; leitura anônima fail-closed por `where published: true`, escrita por `canManagePublishedContent`) — sem analytics/UTM, sem Consent/Contact e sem migration além da tabela aditiva `add_share_link` (a migration nova também corrige o snapshot perdido de `reel.source_hash` que a `add_recording` tinha deixado para trás, sem reexecutar SQL). Instruções de imagem/título/descrição e o cache do WhatsApp ficam nos `admin.description` do formulário. Testes: 4 unit da lib pura + 2 de drift, 16 int (validações, slugify, unicidade, kill switch, loader, access anônimo × editor, fallback de imagem com mock do global e revalidação da tag) e e2e `frontendShareLink` novo (OG e no-JS por meta refresh no navegador real, 404 uniforme, ciclo 404→200) — pinado no manifesto da área e no conjunto curado OPS86 (a migration torna o PR high-risk). Verificação: `pnpm gate:fast` verde (4096 unit), int e e2e da superfície verdes, `curl` manual conferindo `og:*`, `noindex` e `refresh`.
|
||||
128
docs/plans/links-compartilhamento-miniatura-og-impl.md
Normal file
128
docs/plans/links-compartilhamento-miniatura-og-impl.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# Impl: Links de compartilhamento com miniatura personalizada
|
||||
|
||||
Status: em execução
|
||||
Atualizado em: 2026-09-19
|
||||
Issue: #1207
|
||||
Intenção: docs/plans/links-compartilhamento-miniatura-og.md
|
||||
Appetite restante: herdado (~1 dia eng — cadastro novo no admin + uma rota pública + testes; sem gordura de analytics/expiração)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** a equipe de comunicação cria no admin um `shareLink` (slug de 1 segmento, destino http/https, título, descrição e imagem opcionais) e `jorgesolla1313.com.br/<slug>` serve HTML com o OG configurado — o crawler do WhatsApp lê esse HTML — enquanto a pessoa que clica chega ao destino imediatamente, sem interstício; sem imagem configurada, a miniatura cai na imagem padrão do site; despublicado → 404; destino inválido, slug duplicado ou reservado → recusa no formulário com mensagem clara; página `noindex,nofollow`; a URL de destino crua nunca aparece na miniatura.
|
||||
- **O que NÃO negociar:** contrato de URL de 1 segmento na raiz (produto); OG renderizado no servidor; clique direto; fallback de imagem do site (nunca miniatura quebrada); fail-closed nas quatro frentes (não publicado, destino fora de `http`/`https`, duplicado, reservado); destino fora da descrição/OG; instruções pt-BR visíveis no formulário; grupo `Publicações`; zero analytics/UTM/persistência de quem clica.
|
||||
- **O que reavaliar:** as decisões assumidas no produto (imagem por upload A; clique instantâneo A; editores A; grupo `Publicações` A; sem agendamento A) — a engenharia confirma A em todas e mantém agendamento/expiração fora; a hipótese de rota também se confirma: o Next 15.4.11 lança E337 para `[slug]` ao lado de `[type]`, então não há pasta dinâmica nova possível e o destino do ramo é o dono existente.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
E["Editor no admin<br/>Publicações › Links de compartilhamento"] --> C["shareLink<br/>slug · destino · título · descrição · imagem · published"]
|
||||
C -->|afterChange / afterDelete| T["revalidateShareLinksListing<br/>tag shareLinks (documents.ts)"]
|
||||
W["Crawler do WhatsApp / pessoa"] -->|GET /:slug| R["[type]/page.tsx<br/>!isPostType(type)"]
|
||||
R --> L["getCachedPublishedShareLinkBySlug<br/>unstable_cache · tag shareLinks"]
|
||||
L --> DB[("shareLink published=true")]
|
||||
R -->|achou + destino http(s)| H["200 HTML<br/>og:* + meta refresh + location.replace"]
|
||||
R -->|não achou / despublicado / destino inválido| N["404"]
|
||||
H -->|pessoa navega na hora| D["destino externo"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (ramificar dentro de `[type]/page.tsx`) | B (prefixo estático `/l/<slug>`) | C (`middleware.ts` com rewrite) | D (renomear o segmento para `[[...segments]]`)
|
||||
|
||||
**Recomendação:** A — nenhuma rota nova: quando `!isPostType(type)`, o dono do segmento tenta o loader do share link por slug; achou + publicado + destino válido → renderiza a página-redirect; senão `notFound()`. Reusa o molde do `/corte/[id]` (loader + `noindex` + 404 uniforme) e o contrato de URL do produto. `generateStaticParams` continua só com os tipos de post; `dynamicParams = true` (já no arquivo) cobre o slug em runtime, e os estáticos (`/artigos`, `/cards`) seguem vencendo o dinâmico sem interferir.
|
||||
|
||||
**Rejeitadas:** B — viola o contrato de 1 segmento na raiz; C — não existe `middleware` no frontend, acoplaria leitura de DB na borda e taxaria toda rota; D — reescreveria todas as rotas de posts (blast radius desproporcional) e ainda deixaria o custo de precedência no mesmo lugar.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
1. **Mecânica do redirect — 200 HTML com OG + meta refresh + `location.replace` (caro).**
|
||||
**Opções:** A) `generateMetadata` (OG/twitter/noindex/canonical) + `<meta httpEquiv="refresh" content="0;url=…">` (React 19 hoista `<meta>` para o `<head>`) + script inline `location.replace` com URL escapada; B) `redirect()` 307 server-side; C) página intermediária com botão.
|
||||
**Recomendação:** A — o crawler do WhatsApp lê o HTML servido e não executa refresh/JS; B faria o crawler seguir o 307 e perder o nosso OG (a miniatura seria a do destino ou vazia), e o `noindex` não viajaria; C é anti-goal explícito ("sem tela intermediária perceptível"). O meta refresh cobre no-JS; o script navega antes do paint; o destino cru só existe no `content`/script do HTML, nunca em `og:description`. No render, o destino é **revalidado** com o mesmo validador puro antes de emitir (defense-in-depth: dado inválido → 404, nunca redireciona para esquema estranho).
|
||||
**Rejeitadas:** B e C acima.
|
||||
|
||||
2. **Publicação — checkbox `published`, default `false` (caro).**
|
||||
**Opções:** A) boolean `published` (kill switch) com default `false` (publicar é ato explícito); B) drafts/versions (como `Post`); C) select `status` (como `SpeechCut`).
|
||||
**Recomendação:** A — o produto pede um kill switch, não fluxo editorial: versions adicionam `_status`, tabelas `_v` e autosave sem consumidor; o select do corte carrega um ciclo de job (`processing`/`failed`) que não existe aqui. Default `false` porque o repo é fail-closed: o link nasce invisível e a editora marca "Publicado" no mesmo formulário (descrição no campo diz que desmarcado responde 404); a leitura anônima (REST) também filtra `published: true`.
|
||||
**Rejeitadas:** B — cerimônia de versões para um doc de 5 campos; C — vocabulário de status que mente sobre o domínio.
|
||||
|
||||
3. **Leitura cacheada e revalidação — tag de listing no vocabulário de `documents.ts` (caro).**
|
||||
**Opções:** A) `getCachedPublishedShareLinkBySlug` em `src/utilities/shareLinkReads.ts` (`unstable_cache`, tags `[getCollectionListingTag('shareLink')]` → `shareLinks`) + `revalidateShareLinksListing()` exportado por `documents.ts` e chamado em `afterChange`/`afterDelete`; B) sem cache (DB a cada view); C) tag por documento no loader por slug.
|
||||
**Recomendação:** A — reusa o dono do vocabulário de tags (o privado `getListingTag` vira `getCollectionListingTag` exportado; precedente `revalidatePostsListing`) e mantém o grafo acíclico: a lib pura não importa `@payload-config`; só `shareLinkReads.ts` importa config, com `import 'server-only'` e imports estáticos (guardas de convenção). O loader por slug não conhece o id antes da leitura, então uma tag por documento nasceria sem leitor.
|
||||
**Rejeitadas:** B — toda view quente bateria no Postgres, contra o precedente do corte; C — tag morta (nenhum leitor por id).
|
||||
|
||||
4. **Validação de slug — `beforeValidate` (slugify) + `validate` assíncrono + índice único (caro).**
|
||||
**Opções:** A) `beforeValidate` reusando `slugify` (`src/lib/slug.ts`, precedente `Post.ts`), `validate` async com formato kebab-case, reservados e duplicata via `req.payload.find` (`overrideAccess: true` com comentário de bypass), e `unique: true` no campo; B) só `unique: true` (mensagem genérica do Payload); C) schema zod em `src/lib/schemas/`.
|
||||
**Recomendação:** A — mensagens pt-BR claras no formulário (o pedido literal do produto), com o índice único como backstop para a corrida entre validate e insert; a query de unicidade enxerga todas as linhas independentemente do ator. O padrão kebab-case ainda exclui `_next`/`favicon.ico`/caminhos com ponto por construção. Zod é dono de input de action HTTP (precedente), não de campo do admin.
|
||||
**Rejeitadas:** B — erro genérico e sem reservados; C — fora do precedente do admin.
|
||||
|
||||
5. **Imagem e forma do OG — molde do `/corte/[id]` (caro o suficiente).**
|
||||
**Opções:** A) `image` upload opcional → `toAbsoluteUrl`; vazio → imagem do global `metadata` via `getCachedDocumentById('media', …)`; OG `type: 'website'`, `url`/canonical = URL curta, twitter `summary_large_image`, `robots: {index:false, follow:false}`; B) exigir imagem no link; C) criar `imageSizes`/derivadas no `Media`.
|
||||
**Recomendação:** A — reusa `resolveSiteMetadata`/`absoluteSitePath`/`toAbsoluteUrl`/`truncate` (`src/utilities/seo.ts`) e a resolução global→mídia do corte e do layout raiz. Sem `siteUrl`, omite a imagem em vez de emitir `og:image` relativa (o crawler não resolve relativa). `og:title` = título configurado **literal** (o card mostra o que a editora escolheu; o `<title>` da aba leva `| <siteName>`); `og:site_name` segue o global. Descrição só com o texto configurado (jamais o destino).
|
||||
**Rejeitadas:** B — a intenção diz opcional com fallback; C — a instrução de 1200×630 no formulário resolve e derivadas/transcode são rabbit hole.
|
||||
|
||||
6. **Modelo do cadastro — `shareLink` no grupo `Publicações` (caro).**
|
||||
**Opções:** A) collection `shareLink`, labels "Link de compartilhamento"/"Links de compartilhamento", campos `title` (text, required, `maxLength` 90), `slug` (text, required, unique, index, slugify+validate), `destination` (text, required, trim + validate http/https), `description` (textarea, required, `maxLength` 160, whitespace colapsado no metadata), `image` (upload → media, opcional), `published` (checkbox, default false), `admin.description` com os literais de instrução; B) `shortLink`; C) `redirect`.
|
||||
**Recomendação:** A — o nome diz a função de produto; `Publicações` já existe (`Post`) e é onde a comunicação publica. Os `admin.description` copiam os literais da intenção (imagem 1200×630/600 KB/JPG-PNG/300 px/4:1; título ~60–90; descrição ~80–160; URL pública absoluta resolvida pelo upload; cache do WhatsApp por URL com orientação de slug novo). `useAsTitle: 'title'`; sem `versions`; sem `createdBy` (conteúdo público de CMS, como `Post`).
|
||||
**Rejeitadas:** B — puxa o rabbit hole "encurtador"; C — colide com o conceito de infra de redirect e não descreve o produto.
|
||||
|
||||
7. **Access — leitura fail-closed, sem Consent novo (barato).**
|
||||
`read`: staff (`hasPayloadPanelAccess`) vê tudo; anônimo recebe `{ published: { equals: true } }` (molde `canReadSpeechCut`, `access/speeches.ts`); `create`/`update`/`delete`: `canManagePublishedContent` (editores/admin, como `Post`/`Media`). Sem `Consent` (não há PII/opt-in), sem `Contact`, sem transação multi-collection (a escrita é de um doc só), sem `stampCampaignCreatedBy` (não é coleção de campanha).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/lib/shareLink.ts`** (novo, puro/client-safe): dono do vocabulário — `SHARE_LINK_TITLE_MAX_LENGTH` (90), `SHARE_LINK_DESCRIPTION_MAX_LENGTH` (160), `SHARE_LINK_RESERVED_SLUGS` (literal da intenção: `cards, artigos, corte, privacidade, mandato-no-whatsapp, abaixo-assinado, api, admin, noticia, campanha, artigo, evento`), `isValidShareLinkSlug`, `isReservedShareLinkSlug`, `isValidShareLinkDestination` (`new URL` + protocolo http/https), `shareLinkPath`, `normalizeShareLinkDescription` (colapsa whitespace), e as mensagens pt-BR (`SHARE_LINK_SLUG_INVALID_MESSAGE`, `SHARE_LINK_SLUG_RESERVED_MESSAGE`, `SHARE_LINK_SLUG_DUPLICATE_MESSAGE`, `SHARE_LINK_DESTINATION_INVALID_MESSAGE`).
|
||||
- **`src/collections/ShareLink.ts`** (novo): config da collection; reusa `slugify` e `canManagePublishedContent`/`hasPayloadPanelAccess`; `beforeValidate` de slug; `validate` async de unicidade com bypass comentado; hooks `afterChange`/`afterDelete` chamando `revalidateShareLinksListing()`.
|
||||
- **`src/payload.config.ts`** (editar): import + entrada no array (junto de `Post`/`Tag`).
|
||||
- **`src/utilities/documents.ts`** (editar): `getListingTag` → `getCollectionListingTag` exportado + `revalidateShareLinksListing()` (sem importar config).
|
||||
- **`src/utilities/shareLinkReads.ts`** (novo, `server-only`, imports estáticos): `getCachedPublishedShareLinkBySlug(slug, depth = 1)` — `find` com `slug equals` + `published equals true`, `unstable_cache` tags `[getCollectionListingTag('shareLink')]`, key `['share-link-by-slug', slug, String(depth)]`; e `resolveShareLinkOgImageUrl(link)` — imagem do link → fallback do global `metadata` (número → mídia) → `null`, com `siteUrl` derivado de `resolveSiteMetadata` e URL sempre absoluta via `toAbsoluteUrl` (nunca relativa).
|
||||
- **`src/app/(frontend)/[type]/page.tsx`** (editar): ramo `!isPostType(type)` no `Page` (achou → `<ShareLinkRedirect>`; senão `notFound()`) e o mesmo ramo no `generateMetadata` (OG completo; `{}` quando não achou).
|
||||
- **`src/components/ShareLinkRedirect.tsx`** (novo): server component sem DOM visível — `<meta httpEquiv="refresh" content=... />` (hoisted) + `<script dangerouslySetInnerHTML>` com `location.replace(JSON.stringify(destination).replace(/</g, '\\u003c'))`; sem `SiteHeader`/hierarquia.
|
||||
- **Testes:** `tests/unit/shareLink.unit.spec.ts` (puros + lista de reservados literal + drift: todo segmento de rota estático em `src/app/(frontend)`/`(home)` está reservado), `tests/int/shareLink.int.spec.ts` (getPayload real em `teqo_test`, mock de `next/cache` como `speechCut.int.spec.ts`: validações/mensagens, default `published: false`, slugify, loader só publicado, kill switch, access), `tests/e2e/frontendShareLink.e2e.spec.ts` (OG HTML + redirect real + 404 + republicação + fallback).
|
||||
- **Manifesto e2e:** entrada nova em `scripts/lib/e2e-affected-manifest.mjs` (prefixos `src/app/(frontend)/[type]`, `src/lib/shareLink.ts`, `src/utilities/shareLinkReads.ts`, `src/collections/ShareLink.ts`, `src/components/ShareLinkRedirect.tsx` → spec `frontendShareLink`) + `frontendShareLink` em `E2E_CURATED_SPECS` com o pin atualizado em `tests/unit/e2eAffectedManifest.unit.spec.ts` (a migration torna todo PR high-risk; precedente C167).
|
||||
- **Migration:** `add_share_link` via `pnpm migrate:create add_share_link` (tabela + FK de `image` + índice único de `slug`; aditiva, sem backfill); commitar `.ts`+`.json`+`index.ts`; `src/payload-types.ts` regenerado.
|
||||
- **Access / Consent:** access inline na collection (acima); sem Consent; sem tocar `campaignAccess.ts`.
|
||||
- **UI:** Impeccable **A** — a única "tela" é o redirect sem pixels; no admin, só o formulário com `admin.description` pt-BR.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Puros + schema + migration (tracer ~0,3 dia).** `src/lib/shareLink.ts` + `tests/unit/shareLink.unit.spec.ts`; `src/collections/ShareLink.ts`; registro no `payload.config.ts`; `pnpm generate:types`; `pnpm migrate:create add_share_link`, revisar SQL e `pnpm migrate` **só no banco local do worktree** (produção aplica no deploy via `pnpm build`; `push:false` intocado); `pnpm gate:fast`. Verificação: unit verde, `payload-types.ts` com `shareLink`, `migrate:status` aplicado.
|
||||
2. **Leitura + revalidação (~0,2 dia).** `documents.ts`; `src/utilities/shareLinkReads.ts`; hooks na collection; `tests/int/shareLink.int.spec.ts` com mock de `next/cache`. Verificação: loader devolve só publicado; despublicar zera; validações com as mensagens pt-BR; access anônimo × editor.
|
||||
3. **Rota + componente (~0,2 dia).** `ShareLinkRedirect.tsx` + ramo no `[type]/page.tsx` (`Page` e `generateMetadata`). Verificação manual no `pnpm dev` do worktree: link criado → `curl` mostra `og:*`, `noindex`, `http-equiv="refresh"`, `location.replace`; despublicar → 404; republicar → 200.
|
||||
4. **Testes de superfície (~0,2 dia).** e2e `frontendShareLink`: 200 com `og:title`/`og:description`/`og:image` absoluto e `og:url` curto; `og:description` sem o destino; navegação real do browser para o destino (script e meta refresh sem JS); 404 uniforme para despublicado e slug inexistente; ciclo despublicar→republicar. O fallback de imagem é do `resolveShareLinkOgImageUrl` (int, com mock do global `metadata` — o global é estado compartilhado entre int e e2e no mesmo banco, então o teste não o escreve); manifesto + curado + pin; `pnpm test`.
|
||||
5. **Gates + changelog + PR (~0,1 dia).** `pnpm gate:fast`; changelog `docs/changelog/2026-09-19-s19.md`; `pnpm push` e PR ready (CI de PR roda unit/int full + e2e curado por causa de `src/collections`/`src/migrations` — esperado).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Analytics/contagem de cliques/UTM, expiração/agendamento, QR code, encurtador genérico/domínio próprio, múltiplos destinos/A-B, preview do card no admin (lista da intenção).
|
||||
- `imageSizes`/derivadas/validação de dimensão-pixel/transcode da imagem (a instrução no formulário é o contrato).
|
||||
- `middleware.ts`, refatorar `[type]` para catch-all, redirects no `next.config`, histórico/redirect de slug renomeado (renomeou → link antigo morre; a orientação de cache no formulário já cobre o caso do WhatsApp).
|
||||
- Tag por documento/revalidação por path; versionamento/auditoria do link; apagar mídia órfã ao excluir o link (mídia é do `Media`, como a capa do `Post`).
|
||||
- Qualquer Consent novo, `Contact` ou escrita multi-collection (não existem no fluxo).
|
||||
- **Defer com gatilho:** a entrada S19 do manifesto (`src/utilities/documents.ts`) acorda só o `frontendShareLink`; quando esse arquivo mudar por listagens além de shareLink, ampliar a entrada para `specs: ['frontend', 'frontendShareLink']`.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Colisão com rota estática futura** (estático vence o dinâmico e o link sumiria): lista de reservados + teste unitário de drift que varre os segmentos estáticos de rota de `src/app/(frontend)` (incl. `(home)`) e exige reserva; o padrão kebab-case exclui `_next`/pontos.
|
||||
- **404→200 após republicar** (cache de rota cheia): o e2e cobre o ciclo completo (publicar → despublicar → republicar); se falhar, o remédio documentado é `dynamic = 'force-dynamic'` no `[type]` (custo no cache das listagens — decidir com a prova do e2e, não preventivamente).
|
||||
- **Corrida de slug** (validate × insert): índice único é o backstop; a corrida rara cai no erro genérico do banco.
|
||||
- **Cache do WhatsApp**: orientação no formulário, sem mecanismo de invalidação (corte aceito no produto).
|
||||
- **`og:image` relativa** (sem `siteUrl`/global): omitida em vez de quebrada; `siteUrl` cai no `NEXT_PUBLIC_SITE_URL` do build.
|
||||
- **Crawler real do WhatsApp não roda em CI**: o contrato testado é o HTML servido (OG + refresh sem depender de JS).
|
||||
- **Migration em produção**: aditiva e sem backfill, aplicada pelo deploy (`pnpm build` → `payload migrate`); em dev, só no banco local do worktree.
|
||||
- **Sondagens a slugs inexistentes** populam o cache de dados (uma entrada por slug, LRU do Next) — sem impacto de correção.
|
||||
- **CI mais caro**: `src/collections/`/`src/migrations/` são high-risk → unit/int full + e2e curado (previsto).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto: criar pelo admin e compartilhar `/<slug>` mostra título/descrição/imagem configurados no HTML servido; clique instantâneo; fallback para a imagem padrão; 404 para não publicado; recusa clara para destino inválido, slug duplicado e reservado; `noindex,nofollow`; destino nunca na miniatura; instruções pt-BR no formulário; nada de analytics/PII.
|
||||
- [ ] Invariantes: leitura anônima fail-closed (`published` where) e escrita só editor/admin; Local API com bypass só com comentário; sem Consent/Contact/transação (não se aplicam); copy pt-BR e identificadores em inglês; collection só via migration nova (`push:false`, nenhuma migration editada); entrega via `pnpm push`.
|
||||
- [ ] Testes: unit dos puros + drift de reservados; int das validações/default/loader/access; e2e do HTML OG, redirect real, 404, republicação e fallback.
|
||||
|
||||
## Self-score de decision-quality (gate ≥4)
|
||||
|
||||
**4,5/5.**
|
||||
|
||||
1. Decisões caras com rejeitadas — 5/5: roteamento (A×B×C×D), mecânica do redirect, modelo de publicação, cache/tag, validação de slug, OG/fallback e modelo do cadastro, todas com o porquê e o que foi recusado.
|
||||
2. Appetite — 4/5: cabe em ~1 dia (lib pura, collection, loader, ramo de rota, componente, migration e testes); o ciclo publicar→despublicar→republicar no e2e é o único custo além do previsto.
|
||||
3. Rabbit holes — 5/5: nomeados (analytics, expiração, QR, encurtador, preview do card, `imageSizes`, middleware, histórico de slug).
|
||||
4. Depth check — 5/5: reusa `slugify`, `canManagePublishedContent`/`hasPayloadPanelAccess`, o vocabulário de tags de `documents.ts`, `resolveSiteMetadata`/`absoluteSitePath`/`toAbsoluteUrl`/`truncate`, o molde de OG/fallback do `/corte/[id]`, o upload do `Media` e o grupo `Publicações`; nenhum wrapper raso.
|
||||
5. Intenção satisfeita — 5/5: o aceite de produto (URL de 1 segmento, miniatura do crawler, clique direto, fallback, fail-closed, noindex, instruções) não foi reescrito pela engenharia; a única cedência técnica é o ramo no dono do segmento, imposto pelo E337 e contido por reservados + teste de drift.
|
||||
|
|
@ -179,16 +179,27 @@ export default defineConfig({
|
|||
dependencies: isProdMode ? [] : ['campaign'],
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
|
||||
},
|
||||
{
|
||||
// S19 — the share-link public surface (OG card + redirect) shares the
|
||||
// metadata global with the frontend spec, so it serializes behind it in
|
||||
// dev; prod keeps the two parallel but the admin project below still
|
||||
// waits for both.
|
||||
name: 'frontendShareLink',
|
||||
testMatch: /frontendShareLink\.e2e\.spec\.ts/,
|
||||
dependencies: isProdMode ? [] : ['frontend'],
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
|
||||
},
|
||||
{
|
||||
name: 'admin',
|
||||
testMatch: /admin\.e2e\.spec\.ts/,
|
||||
// Serialize behind `frontend` in BOTH modes: admin's Instagram sync
|
||||
// panel and the frontend IG feed specs share the instagram stub and the
|
||||
// `social-feed-settings` global (single server process). Drop the
|
||||
// dependency in prod and the two projects run concurrently, letting a
|
||||
// frontend IG test flip the stub/global while admin:134 seeds and syncs
|
||||
// (OPS83 run #16: hard-failed all 3 retries on the shared stub state).
|
||||
dependencies: ['frontend'],
|
||||
// Serialize behind `frontend` (and the S19 share-link spec) in BOTH modes:
|
||||
// admin's Instagram sync panel and the frontend IG feed specs share the
|
||||
// instagram stub and the `social-feed-settings` global (single server
|
||||
// process). Drop the dependency in prod and the projects run
|
||||
// concurrently, letting a frontend IG test flip the stub/global while
|
||||
// admin:134 seeds and syncs (OPS83 run #16: hard-failed all 3 retries on
|
||||
// the shared stub state).
|
||||
dependencies: ['frontend', 'frontendShareLink'],
|
||||
fullyParallel: false,
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ export const E2E_CURATED_SPECS = [
|
|||
// exercising the export dimensions/size guard; the e2e manifest maps only
|
||||
// `src/**`, so without the curated entry a scripts/ diff would never wake it.
|
||||
'campaignChartPng',
|
||||
// S19 — deliberate: the share-link OG/redirect contract is new and the
|
||||
// migration makes every PR of this delivery high-risk (curated only).
|
||||
'frontendShareLink',
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
@ -71,6 +74,21 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
prefixes: ['src/app/(frontend)'],
|
||||
specs: ['frontend'],
|
||||
},
|
||||
{
|
||||
// S19 — the share-link surface: the `[type]` branch serves the OG card and
|
||||
// the instant handoff, and the pure module/collection/cached read own the
|
||||
// slug, destination and publish contract.
|
||||
prefixes: [
|
||||
'src/app/(frontend)/[type]',
|
||||
'src/lib/shareLink',
|
||||
'src/utilities/shareLinkReads.ts',
|
||||
// The tag/revalidation vocabulary owner for the shareLinks listing.
|
||||
'src/utilities/documents.ts',
|
||||
'src/collections/ShareLink.ts',
|
||||
'src/components/ShareLinkRedirect.tsx',
|
||||
],
|
||||
specs: ['frontendShareLink'],
|
||||
},
|
||||
{
|
||||
// S13 — the personalized-cards studio lives in shared cards components and
|
||||
// pure card* modules; a future diff there must wake the frontend spec (the
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
import { PostCard } from '@/components/PostCard'
|
||||
import { ShareLinkRedirect } from '@/components/ShareLinkRedirect'
|
||||
import { SiteHeader } from '@/components/SiteHeader'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
isValidShareLinkDestination,
|
||||
normalizeShareLinkDescription,
|
||||
shareLinkPath,
|
||||
} from '@/lib/shareLink'
|
||||
import type { ShareLink } from '@/payload-types'
|
||||
import { getCachedGlobal } from '@/utilities/globalReads'
|
||||
import { POST_TYPE_LABELS, getVisiblePosts, isPostType } from '@/utilities/posts'
|
||||
import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
|
||||
import {
|
||||
getCachedPublishedShareLinkBySlug,
|
||||
resolveShareLinkOgImageUrl,
|
||||
} from '@/utilities/shareLinkReads'
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
|
|
@ -18,39 +29,96 @@ export async function generateStaticParams(): Promise<RouteParams[]> {
|
|||
return [...types].map((type) => ({ type }))
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<RouteParams>
|
||||
}): Promise<Metadata> {
|
||||
const { type } = await params
|
||||
if (!isPostType(type)) return {}
|
||||
/**
|
||||
* S19 — the share-link page lives inside the existing `[type]` dynamic segment
|
||||
* (Next refuses a second dynamic folder name at the same level, E337). A
|
||||
* non-post-type segment is looked up as a published share link; an unknown,
|
||||
* unpublished or invalid one resolves to the same 404 (never reveals whether it
|
||||
* existed), the same uniform not-found contract as `/corte/[id]`.
|
||||
*/
|
||||
const loadPublishedShareLink = async (slug: string): Promise<ShareLink | null> => {
|
||||
const link = await getCachedPublishedShareLinkBySlug(slug)()
|
||||
if (!link || !isValidShareLinkDestination(link.destination)) return null
|
||||
return link
|
||||
}
|
||||
|
||||
const resolveShareLinkMetadata = async (link: ShareLink): Promise<Metadata> => {
|
||||
const globalMetadata = await getCachedGlobal('metadata')()
|
||||
const { siteUrl, siteName } = resolveSiteMetadata(globalMetadata)
|
||||
const label = POST_TYPE_LABELS[type]
|
||||
const title = `${label} | ${siteName}`
|
||||
const description = `${label} de ${siteName}.`
|
||||
const canonicalUrl = absoluteSitePath(siteUrl, `/${type}`)
|
||||
const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata)
|
||||
const canonicalUrl = absoluteSitePath(siteUrl, shareLinkPath(link.slug))
|
||||
const description = normalizeShareLinkDescription(link.description)
|
||||
const imageUrl = await resolveShareLinkOgImageUrl(link)
|
||||
const images = imageUrl ? [{ url: imageUrl, alt: link.title }] : []
|
||||
|
||||
return {
|
||||
title,
|
||||
title: `${link.title} | ${siteName}`,
|
||||
description,
|
||||
robots: { index: false, follow: false },
|
||||
...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'pt-BR',
|
||||
...(canonicalUrl ? { url: canonicalUrl } : {}),
|
||||
siteName,
|
||||
title,
|
||||
// The card shows exactly what the editor configured (never the destination).
|
||||
title: link.title,
|
||||
description,
|
||||
images,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: link.title,
|
||||
description,
|
||||
creator: twitterCreator,
|
||||
images: images.map((entry) => entry.url),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<RouteParams>
|
||||
}): Promise<Metadata> {
|
||||
const { type } = await params
|
||||
if (isPostType(type)) {
|
||||
const globalMetadata = await getCachedGlobal('metadata')()
|
||||
const { siteUrl, siteName } = resolveSiteMetadata(globalMetadata)
|
||||
const label = POST_TYPE_LABELS[type]
|
||||
const title = `${label} | ${siteName}`
|
||||
const description = `${label} de ${siteName}.`
|
||||
const canonicalUrl = absoluteSitePath(siteUrl, `/${type}`)
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'pt-BR',
|
||||
...(canonicalUrl ? { url: canonicalUrl } : {}),
|
||||
siteName,
|
||||
title,
|
||||
description,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const link = await loadPublishedShareLink(type)
|
||||
if (!link) return {}
|
||||
|
||||
return resolveShareLinkMetadata(link)
|
||||
}
|
||||
|
||||
export default async function Page({ params }: { params: Promise<RouteParams> }) {
|
||||
const { type } = await params
|
||||
if (!isPostType(type)) return notFound()
|
||||
|
||||
if (!isPostType(type)) {
|
||||
const link = await loadPublishedShareLink(type)
|
||||
if (!link) notFound()
|
||||
|
||||
return <ShareLinkRedirect destination={link.destination} />
|
||||
}
|
||||
|
||||
const posts = (await getVisiblePosts()).filter((post) => post.type === type)
|
||||
|
||||
|
|
|
|||
157
src/collections/ShareLink.ts
Normal file
157
src/collections/ShareLink.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import {
|
||||
SHARE_LINK_DESCRIPTION_MAX_LENGTH,
|
||||
SHARE_LINK_DESTINATION_INVALID_MESSAGE,
|
||||
SHARE_LINK_SLUG_DUPLICATE_MESSAGE,
|
||||
SHARE_LINK_SLUG_INVALID_MESSAGE,
|
||||
SHARE_LINK_SLUG_RESERVED_MESSAGE,
|
||||
SHARE_LINK_TITLE_MAX_LENGTH,
|
||||
isReservedShareLinkSlug,
|
||||
isValidShareLinkDestination,
|
||||
isValidShareLinkSlug,
|
||||
} from '@/lib/shareLink'
|
||||
import { slugify } from '@/lib/slug'
|
||||
import { canManagePublishedContent, hasPayloadPanelAccess } from '@/utilities/campaignAccess'
|
||||
import { revalidateShareLinksListing } from '@/utilities/documents'
|
||||
import type { Access, CollectionConfig, TextFieldSingleValidation } from 'payload'
|
||||
|
||||
/**
|
||||
* S19 — share links: `jorgesolla1313.com.br/<slug>` serves our own HTML with
|
||||
* the configured Open Graph card while the visitor goes straight to the
|
||||
* external destination. `published` is the kill switch (unchecked → 404);
|
||||
* anonymous reads fail closed through the `where` constraint.
|
||||
*/
|
||||
const readShareLink: Access = ({ req }) =>
|
||||
hasPayloadPanelAccess(req.user) ? true : { published: { equals: true } }
|
||||
|
||||
const validateSlug: TextFieldSingleValidation = async (value, { req, id }) => {
|
||||
if (typeof value !== 'string' || !value) return SHARE_LINK_SLUG_INVALID_MESSAGE
|
||||
if (!isValidShareLinkSlug(value)) return SHARE_LINK_SLUG_INVALID_MESSAGE
|
||||
if (isReservedShareLinkSlug(value)) return SHARE_LINK_SLUG_RESERVED_MESSAGE
|
||||
|
||||
const existing = await req.payload.find({
|
||||
collection: 'shareLink',
|
||||
where: {
|
||||
slug: { equals: value },
|
||||
...(id !== undefined && id !== null ? { id: { not_equals: id } } : {}),
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
// Deliberate access bypass — uniqueness must see every row (drafts
|
||||
// included for a plain user); admin writes are gated upstream.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (existing.totalDocs > 0) return SHARE_LINK_SLUG_DUPLICATE_MESSAGE
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const validateDestination: TextFieldSingleValidation = (value) => {
|
||||
if (typeof value !== 'string' || !value) return SHARE_LINK_DESTINATION_INVALID_MESSAGE
|
||||
if (!isValidShareLinkDestination(value)) return SHARE_LINK_DESTINATION_INVALID_MESSAGE
|
||||
return true
|
||||
}
|
||||
|
||||
export const ShareLink: CollectionConfig = {
|
||||
slug: 'shareLink',
|
||||
labels: {
|
||||
singular: 'Link de compartilhamento',
|
||||
plural: 'Links de compartilhamento',
|
||||
},
|
||||
admin: {
|
||||
group: 'Publicações',
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'slug', 'destination', 'published'],
|
||||
description:
|
||||
'Links curtos com miniatura personalizada para compartilhar no WhatsApp. O endereço é jorgesolla1313.com.br/<slug> e o clique leva direto ao destino.',
|
||||
},
|
||||
access: {
|
||||
read: readShareLink,
|
||||
create: canManagePublishedContent,
|
||||
update: canManagePublishedContent,
|
||||
delete: canManagePublishedContent,
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [revalidateShareLinksListing],
|
||||
afterDelete: [revalidateShareLinksListing],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
label: 'Título',
|
||||
required: true,
|
||||
maxLength: SHARE_LINK_TITLE_MAX_LENGTH,
|
||||
admin: {
|
||||
description:
|
||||
'Título do cartão no WhatsApp — o WhatsApp mostra no máximo 2 linhas (~60–90 caracteres).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
label: 'Slug',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description:
|
||||
'Endereço curto: jorgesolla1313.com.br/<slug>. Gerado do título quando vazio; não pode repetir nem usar palavra reservada do site. O WhatsApp guarda a miniatura em cache por URL: para trocar a miniatura de um link já compartilhado, use um slug novo.',
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [
|
||||
({ value, siblingData }) => {
|
||||
if (value) return slugify(String(value))
|
||||
if (siblingData?.title) return slugify(String(siblingData.title))
|
||||
return value
|
||||
},
|
||||
],
|
||||
},
|
||||
validate: validateSlug,
|
||||
},
|
||||
{
|
||||
name: 'destination',
|
||||
type: 'text',
|
||||
label: 'Destino',
|
||||
required: true,
|
||||
admin: {
|
||||
description:
|
||||
'URL completa do destino (ex.: o link do Google Meet da plenária), começando com http:// ou https://.',
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [({ value }) => (typeof value === 'string' ? value.trim() : value)],
|
||||
},
|
||||
validate: validateDestination,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Descrição',
|
||||
required: true,
|
||||
maxLength: SHARE_LINK_DESCRIPTION_MAX_LENGTH,
|
||||
admin: {
|
||||
description:
|
||||
'Texto do cartão no WhatsApp: ~80 caracteres já bastam; evitar passar de ~160.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'upload',
|
||||
label: 'Imagem',
|
||||
relationTo: 'media',
|
||||
admin: {
|
||||
description:
|
||||
'Imagem do cartão: 1200×630 px (proporção 1,91:1), arquivo até 600 KB, JPG ou PNG, largura mínima 300 px; evitar imagens muito largas (proporção máx. 4:1). A página do link monta a URL pública absoluta na hora de exibir o cartão. Vazio = imagem padrão do site.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'published',
|
||||
type: 'checkbox',
|
||||
label: 'Publicado',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: 'Desmarcado, o link responde 404 (kill switch).',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
16
src/components/ShareLinkRedirect.tsx
Normal file
16
src/components/ShareLinkRedirect.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* S19 — the share-link page exists for the crawler's card and for the instant
|
||||
* handoff. It renders no chrome and no visible interstice: the meta refresh
|
||||
* covers clients without JS, and the inline script navigates before paint. The
|
||||
* destination is JSON-escaped so a URL containing `</script>` cannot break out.
|
||||
*/
|
||||
export const ShareLinkRedirect = ({ destination }: { destination: string }) => (
|
||||
<>
|
||||
<meta httpEquiv="refresh" content={`0;url=${destination}`} />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.location.replace(${JSON.stringify(destination).replace(/</g, '\\u003c')})`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
57
src/lib/shareLink.ts
Normal file
57
src/lib/shareLink.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
export const SHARE_LINK_TITLE_MAX_LENGTH = 90
|
||||
export const SHARE_LINK_DESCRIPTION_MAX_LENGTH = 160
|
||||
|
||||
/**
|
||||
* Slugs that would collide with a route or a route value of the public site.
|
||||
* The drift unit test keeps this list in sync with the static segments under
|
||||
* `src/app/(frontend)` and with the post type enum (`src/utilities/posts.ts`).
|
||||
*/
|
||||
export const SHARE_LINK_RESERVED_SLUGS = [
|
||||
'cards',
|
||||
'artigos',
|
||||
'corte',
|
||||
'privacidade',
|
||||
'mandato-no-whatsapp',
|
||||
'abaixo-assinado',
|
||||
'api',
|
||||
'admin',
|
||||
'noticia',
|
||||
'campanha',
|
||||
'artigo',
|
||||
'evento',
|
||||
] as const
|
||||
|
||||
export const SHARE_LINK_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
|
||||
export const SHARE_LINK_SLUG_INVALID_MESSAGE =
|
||||
'Use apenas letras minúsculas, números e hífens (ex.: plenaria-saude).'
|
||||
export const SHARE_LINK_SLUG_RESERVED_MESSAGE =
|
||||
'Este endereço é reservado pelo site. Escolha outro slug.'
|
||||
export const SHARE_LINK_SLUG_DUPLICATE_MESSAGE =
|
||||
'Já existe um link de compartilhamento com este slug.'
|
||||
export const SHARE_LINK_DESTINATION_INVALID_MESSAGE =
|
||||
'Informe uma URL completa começando com http:// ou https://.'
|
||||
|
||||
/** Public path contract: a single root segment (`jorgesolla1313.com.br/<slug>`). */
|
||||
export const shareLinkPath = (slug: string): string => `/${slug}`
|
||||
|
||||
export const isValidShareLinkSlug = (value: string): boolean => SHARE_LINK_SLUG_PATTERN.test(value)
|
||||
|
||||
export const isReservedShareLinkSlug = (value: string): boolean =>
|
||||
(SHARE_LINK_RESERVED_SLUGS as readonly string[]).includes(value)
|
||||
|
||||
/** Only `http`/`https` absolute URLs are accepted; anything else fails closed. */
|
||||
export const isValidShareLinkDestination = (value: string): boolean => {
|
||||
if (!/^https?:\/\//i.test(value)) return false
|
||||
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Collapse whitespace so the value is safe inside a single-line meta tag. */
|
||||
export const normalizeShareLinkDescription = (value: string): string =>
|
||||
value.replace(/\s+/g, ' ').trim()
|
||||
16939
src/migrations/20260919_155232_add_share_link.json
Normal file
16939
src/migrations/20260919_155232_add_share_link.json
Normal file
File diff suppressed because it is too large
Load diff
35
src/migrations/20260919_155232_add_share_link.ts
Normal file
35
src/migrations/20260919_155232_add_share_link.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "share_link" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar NOT NULL,
|
||||
"slug" varchar NOT NULL,
|
||||
"destination" varchar NOT NULL,
|
||||
"description" varchar NOT NULL,
|
||||
"image_id" integer,
|
||||
"published" boolean DEFAULT false,
|
||||
"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 "share_link_id" integer;
|
||||
ALTER TABLE "share_link" ADD CONSTRAINT "share_link_image_id_media_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
CREATE UNIQUE INDEX "share_link_slug_idx" ON "share_link" USING btree ("slug");
|
||||
CREATE INDEX "share_link_image_idx" ON "share_link" USING btree ("image_id");
|
||||
CREATE INDEX "share_link_updated_at_idx" ON "share_link" USING btree ("updated_at");
|
||||
CREATE INDEX "share_link_created_at_idx" ON "share_link" USING btree ("created_at");
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_share_link_fk" FOREIGN KEY ("share_link_id") REFERENCES "public"."share_link"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "payload_locked_documents_rels_share_link_id_idx" ON "payload_locked_documents_rels" USING btree ("share_link_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "share_link" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "share_link" CASCADE;
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_share_link_fk";
|
||||
|
||||
DROP INDEX "payload_locked_documents_rels_share_link_id_idx";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "share_link_id";`)
|
||||
}
|
||||
|
|
@ -76,6 +76,7 @@ import * as migration_20260918_201009_add_reel from './20260918_201009_add_reel'
|
|||
import * as migration_20260919_002605_add_reel_source_hash from './20260919_002605_add_reel_source_hash';
|
||||
import * as migration_20260919_003440_add_recording from './20260919_003440_add_recording';
|
||||
import * as migration_20260919_032729_add_recording_speakers from './20260919_032729_add_recording_speakers';
|
||||
import * as migration_20260919_155232_add_share_link from './20260919_155232_add_share_link';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -466,6 +467,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260919_032729_add_recording_speakers.up,
|
||||
down: migration_20260919_032729_add_recording_speakers.down,
|
||||
name: '20260919_032729_add_recording_speakers'
|
||||
name: '20260919_032729_add_recording_speakers',
|
||||
},
|
||||
{
|
||||
up: migration_20260919_155232_add_share_link.up,
|
||||
down: migration_20260919_155232_add_share_link.down,
|
||||
name: '20260919_155232_add_share_link'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ export interface Config {
|
|||
subscription: Subscription;
|
||||
post: Post;
|
||||
tag: Tag;
|
||||
shareLink: ShareLink;
|
||||
exports: Export;
|
||||
imports: Import;
|
||||
'payload-kv': PayloadKv;
|
||||
|
|
@ -156,6 +157,7 @@ export interface Config {
|
|||
subscription: SubscriptionSelect<false> | SubscriptionSelect<true>;
|
||||
post: PostSelect<false> | PostSelect<true>;
|
||||
tag: TagSelect<false> | TagSelect<true>;
|
||||
shareLink: ShareLinkSelect<false> | ShareLinkSelect<true>;
|
||||
exports: ExportsSelect<false> | ExportsSelect<true>;
|
||||
imports: ImportsSelect<false> | ImportsSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
|
|
@ -1522,6 +1524,41 @@ export interface Tag {
|
|||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Links curtos com miniatura personalizada para compartilhar no WhatsApp. O endereço é jorgesolla1313.com.br/<slug> e o clique leva direto ao destino.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "shareLink".
|
||||
*/
|
||||
export interface ShareLink {
|
||||
id: number;
|
||||
/**
|
||||
* Título do cartão no WhatsApp — o WhatsApp mostra no máximo 2 linhas (~60–90 caracteres).
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Endereço curto: jorgesolla1313.com.br/<slug>. Gerado do título quando vazio; não pode repetir nem usar palavra reservada do site. O WhatsApp guarda a miniatura em cache por URL: para trocar a miniatura de um link já compartilhado, use um slug novo.
|
||||
*/
|
||||
slug: string;
|
||||
/**
|
||||
* URL completa do destino (ex.: o link do Google Meet da plenária), começando com http:// ou https://.
|
||||
*/
|
||||
destination: string;
|
||||
/**
|
||||
* Texto do cartão no WhatsApp: ~80 caracteres já bastam; evitar passar de ~160.
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* Imagem do cartão: 1200×630 px (proporção 1,91:1), arquivo até 600 KB, JPG ou PNG, largura mínima 300 px; evitar imagens muito largas (proporção máx. 4:1). A página do link monta a URL pública absoluta na hora de exibir o cartão. Vazio = imagem padrão do site.
|
||||
*/
|
||||
image?: (number | null) | Media;
|
||||
/**
|
||||
* Desmarcado, o link responde 404 (kill switch).
|
||||
*/
|
||||
published?: boolean | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "exports".
|
||||
|
|
@ -1867,6 +1904,10 @@ export interface PayloadLockedDocument {
|
|||
| ({
|
||||
relationTo: 'tag';
|
||||
value: number | Tag;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'shareLink';
|
||||
value: number | ShareLink;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user:
|
||||
|
|
@ -2734,6 +2775,20 @@ export interface TagSelect<T extends boolean = true> {
|
|||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "shareLink_select".
|
||||
*/
|
||||
export interface ShareLinkSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
slug?: T;
|
||||
destination?: T;
|
||||
description?: T;
|
||||
image?: T;
|
||||
published?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "exports_select".
|
||||
|
|
@ -3245,6 +3300,7 @@ export interface TaskCreateCollectionExport {
|
|||
| 'subscription'
|
||||
| 'post'
|
||||
| 'tag'
|
||||
| 'shareLink'
|
||||
| 'exports'
|
||||
| 'imports';
|
||||
drafts?: ('yes' | 'no') | null;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { RecordingMedia } from './collections/RecordingMedia'
|
|||
import { RecordingSegment } from './collections/RecordingSegment'
|
||||
import { Reel } from './collections/Reel'
|
||||
import { ReelMedia } from './collections/ReelMedia'
|
||||
import { ShareLink } from './collections/ShareLink'
|
||||
import { Signature } from './collections/Signature'
|
||||
import { Speech } from './collections/Speech'
|
||||
import { SpeechCut } from './collections/SpeechCut'
|
||||
|
|
@ -140,6 +141,7 @@ export default buildConfig({
|
|||
Subscription,
|
||||
Post,
|
||||
Tag,
|
||||
ShareLink,
|
||||
],
|
||||
globals: [SiteSettings, HomePage, Metadata, PrivacyPolicy, CampaignGoals, SocialFeedSettings],
|
||||
editor: lexicalEditor(),
|
||||
|
|
|
|||
|
|
@ -22,9 +22,12 @@ export const revalidateDocumentById = <Slug extends Collection>(
|
|||
id: string | number,
|
||||
) => revalidateTag(getDocumentCacheTag(collection, id))
|
||||
|
||||
const getListingTag = <Slug extends Collection>(collection: Slug) => `${collection}s`
|
||||
export const getCollectionListingTag = <Slug extends Collection>(collection: Slug) =>
|
||||
`${collection}s`
|
||||
|
||||
const revalidateCollectionListing = <Slug extends Collection>(collection: Slug) =>
|
||||
revalidateTag(getListingTag(collection))
|
||||
revalidateTag(getCollectionListingTag(collection))
|
||||
|
||||
export const revalidatePostsListing = () => revalidateCollectionListing('post')
|
||||
|
||||
export const revalidateShareLinksListing = () => revalidateCollectionListing('shareLink')
|
||||
|
|
|
|||
71
src/utilities/shareLinkReads.ts
Normal file
71
src/utilities/shareLinkReads.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import 'server-only'
|
||||
|
||||
import { getCachedDocumentById } from '@/utilities/documentReads'
|
||||
import { getCollectionListingTag } from '@/utilities/documents'
|
||||
import { getCachedGlobal } from '@/utilities/globalReads'
|
||||
import { resolveSiteMetadata, toAbsoluteUrl } from '@/utilities/seo'
|
||||
import configPromise from '@payload-config'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import type { Media, ShareLink } from '@/payload-types'
|
||||
|
||||
// Config-dependent cached read of a published share link by slug. Kept apart
|
||||
// from `documents.ts` (the tag vocabulary/revalidation contract collections
|
||||
// import) so the module graph stays acyclic — same split as `documentReads.ts`.
|
||||
// The anonymous filter lives in the query itself: a draft never leaves here.
|
||||
|
||||
const findPublishedShareLinkBySlug = async (
|
||||
slug: string,
|
||||
depth: number,
|
||||
): Promise<ShareLink | null> => {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const result = await payload.find({
|
||||
collection: 'shareLink',
|
||||
where: { slug: { equals: slug }, published: { equals: true } },
|
||||
depth,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
return result.docs[0] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Public read of `jorgesolla1313.com.br/<slug>`. Cached under the collection
|
||||
* listing tag (`shareLinks`), busted by the collection's afterChange/afterDelete
|
||||
* hooks — one tag invalidates every slug, the same listing contract as posts.
|
||||
*/
|
||||
export const getCachedPublishedShareLinkBySlug = (slug: string, depth = 1) =>
|
||||
unstable_cache(
|
||||
() => findPublishedShareLinkBySlug(slug, depth),
|
||||
['share-link-by-slug', slug, String(depth)],
|
||||
{
|
||||
tags: [getCollectionListingTag('shareLink')],
|
||||
},
|
||||
)
|
||||
|
||||
const mediaOf = (value: Media | number | null | undefined): Media | null =>
|
||||
typeof value === 'object' && value !== null ? value : null
|
||||
|
||||
/**
|
||||
* The card image: the link's own upload first; without it the site default
|
||||
* image of the `metadata` global — never a relative URL the WhatsApp crawler
|
||||
* cannot resolve. Same fallback contract as `/corte/[id]` and the root layout;
|
||||
* `siteUrl` comes from `resolveSiteMetadata` (empty global never crashes).
|
||||
*/
|
||||
export const resolveShareLinkOgImageUrl = async (link: ShareLink): Promise<string | null> => {
|
||||
const globalMetadata = await getCachedGlobal('metadata')()
|
||||
const { siteUrl } = resolveSiteMetadata(globalMetadata)
|
||||
|
||||
const configured = mediaOf(link.image)?.url
|
||||
if (configured && siteUrl) return toAbsoluteUrl(configured, siteUrl)
|
||||
|
||||
const fallback =
|
||||
typeof globalMetadata.image === 'number'
|
||||
? await getCachedDocumentById('media', String(globalMetadata.image))()
|
||||
: globalMetadata.image
|
||||
const fallbackUrl = mediaOf(fallback)?.url
|
||||
if (!fallbackUrl || !siteUrl) return null
|
||||
|
||||
return toAbsoluteUrl(fallbackUrl, siteUrl)
|
||||
}
|
||||
225
tests/e2e/frontendShareLink.e2e.spec.ts
Normal file
225
tests/e2e/frontendShareLink.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import type { APIRequestContext } from '@playwright/test'
|
||||
import { request as playwrightRequest } from '@playwright/test'
|
||||
|
||||
import { adminHeaders } from '../helpers/adminApi'
|
||||
import { seedTestUser } from '../helpers/seedUser'
|
||||
import { expect, test } from './fixtures/e2eTest'
|
||||
|
||||
/**
|
||||
* S19 — share links over real HTTP: `jorgesolla1313.com.br/<slug>` serves the
|
||||
* configured Open Graph card (the WhatsApp crawler reads the raw HTML) and hands
|
||||
* the visitor to the destination without an interstice (inline script; meta
|
||||
* refresh when JS is off). Unpublished and unknown slugs answer the same 404,
|
||||
* and the kill switch flips without a deploy (404 → 200 after republishing).
|
||||
*
|
||||
* Links and media are seeded through the deployed REST API (admin session) so
|
||||
* the server process runs the real cache hooks; a Local API call from the runner
|
||||
* would throw on `revalidateTag` (same reason as the speech-cut spec).
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
|
||||
|
||||
// 1×1 opaque PNG — enough for the upload (no dimensions are enforced on purpose).
|
||||
const TEST_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
)
|
||||
|
||||
const createdShareLinkIds: number[] = []
|
||||
const createdMediaIds: number[] = []
|
||||
|
||||
/** Reads a meta tag by one attribute and returns its `content` (attribute-order tolerant). */
|
||||
const metaContent = (html: string, attribute: string, value: string): string | null => {
|
||||
for (const tag of html.match(/<meta\b[^>]*>/g) ?? []) {
|
||||
if (!tag.includes(`${attribute}="${value}"`)) continue
|
||||
return tag.match(/content="([^"]*)"/)?.[1] ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const createMedia = async (
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
alt: string,
|
||||
): Promise<number> => {
|
||||
const response = await request.post(`${BASE_URL}/api/media`, {
|
||||
headers,
|
||||
// Payload multipart carries the document fields in `_payload` (JSON).
|
||||
multipart: {
|
||||
_payload: JSON.stringify({ alt }),
|
||||
file: {
|
||||
name: `share-link-${randomUUID().slice(0, 8)}.png`,
|
||||
mimeType: 'image/png',
|
||||
buffer: TEST_PNG,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
const mediaId = ((await response.json()) as { doc: { id: number } }).doc.id
|
||||
createdMediaIds.push(mediaId)
|
||||
return mediaId
|
||||
}
|
||||
|
||||
const createShareLink = async (
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
data: {
|
||||
title: string
|
||||
slug: string
|
||||
destination: string
|
||||
description: string
|
||||
published: boolean
|
||||
image?: number
|
||||
},
|
||||
): Promise<number> => {
|
||||
const response = await request.post(`${BASE_URL}/api/shareLink`, { headers, data })
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
const id = ((await response.json()) as { doc: { id: number } }).doc.id
|
||||
createdShareLinkIds.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
const setShareLinkPublished = async (
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
id: number,
|
||||
published: boolean,
|
||||
): Promise<void> => {
|
||||
const response = await request.patch(`${BASE_URL}/api/shareLink/${id}`, {
|
||||
headers,
|
||||
data: { published },
|
||||
})
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
}
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
const headers = await adminHeaders(request, BASE_URL).catch(() => null)
|
||||
if (!headers) return
|
||||
for (const id of createdShareLinkIds.splice(0)) {
|
||||
await request.delete(`${BASE_URL}/api/shareLink/${id}`, { headers }).catch(() => undefined)
|
||||
}
|
||||
for (const id of createdMediaIds.splice(0)) {
|
||||
await request.delete(`${BASE_URL}/api/media/${id}`, { headers }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Frontend share links (S19)', () => {
|
||||
test.beforeAll(async () => {
|
||||
await seedTestUser()
|
||||
})
|
||||
|
||||
test('serves the configured card and hands the visitor straight to the destination', async ({
|
||||
browser,
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const headers = await adminHeaders(request, BASE_URL)
|
||||
const mediaId = await createMedia(request, headers, 'Miniatura da plenária')
|
||||
const slug = `plenaria-saude-${randomUUID().slice(0, 8)}`
|
||||
const title = 'Plenária da Saúde em Feira de Santana'
|
||||
const description = 'Participe da plenária sobre saúde nesta quinta-feira.'
|
||||
const destination = `${BASE_URL}/artigos`
|
||||
await createShareLink(request, headers, {
|
||||
title,
|
||||
slug,
|
||||
destination,
|
||||
description,
|
||||
published: true,
|
||||
image: mediaId,
|
||||
})
|
||||
|
||||
const anonymous = await playwrightRequest.newContext({ baseURL: BASE_URL })
|
||||
try {
|
||||
const response = await anonymous.get(`/${slug}`)
|
||||
expect(response.status()).toBe(200)
|
||||
const html = await response.text()
|
||||
|
||||
expect(metaContent(html, 'property', 'og:title')).toBe(title)
|
||||
expect(metaContent(html, 'property', 'og:description')).toBe(description)
|
||||
// The canonical origin comes from the site URL (prod mode inlines the
|
||||
// canonical HTTPS origin), so derive it from the served HTML itself.
|
||||
const canonical = html.match(/<link rel="canonical" href="([^"]*)"/)?.[1]
|
||||
expect(canonical).toMatch(new RegExp(`^https?://[^/]+/${slug}$`))
|
||||
expect(metaContent(html, 'property', 'og:url')).toBe(canonical)
|
||||
expect(metaContent(html, 'property', 'og:image')).toMatch(/^http[^"]*\/api\/media\/file\//)
|
||||
expect(metaContent(html, 'name', 'robots')).toBe('noindex, nofollow')
|
||||
expect(metaContent(html, 'name', 'twitter:card')).toBe('summary_large_image')
|
||||
expect(metaContent(html, 'http-equiv', 'refresh')).toBe(`0;url=${destination}`)
|
||||
expect(html).toContain(`window.location.replace("${destination}")`)
|
||||
// The card never leaks the raw destination.
|
||||
expect(metaContent(html, 'property', 'og:description')).not.toBe(destination)
|
||||
} finally {
|
||||
await anonymous.dispose()
|
||||
}
|
||||
|
||||
await page.goto(`/${slug}`, { waitUntil: 'commit' }).catch(() => undefined)
|
||||
await expect(page).toHaveURL(destination)
|
||||
|
||||
const noJsContext = await browser.newContext({ javaScriptEnabled: false })
|
||||
try {
|
||||
const noJsPage = await noJsContext.newPage()
|
||||
await noJsPage.goto(`/${slug}`, { waitUntil: 'commit' }).catch(() => undefined)
|
||||
await expect(noJsPage).toHaveURL(destination)
|
||||
} finally {
|
||||
await noJsContext.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('answers the same 404 for an unpublished link and an unknown slug', async ({ request }) => {
|
||||
const headers = await adminHeaders(request, BASE_URL)
|
||||
const slug = `rascunho-${randomUUID().slice(0, 8)}`
|
||||
await createShareLink(request, headers, {
|
||||
title: 'Rascunho que não deve aparecer',
|
||||
slug,
|
||||
destination: 'https://meet.google.com/abc-defg-hij',
|
||||
description: 'Ainda não publicado.',
|
||||
published: false,
|
||||
})
|
||||
|
||||
const anonymous = await playwrightRequest.newContext({ baseURL: BASE_URL })
|
||||
try {
|
||||
const unpublished = await anonymous.get(`/${slug}`)
|
||||
const unknown = await anonymous.get(`/nao-existe-${randomUUID().slice(0, 8)}`)
|
||||
|
||||
expect(unpublished.status()).toBe(404)
|
||||
expect(unknown.status()).toBe(404)
|
||||
|
||||
const unpublishedHtml = await unpublished.text()
|
||||
const unknownHtml = await unknown.text()
|
||||
expect(unpublishedHtml).not.toContain('Rascunho que não deve aparecer')
|
||||
expect(unknownHtml).not.toContain('Rascunho que não deve aparecer')
|
||||
// The same not-found screen either way (never reveals whether it existed).
|
||||
expect(unpublishedHtml).toContain('This page could not be found')
|
||||
expect(unknownHtml).toContain('This page could not be found')
|
||||
} finally {
|
||||
await anonymous.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('flips the kill switch without a deploy (404 and back)', async ({ request }) => {
|
||||
const headers = await adminHeaders(request, BASE_URL)
|
||||
const slug = `kill-switch-${randomUUID().slice(0, 8)}`
|
||||
const id = await createShareLink(request, headers, {
|
||||
title: 'Plenária com kill switch',
|
||||
slug,
|
||||
destination: `${BASE_URL}/artigos`,
|
||||
description: 'Publicado, despublicado e republicado.',
|
||||
published: true,
|
||||
})
|
||||
|
||||
const anonymous = await playwrightRequest.newContext({ baseURL: BASE_URL })
|
||||
try {
|
||||
expect((await anonymous.get(`/${slug}`)).status()).toBe(200)
|
||||
|
||||
await setShareLinkPublished(request, headers, id, false)
|
||||
expect((await anonymous.get(`/${slug}`)).status()).toBe(404)
|
||||
|
||||
await setShareLinkPublished(request, headers, id, true)
|
||||
expect((await anonymous.get(`/${slug}`)).status()).toBe(200)
|
||||
} finally {
|
||||
await anonymous.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
304
tests/int/shareLink.int.spec.ts
Normal file
304
tests/int/shareLink.int.spec.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
// @vitest-environment node
|
||||
|
||||
/**
|
||||
* S19 — `shareLink`: slug validation (format, reserved, duplicate), destination
|
||||
* validation (http/https only), slugify fallback, the fail-closed `published`
|
||||
* switch for anonymous reads, the slug-keyed public loader and the listing cache
|
||||
* tag busted by afterChange/afterDelete.
|
||||
*/
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { revalidateTagMock, getCachedGlobalMock } = vi.hoisted(() => ({
|
||||
revalidateTagMock: vi.fn(),
|
||||
getCachedGlobalMock: vi.fn(),
|
||||
}))
|
||||
|
||||
// The collection hooks bust the Next cache tag; the loader reaches
|
||||
// `unstable_cache`. Both need the Next runtime, so mock it — the assertion
|
||||
// pins the tag name.
|
||||
vi.mock('next/cache', () => ({
|
||||
revalidatePath: () => undefined,
|
||||
revalidateTag: revalidateTagMock,
|
||||
unstable_cache: (fn: unknown) => fn,
|
||||
}))
|
||||
|
||||
// The site-default image fallback reads the shared `metadata` global; mocking
|
||||
// the read lets the test exercise it without writing a global the rest of the
|
||||
// suite (int and e2e share `teqo_wt19_test`) also consumes.
|
||||
vi.mock('@/utilities/globalReads', () => ({
|
||||
getCachedGlobal: getCachedGlobalMock,
|
||||
}))
|
||||
|
||||
import {
|
||||
SHARE_LINK_DESTINATION_INVALID_MESSAGE,
|
||||
SHARE_LINK_SLUG_DUPLICATE_MESSAGE,
|
||||
SHARE_LINK_SLUG_INVALID_MESSAGE,
|
||||
SHARE_LINK_SLUG_RESERVED_MESSAGE,
|
||||
} from '@/lib/shareLink'
|
||||
import config from '@/payload.config'
|
||||
import {
|
||||
getCachedPublishedShareLinkBySlug,
|
||||
resolveShareLinkOgImageUrl,
|
||||
} from '@/utilities/shareLinkReads'
|
||||
|
||||
const TEST_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
)
|
||||
|
||||
let payload: Payload
|
||||
const createdIds = new Set<number>()
|
||||
const createdMediaIds = new Set<number>()
|
||||
|
||||
type ShareLinkInput = {
|
||||
title: string
|
||||
slug: string
|
||||
destination: string
|
||||
description: string
|
||||
image?: number
|
||||
published?: boolean
|
||||
}
|
||||
|
||||
const createShareLink = async (data: ShareLinkInput) => {
|
||||
const doc = await payload.create({ collection: 'shareLink', data })
|
||||
createdIds.add(doc.id)
|
||||
return doc
|
||||
}
|
||||
|
||||
const findAnonymous = (slug: string) =>
|
||||
payload.find({
|
||||
collection: 'shareLink',
|
||||
where: { slug: { equals: slug } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* Payload wraps a field `validate` message in a generic ValidationError; the
|
||||
* pt-BR message travels in `data.errors[].message`.
|
||||
*/
|
||||
const createErrorMessages = async (data: ShareLinkInput): Promise<string[]> => {
|
||||
try {
|
||||
const doc = await payload.create({ collection: 'shareLink', data })
|
||||
createdIds.add(doc.id)
|
||||
} catch (error) {
|
||||
const entries =
|
||||
(error as { data?: { errors?: Array<{ message?: string }> } }).data?.errors ?? []
|
||||
return entries.map((entry) => entry.message ?? '')
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
describe('shareLink', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
revalidateTagMock.mockClear()
|
||||
getCachedGlobalMock.mockReset()
|
||||
// `resolveSiteMetadata` falls back to this env when the global has no URL.
|
||||
process.env.NEXT_PUBLIC_SITE_URL = 'https://site.test'
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.NEXT_PUBLIC_SITE_URL
|
||||
for (const id of createdIds) {
|
||||
await payload.delete({ collection: 'shareLink', id }).catch(() => undefined)
|
||||
}
|
||||
for (const id of createdMediaIds) {
|
||||
await payload.delete({ collection: 'media', id }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a draft by default and busts the shareLinks tag', async () => {
|
||||
const doc = await createShareLink({
|
||||
title: 'Plenária da saúde',
|
||||
slug: 'plenaria-saude',
|
||||
destination: 'https://meet.google.com/abc-defg-hij',
|
||||
description: 'Participe da plenária sobre saúde.',
|
||||
})
|
||||
|
||||
expect(doc.published).toBe(false)
|
||||
expect(revalidateTagMock).toHaveBeenCalledWith('shareLinks')
|
||||
})
|
||||
|
||||
it('generates the slug from the title when empty', async () => {
|
||||
const doc = await createShareLink({
|
||||
title: 'Plenária da Saúde em Feira',
|
||||
slug: '',
|
||||
destination: 'https://meet.google.com/abc-defg-hij',
|
||||
description: 'Participe.',
|
||||
})
|
||||
|
||||
expect(doc.slug).toBe('plenaria-da-saude-em-feira')
|
||||
})
|
||||
|
||||
it('trims the destination', async () => {
|
||||
const doc = await createShareLink({
|
||||
title: 'Reunião',
|
||||
slug: 'reuniao-trim',
|
||||
destination: ' https://meet.google.com/abc-defg-hij ',
|
||||
description: 'Participe.',
|
||||
})
|
||||
|
||||
expect(doc.destination).toBe('https://meet.google.com/abc-defg-hij')
|
||||
})
|
||||
|
||||
it('rejects a reserved slug with a clear message', async () => {
|
||||
const messages = await createErrorMessages({
|
||||
title: 'Corte',
|
||||
slug: 'corte',
|
||||
destination: 'https://meet.google.com/abc',
|
||||
description: 'x',
|
||||
})
|
||||
|
||||
expect(messages).toContain(SHARE_LINK_SLUG_RESERVED_MESSAGE)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Plenaria', 'plenaria'],
|
||||
['plenaria da saude', 'plenaria-da-saude'],
|
||||
['plenaria--feira', 'plenaria-feira'],
|
||||
['plenaria.2026', 'plenaria-2026'],
|
||||
])('normalizes the slug %s to %s', async (input, expected) => {
|
||||
const doc = await createShareLink({
|
||||
title: 'Plenária',
|
||||
slug: input,
|
||||
destination: 'https://meet.google.com/abc',
|
||||
description: 'x',
|
||||
})
|
||||
|
||||
expect(doc.slug).toBe(expected)
|
||||
})
|
||||
|
||||
it('rejects a slug that resolves to nothing with a clear message', async () => {
|
||||
const messages = await createErrorMessages({
|
||||
title: 'Plenária',
|
||||
slug: '---',
|
||||
destination: 'https://meet.google.com/abc',
|
||||
description: 'x',
|
||||
})
|
||||
|
||||
expect(messages).toContain(SHARE_LINK_SLUG_INVALID_MESSAGE)
|
||||
})
|
||||
|
||||
it('rejects a duplicate slug with a clear message', async () => {
|
||||
await createShareLink({
|
||||
title: 'Primeira',
|
||||
slug: 'slug-unico-s19',
|
||||
destination: 'https://meet.google.com/abc',
|
||||
description: 'x',
|
||||
})
|
||||
|
||||
const messages = await createErrorMessages({
|
||||
title: 'Segunda',
|
||||
slug: 'slug-unico-s19',
|
||||
destination: 'https://meet.google.com/def',
|
||||
description: 'y',
|
||||
})
|
||||
|
||||
expect(messages).toContain(SHARE_LINK_SLUG_DUPLICATE_MESSAGE)
|
||||
})
|
||||
|
||||
it.each(['meet.google.com/abc', 'ftp://example.com/x', 'javascript:alert(1)'])(
|
||||
'rejects the invalid destination %s',
|
||||
async (destination) => {
|
||||
const messages = await createErrorMessages({
|
||||
title: 'Plenária',
|
||||
slug: `destino-invalido-${Buffer.from(destination).toString('hex').slice(0, 12)}`,
|
||||
destination,
|
||||
description: 'x',
|
||||
})
|
||||
|
||||
expect(messages).toContain(SHARE_LINK_DESTINATION_INVALID_MESSAGE)
|
||||
},
|
||||
)
|
||||
|
||||
it('hides drafts from anonymous reads and exposes them once published', async () => {
|
||||
const draft = await createShareLink({
|
||||
title: 'Rascunho',
|
||||
slug: 'rascunho-s19',
|
||||
destination: 'https://meet.google.com/abc',
|
||||
description: 'x',
|
||||
})
|
||||
|
||||
expect((await findAnonymous('rascunho-s19')).totalDocs).toBe(0)
|
||||
expect(await getCachedPublishedShareLinkBySlug('rascunho-s19')()).toBeNull()
|
||||
|
||||
await payload.update({ collection: 'shareLink', id: draft.id, data: { published: true } })
|
||||
|
||||
expect((await findAnonymous('rascunho-s19')).totalDocs).toBe(1)
|
||||
const loaded = await getCachedPublishedShareLinkBySlug('rascunho-s19')()
|
||||
expect(loaded?.id).toBe(draft.id)
|
||||
})
|
||||
|
||||
it('busts the tag on update and delete', async () => {
|
||||
const doc = await createShareLink({
|
||||
title: 'Ciclo',
|
||||
slug: 'ciclo-s19',
|
||||
destination: 'https://meet.google.com/abc',
|
||||
description: 'x',
|
||||
})
|
||||
|
||||
revalidateTagMock.mockClear()
|
||||
await payload.update({ collection: 'shareLink', id: doc.id, data: { published: true } })
|
||||
expect(revalidateTagMock).toHaveBeenCalledWith('shareLinks')
|
||||
|
||||
revalidateTagMock.mockClear()
|
||||
await payload.delete({ collection: 'shareLink', id: doc.id })
|
||||
createdIds.delete(doc.id)
|
||||
expect(revalidateTagMock).toHaveBeenCalledWith('shareLinks')
|
||||
})
|
||||
|
||||
it('resolves the configured image and falls back to the site default', async () => {
|
||||
const media = await payload.create({
|
||||
collection: 'media',
|
||||
data: { alt: 'Imagem do link' },
|
||||
file: {
|
||||
data: TEST_PNG,
|
||||
mimetype: 'image/png',
|
||||
name: `share-link-int-${Date.now()}.png`,
|
||||
size: TEST_PNG.length,
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
createdMediaIds.add(media.id)
|
||||
const expectedUrl = `https://site.test${media.url}`
|
||||
|
||||
const withImage = await createShareLink({
|
||||
title: 'Com imagem',
|
||||
slug: 'imagem-configurada-s19',
|
||||
destination: 'https://meet.google.com/abc',
|
||||
description: 'x',
|
||||
image: media.id,
|
||||
})
|
||||
const populated = await payload.findByID({
|
||||
collection: 'shareLink',
|
||||
id: withImage.id,
|
||||
depth: 1,
|
||||
})
|
||||
getCachedGlobalMock.mockReturnValue(async () => ({}))
|
||||
expect(await resolveShareLinkOgImageUrl(populated)).toBe(expectedUrl)
|
||||
|
||||
const withoutImage = await createShareLink({
|
||||
title: 'Sem imagem',
|
||||
slug: 'imagem-fallback-s19',
|
||||
destination: 'https://meet.google.com/abc',
|
||||
description: 'x',
|
||||
})
|
||||
// No site default configured: no image at all — never a relative/broken one.
|
||||
expect(await resolveShareLinkOgImageUrl(withoutImage)).toBeNull()
|
||||
|
||||
getCachedGlobalMock.mockReturnValue(async () => ({
|
||||
image: { url: '/api/media/file/site-default.png' },
|
||||
}))
|
||||
expect(await resolveShareLinkOgImageUrl(withoutImage)).toBe(
|
||||
'https://site.test/api/media/file/site-default.png',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -471,6 +471,10 @@ describe('src/utilities top-level is pinned', () => {
|
|||
// (municipality + people): pure client-side factory, no Payload/Next.
|
||||
'campaignSavedFilterStore.ts',
|
||||
'campaignPageActor.ts',
|
||||
// S19 — cached slug-keyed read of a published share link (listing tag) and
|
||||
// the OG image resolver with the site-default fallback; the sibling of
|
||||
// `documentReads.ts` (config-dependent side of the tag vocabulary).
|
||||
'shareLinkReads.ts',
|
||||
'campaignPasswordReset.ts',
|
||||
'campaignPwa.ts',
|
||||
'campaignPwaClient.ts',
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ describe('E2E_AFFECTED_MANIFEST (OPS5)', () => {
|
|||
// C191-runtime — deliberate: real-Chromium PNG smoke; the curated net is
|
||||
// the only path that wakes it for a scripts/ (high-risk) diff.
|
||||
'campaignChartPng',
|
||||
// S19 — deliberate: the share-link OG/redirect contract is new and the
|
||||
// migration makes every PR of this delivery high-risk (curated only).
|
||||
'frontendShareLink',
|
||||
])
|
||||
const onDisk = new Set(specNamesOnDisk())
|
||||
for (const spec of E2E_CURATED_SPECS) {
|
||||
|
|
|
|||
147
tests/unit/shareLink.unit.spec.ts
Normal file
147
tests/unit/shareLink.unit.spec.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
isReservedShareLinkSlug,
|
||||
isValidShareLinkDestination,
|
||||
isValidShareLinkSlug,
|
||||
normalizeShareLinkDescription,
|
||||
SHARE_LINK_RESERVED_SLUGS,
|
||||
SHARE_LINK_SLUG_PATTERN,
|
||||
shareLinkPath,
|
||||
} from '@/lib/shareLink'
|
||||
|
||||
describe('shareLink slug', () => {
|
||||
it('accepts lowercase kebab-case slugs', () => {
|
||||
expect(isValidShareLinkSlug('plenaria-saude-15-07')).toBe(true)
|
||||
expect(isValidShareLinkSlug('meet')).toBe(true)
|
||||
expect(isValidShareLinkSlug('a1')).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'Plenaria',
|
||||
'plenaria_saude',
|
||||
'plenaria--saude',
|
||||
'-plenaria',
|
||||
'plenaria-',
|
||||
'',
|
||||
'a/b',
|
||||
'a.b',
|
||||
])('rejects %s', (value) => {
|
||||
expect(isValidShareLinkSlug(value)).toBe(false)
|
||||
expect(SHARE_LINK_SLUG_PATTERN.test(value)).toBe(false)
|
||||
})
|
||||
|
||||
it('flags the reserved slugs literally', () => {
|
||||
expect([...SHARE_LINK_RESERVED_SLUGS].sort()).toEqual(
|
||||
[
|
||||
'abaixo-assinado',
|
||||
'admin',
|
||||
'api',
|
||||
'artigo',
|
||||
'artigos',
|
||||
'campanha',
|
||||
'cards',
|
||||
'corte',
|
||||
'evento',
|
||||
'mandato-no-whatsapp',
|
||||
'noticia',
|
||||
'privacidade',
|
||||
].sort(),
|
||||
)
|
||||
expect(isReservedShareLinkSlug('corte')).toBe(true)
|
||||
expect(isReservedShareLinkSlug('plenaria-saude')).toBe(false)
|
||||
})
|
||||
|
||||
it('builds the single-segment public path', () => {
|
||||
expect(shareLinkPath('plenaria-saude')).toBe('/plenaria-saude')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shareLink destination', () => {
|
||||
it.each([
|
||||
'https://meet.google.com/abc-defg-hij',
|
||||
'http://example.com',
|
||||
'https://example.com/path?x=1#y',
|
||||
])('accepts %s', (value) => {
|
||||
expect(isValidShareLinkDestination(value)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'meet.google.com/abc',
|
||||
'ftp://example.com/file',
|
||||
'javascript:alert(1)',
|
||||
'data:text/html,x',
|
||||
'mailto:x@example.com',
|
||||
'http:foo',
|
||||
'https:example.com',
|
||||
'',
|
||||
])('rejects %s', (value) => {
|
||||
expect(isValidShareLinkDestination(value)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeShareLinkDescription', () => {
|
||||
it('collapses newlines and repeated spaces so the meta tag stays single-line', () => {
|
||||
expect(normalizeShareLinkDescription(' Venha para a\n\nplenária de saúde ')).toBe(
|
||||
'Venha para a plenária de saúde',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reserved slugs drift', () => {
|
||||
const ROUTE_FILES = new Set(['page.tsx', 'page.ts', 'route.ts', 'route.tsx'])
|
||||
|
||||
/** Any route file below `dir` (dynamic segments included) makes the segment a URL. */
|
||||
const hasRouteFile = (dir: string): boolean => {
|
||||
if (!existsSync(dir)) return false
|
||||
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isFile() && ROUTE_FILES.has(entry.name)) return true
|
||||
if (entry.isDirectory() && hasRouteFile(path.join(dir, entry.name))) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const staticRouteSegments = (): string[] => {
|
||||
const roots = ['src/app/(frontend)', 'src/app/(frontend)/(home)']
|
||||
const segments = new Set<string>()
|
||||
|
||||
for (const root of roots) {
|
||||
const absolute = path.resolve(process.cwd(), root)
|
||||
if (!existsSync(absolute)) continue
|
||||
|
||||
for (const entry of readdirSync(absolute, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('[') || entry.name.startsWith('('))
|
||||
continue
|
||||
|
||||
if (hasRouteFile(path.join(absolute, entry.name))) segments.add(entry.name)
|
||||
}
|
||||
}
|
||||
|
||||
return [...segments]
|
||||
}
|
||||
|
||||
/** The post type enum is private to the server-only `posts.ts`; read it as source. */
|
||||
const postTypesOnDisk = (): string[] => {
|
||||
const source = readFileSync(path.resolve(process.cwd(), 'src/utilities/posts.ts'), 'utf8')
|
||||
const match = source.match(/const POST_TYPES = \[([^\]]+)\]/)
|
||||
if (!match?.[1]) return []
|
||||
return [...match[1].matchAll(/'([^']+)'/g)].map((entry) => entry[1]!)
|
||||
}
|
||||
|
||||
it('reserves every static route segment at the root of the public site', () => {
|
||||
const missing = staticRouteSegments().filter((segment) => !isReservedShareLinkSlug(segment))
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('reserves every post type value (the `[type]` branch answers them first)', () => {
|
||||
const types = postTypesOnDisk()
|
||||
expect(types).toEqual(['noticia', 'campanha', 'artigo', 'evento'])
|
||||
|
||||
const missing = types.filter((type) => !isReservedShareLinkSlug(type))
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue