20 KiB
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) ejorgesolla1313.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áginanoindex,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; grupoPublicaçõ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çõesA; 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
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
-
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 inlinelocation.replacecom 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 onoindexnã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 nocontent/script do HTML, nunca emog: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. -
Publicação — checkbox
published, defaultfalse(caro). Opções: A) booleanpublished(kill switch) com defaultfalse(publicar é ato explícito); B) drafts/versions (comoPost); C) selectstatus(comoSpeechCut). Recomendação: A — o produto pede um kill switch, não fluxo editorial: versions adicionam_status, tabelas_ve autosave sem consumidor; o select do corte carrega um ciclo de job (processing/failed) que não existe aqui. Defaultfalseporque 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 filtrapublished: 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. -
Leitura cacheada e revalidação — tag de listing no vocabulário de
documents.ts(caro). Opções: A)getCachedPublishedShareLinkBySlugemsrc/utilities/shareLinkReads.ts(unstable_cache, tags[getCollectionListingTag('shareLink')]→shareLinks) +revalidateShareLinksListing()exportado pordocuments.tse chamado emafterChange/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 privadogetListingTagviragetCollectionListingTagexportado; precedenterevalidatePostsListing) e mantém o grafo acíclico: a lib pura não importa@payload-config; sóshareLinkReads.tsimporta config, comimport '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). -
Validação de slug —
beforeValidate(slugify) +validateassíncrono + índice único (caro). Opções: A)beforeValidatereusandoslugify(src/lib/slug.ts, precedentePost.ts),validateasync com formato kebab-case, reservados e duplicata viareq.payload.find(overrideAccess: truecom comentário de bypass), eunique: trueno campo; B) sóunique: true(mensagem genérica do Payload); C) schema zod emsrc/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. -
Imagem e forma do OG — molde do
/corte/[id](caro o suficiente). Opções: A)imageupload opcional →toAbsoluteUrl; vazio → imagem do globalmetadataviagetCachedDocumentById('media', …); OGtype: 'website',url/canonical = URL curta, twittersummary_large_image,robots: {index:false, follow:false}; B) exigir imagem no link; C) criarimageSizes/derivadas noMedia. Recomendação: A — reusaresolveSiteMetadata/absoluteSitePath/toAbsoluteUrl/truncate(src/utilities/seo.ts) e a resolução global→mídia do corte e do layout raiz. SemsiteUrl, omite a imagem em vez de emitirog:imagerelativa (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_namesegue 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. -
Modelo do cadastro —
shareLinkno grupoPublicações(caro). Opções: A) collectionshareLink, labels "Link de compartilhamento"/"Links de compartilhamento", campostitle(text, required,maxLength90),slug(text, required, unique, index, slugify+validate),destination(text, required, trim + validate http/https),description(textarea, required,maxLength160, whitespace colapsado no metadata),image(upload → media, opcional),published(checkbox, default false),admin.descriptioncom os literais de instrução; B)shortLink; C)redirect. Recomendação: A — o nome diz a função de produto;Publicaçõesjá existe (Post) e é onde a comunicação publica. Osadmin.descriptioncopiam 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'; semversions; semcreatedBy(conteúdo público de CMS, comoPost). Rejeitadas: B — puxa o rabbit hole "encurtador"; C — colide com o conceito de infra de redirect e não descreve o produto. -
Access — leitura fail-closed, sem Consent novo (barato).
read: staff (hasPayloadPanelAccess) vê tudo; anônimo recebe{ published: { equals: true } }(moldecanReadSpeechCut,access/speeches.ts);create/update/delete:canManagePublishedContent(editores/admin, comoPost/Media). SemConsent(não há PII/opt-in), semContact, sem transação multi-collection (a escrita é de um doc só), semstampCampaignCreatedBy(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; reusaslugifyecanManagePublishedContent/hasPayloadPanelAccess;beforeValidatede slug;validateasync de unicidade com bypass comentado; hooksafterChange/afterDeletechamandorevalidateShareLinksListing().src/payload.config.ts(editar): import + entrada no array (junto dePost/Tag).src/utilities/documents.ts(editar):getListingTag→getCollectionListingTagexportado +revalidateShareLinksListing()(sem importar config).src/utilities/shareLinkReads.ts(novo,server-only, imports estáticos):getCachedPublishedShareLinkBySlug(slug, depth = 1)—findcomslug equals+published equals true,unstable_cachetags[getCollectionListingTag('shareLink')], key['share-link-by-slug', slug, String(depth)]; eresolveShareLinkOgImageUrl(link)— imagem do link → fallback do globalmetadata(número → mídia) →null, comsiteUrlderivado deresolveSiteMetadatae URL sempre absoluta viatoAbsoluteUrl(nunca relativa).src/app/(frontend)/[type]/page.tsx(editar): ramo!isPostType(type)noPage(achou →<ShareLinkRedirect>; senãonotFound()) e o mesmo ramo nogenerateMetadata(OG completo;{}quando não achou).src/components/ShareLinkRedirect.tsx(novo): server component sem DOM visível —<meta httpEquiv="refresh" content=... />(hoisted) +<script dangerouslySetInnerHTML>comlocation.replace(JSON.stringify(destination).replace(/</g, '\\u003c')); semSiteHeader/hierarquia.- Testes:
tests/unit/shareLink.unit.spec.ts(puros + lista de reservados literal + drift: todo segmento de rota estático emsrc/app/(frontend)/(home)está reservado),tests/int/shareLink.int.spec.ts(getPayload real emteqo_test, mock denext/cachecomospeechCut.int.spec.ts: validações/mensagens, defaultpublished: 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(prefixossrc/app/(frontend)/[type],src/lib/shareLink.ts,src/utilities/shareLinkReads.ts,src/collections/ShareLink.ts,src/components/ShareLinkRedirect.tsx→ specfrontendShareLink) +frontendShareLinkemE2E_CURATED_SPECScom o pin atualizado emtests/unit/e2eAffectedManifest.unit.spec.ts(a migration torna todo PR high-risk; precedente C167). - Migration:
add_share_linkviapnpm migrate:create add_share_link(tabela + FK deimage+ índice único deslug; aditiva, sem backfill); commitar.ts+.json+index.ts;src/payload-types.tsregenerado. - 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.descriptionpt-BR.
Fases verificáveis
- Puros + schema + migration (tracer ~0,3 dia).
src/lib/shareLink.ts+tests/unit/shareLink.unit.spec.ts;src/collections/ShareLink.ts; registro nopayload.config.ts;pnpm generate:types;pnpm migrate:create add_share_link, revisar SQL epnpm migratesó no banco local do worktree (produção aplica no deploy viapnpm build;push:falseintocado);pnpm gate:fast. Verificação: unit verde,payload-types.tscomshareLink,migrate:statusaplicado. - Leitura + revalidação (~0,2 dia).
documents.ts;src/utilities/shareLinkReads.ts; hooks na collection;tests/int/shareLink.int.spec.tscom mock denext/cache. Verificação: loader devolve só publicado; despublicar zera; validações com as mensagens pt-BR; access anônimo × editor. - Rota + componente (~0,2 dia).
ShareLinkRedirect.tsx+ ramo no[type]/page.tsx(PageegenerateMetadata). Verificação manual nopnpm devdo worktree: link criado →curlmostraog:*,noindex,http-equiv="refresh",location.replace; despublicar → 404; republicar → 200. - Testes de superfície (~0,2 dia). e2e
frontendShareLink: 200 comog:title/og:description/og:imageabsoluto eog:urlcurto;og:descriptionsem 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 é doresolveShareLinkOgImageUrl(int, com mock do globalmetadata— o global é estado compartilhado entre int e e2e no mesmo banco, então o teste não o escreve); manifesto + curado + pin;pnpm test. - Gates + changelog + PR (~0,1 dia).
pnpm gate:fast; changelogdocs/changelog/2026-09-19-s19.md;pnpm pushe PR ready (CI de PR roda unit/int full + e2e curado por causa desrc/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 nonext.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 doPost). - Qualquer Consent novo,
Contactou escrita multi-collection (não existem no fluxo). - Defer com gatilho: a entrada S19 do manifesto (
src/utilities/documents.ts) acorda só ofrontendShareLink; quando esse arquivo mudar por listagens além de shareLink, ampliar a entrada paraspecs: ['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:imagerelativa (semsiteUrl/global): omitida em vez de quebrada;siteUrlcai noNEXT_PUBLIC_SITE_URLdo 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 (
publishedwhere) 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 viapnpm 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.
- 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.
- 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.
- Rabbit holes — 5/5: nomeados (analytics, expiração, QR, encurtador, preview do card,
imageSizes, middleware, histórico de slug). - Depth check — 5/5: reusa
slugify,canManagePublishedContent/hasPayloadPanelAccess, o vocabulário de tags dedocuments.ts,resolveSiteMetadata/absoluteSitePath/toAbsoluteUrl/truncate, o molde de OG/fallback do/corte/[id], o upload doMediae o grupoPublicações; nenhum wrapper raso. - 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.