mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
feat(S41): assina e ancora eventos de agenda do link (Closes #1335)
This commit is contained in:
parent
b4821f1b28
commit
01831446df
17 changed files with 486 additions and 45 deletions
1
docs/changelog/2026-09-25-s41.md
Normal file
1
docs/changelog/2026-09-25-s41.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**Agenda do link de anúncio (S41, Issue #1335):** os eventos gerados pelo Google Agenda e pelo `.ics` agora carregam a URL canônica do anúncio na descrição, acrescentam uma única assinatura ` - Jorge Solla 1313` e usam `America/Bahia` (UTC−3) nos dois caminhos; o `.ics` também emite `TZID`, `VTIMEZONE` e `URL`. A resolução da URL canônica é server-only e a serialização rejeita URLs não absolutas/controles para preservar o fail-closed. Página, menu e cadastro permanecem inalterados; testes unit, int e o e2e de share links cobrem o novo conteúdo e os guardrails existentes.
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
# Impl: S41 — Agenda do link de anúncio: evento leva o próprio link, assinatura "Jorge Solla 1313" e fuso da Bahia
|
||||
|
||||
Status: aprovado — autoaprovado pela invocação `--auto`
|
||||
Atualizado em: 2026-09-25
|
||||
Issue: #1335
|
||||
Intenção: `docs/plans/agenda-do-link-de-anuncio-com-link-e-assinatura.md`
|
||||
Priority: P2
|
||||
Impeccable: A — sem UI nova; somente o conteúdo gerado para Google Agenda e `.ics`
|
||||
Design UI: N/A — a página, o menu e o cadastro não mudam visualmente
|
||||
Appetite restante: herdado (~0,5 dia eng); sem migration, sem schema e sem novos owners de produto
|
||||
Responsável: —
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** os dois caminhos de agenda carregam a URL canônica do anúncio, acrescentam a assinatura ` - Jorge Solla 1313` uma única vez e apresentam o horário no fuso `America/Bahia`, sem alterar a página ou o cadastro.
|
||||
- **O que não negociar:** a descrição configurada vem antes de `Página do evento: <url>`; o `.ics` também tem `URL` de `VEVENT`; a ausência de URL canônica remove apenas o link; duração padrão de 2h, ausência de início, 404 de despublicado e fail-closed permanecem; sem Consent, PII, RSVP, convite, agenda pública ou API do Google.
|
||||
- **O que reavaliar:** a intenção escreve o domínio público de produção como literal, enquanto o código usa a cadeia global-first de SEO; os dois caminhos atualmente serializam UTC e não há precedente local para `TZID`/`VTIMEZONE`; a composição de título e descrição precisa ficar em um único owner.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
L[Link publicado] --> W{Início válido?}
|
||||
W -- não --> H[Menu ausente ou ICS 404]
|
||||
W -- sim --> C[Resolver URL canônica]
|
||||
C --> U[URL absoluta do anúncio]
|
||||
C --> N[Sem URL: canonical ausente]
|
||||
U --> P[Google: título, detalhes, horário local, ctz]
|
||||
U --> R[ICS: TZID, VTIMEZONE, URL, descrição]
|
||||
N --> P
|
||||
N --> R
|
||||
```
|
||||
|
||||
**Opções macro:** A) manter os dois caminhos em UTC e acrescentar apenas texto | B) serializar o mesmo instante no fuso Bahia, com `ctz` no Google e `TZID`/`VTIMEZONE` no ICS | C) criar um segundo modelo de evento para a agenda pública.
|
||||
|
||||
**Recomendação:** B, com as transformações de conteúdo centralizadas em `calendarEvent` e a URL canônica resolvida no servidor. É a menor mudança que satisfaz o aceite sem criar uma agenda paralela.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
#### D1 — Domínio canônico fixo versus metadata configurável
|
||||
|
||||
**Opções:** A) hardcode de `https://jorgesolla1313.com.br` em cada builder/rota | B) usar a cadeia canônica existente: `getCachedGlobal('metadata')` → `resolveSiteMetadata` → `absoluteSitePath(siteUrl, shareLinkPath(slug))` | C) usar `window.location.origin` ou o host da requisição.
|
||||
|
||||
**Recomendação:** B — em produção, o valor global deve resolver `https://jorgesolla1313.com.br`, preservando o contrato S19/S29. `NEXT_PUBLIC_SITE_URL` continua sendo o fallback de worktree, staging e ambiente sem global; a origem é configuração do site, não um novo campo por link. A política fica em `resolveShareLinkCanonicalUrl`, no reader server-only, e a página e a rota do `.ics` consomem o mesmo resultado.
|
||||
|
||||
O reader devolve `undefined` quando não há `siteUrl`; nenhum builder recebe `shareLinkPath(slug)` como se fosse uma URL. Também não usar `resolveDeploymentOrigin`, que é a origem de assets e pode ser diferente do domínio canônico.
|
||||
|
||||
**Alternativas rejeitadas:** A duplica a política em vários arquivos e quebraria staging/testes ao apontar para produção. C transforma a origem atual de navegação em canonical, podendo gerar URLs diferentes entre visitors, página e download.
|
||||
|
||||
#### D2 — Representação do fuso nos consumidores
|
||||
|
||||
**Opções:** A) manter `DTSTART`/`dates` em UTC e apenas adicionar uma dica de timezone | B) converter o instante para horário civil de `America/Bahia`, usar horário local sem `Z` no Google com `ctz=America/Bahia`, e usar `TZID=America/Bahia` mais um `VTIMEZONE` fixo no ICS | C) escrever offsets numéricos `-0300` em todo lugar.
|
||||
|
||||
**Recomendação:** B — `startsAt` e `endsAt` continuam instantes UTC; só a representação externa muda. O Google recebe `20261003T190000` e `ctz=America/Bahia`; o ICS recebe `DTSTART;TZID=America/Bahia:20261003T190000` e `DTEND;TZID=America/Bahia:20261003T210000`, com `VTIMEZONE` contendo um bloco `STANDARD` fixo em `-0300`, sem `DAYLIGHT`. `DTSTAMP` continua em UTC, pois representa o instante de gravação, não o horário do evento.
|
||||
|
||||
A conversão usa o owner de horário Bahia já existente em `campaignTime.ts`, sem nova dependência. O feed público da campanha e `googleCalendarEventMapping.ts` mantêm seus próprios contratos UTC/offset; não serão compartilhados nem alterados.
|
||||
|
||||
**Alternativas rejeitadas:** A preserva o instante, mas não informa ao leitor qual fuso deve ser exibido. C não é a forma padrão para `DTSTART`/`DTEND` de um VEVENT e não oferece a definição IANA que alguns clientes esperam.
|
||||
|
||||
#### D3 — Dono do título, descrição e URL do evento
|
||||
|
||||
**Opções:** A) cada caller monta título e descrição antes de chamar o builder | B) `calendarEvent.ts` recebe título/descrição crus e uma `url` opcional, aplicando as transformações uma vez | C) criar um serviço paralelo de conteúdo de calendário.
|
||||
|
||||
**Recomendação:** B — `CalendarEventInput` ganha `url?: string | null`, mas não conhece origem, environment ou slug. O builder:
|
||||
|
||||
- acrescenta ` - Jorge Solla 1313` ao final apenas quando esse sufixo exato ainda não está no fim;
|
||||
- compõe a descrição como descrição configurada, linha em branco, `Página do evento: <url>` quando houver URL;
|
||||
- mantém a descrição original, sem linha extra, quando a URL não for resolvida;
|
||||
- deixa o título e a descrição da página intocados.
|
||||
|
||||
Assim, o menu e a rota passam o mesmo dado bruto e recebem a mesma saída. O nome `Jorge Solla 1313` é copy do produto; os identificadores permanecem em inglês.
|
||||
|
||||
**Alternativas rejeitadas:** A permite divergência entre Google e ICS e duplica a regra anti-sufixo. C cria um segundo owner para um domínio que já tem um builder puro.
|
||||
|
||||
#### D4 — Campo de URL do VEVENT
|
||||
|
||||
**Opções:** A) URL somente na descrição | B) URL na descrição e em `URL:<url>` dentro do `VEVENT` | C) colocar a URL no campo de local do Google ou forçar um campo de URL equivalente.
|
||||
|
||||
**Recomendação:** B — o `.ics` usa a propriedade padrão `URL`; o Google recebe a URL apenas na descrição, porque o template não tem um campo de URL de evento equivalente. A URL do `VEVENT` e a linha `Página do evento` devem ser o mesmo valor absoluto. Sem canonical resolvido, as duas posições devem ser omitidas.
|
||||
|
||||
**Alternativas rejeitadas:** A deixa de cobrir o requisito explícito do ICS. C mistura papel de local e destination, altera a semântica do evento e não resolve o Google.
|
||||
|
||||
#### D5 — Testes
|
||||
|
||||
**Opções:** A) unit para a serialização, int para a resolução server-side e e2e para a página/rota | B) somente e2e | C) somente unit.
|
||||
|
||||
**Recomendação:** A — a serialização é pura e deve ser rápida; o reader server-only e o contrato de publicado/404 ficam no int; o menu real e o download precisam do fluxo e2e. Não criar teste de browser para o Google Calendar externo, apenas inspecionar o `href` gerado.
|
||||
|
||||
**Alternativas rejeitadas:** B deixa a composição de URL e timezone sem feedback rápido. C não prova a integração entre metadata, página client e rota `.ics`.
|
||||
|
||||
## Componentes / mudanças
|
||||
|
||||
- **`resolveShareLinkCanonicalUrl`** (`src/utilities/shareLinkReads.ts`): helper server-only que reutiliza `getCachedGlobal('metadata')`, `resolveSiteMetadata`, `absoluteSitePath` e `shareLinkPath`; devolve a URL canônica ou `undefined`. Não altera o reader de publicados nem o filtro `published: true`.
|
||||
- **`src/app/(frontend)/[type]/page.tsx`**: usar o helper na resolução do canonical de metadata e no ramo de anúncio; passar a URL para o view. Manter `resolveShareLinkMetadata`, `noindex`, redirect de link direto, poll de live e branch de destino sem alteração.
|
||||
- **`src/lib/shareLinkAnnouncement.ts`**: adicionar `canonicalUrl: string | null` ao view e ao builder do view. A função continua pura e client-safe; não importa metadata, environment ou `window`.
|
||||
- **`src/components/shareLink/ShareLinkAnnouncement.tsx`**: encaminhar `view.canonicalUrl` para `ShareLinkAgendaMenu`. Não alterar markup, rótulos, layout, heading ou CTA.
|
||||
- **`src/components/shareLink/ShareLinkAgendaMenu.tsx`**: receber a URL canônica como prop e passá-la ao `buildGoogleCalendarEventUrl`. O menu continua condicionado a `startsAt`; uma URL ausente não pode esconder o menu. O href do download continua `shareLinkIcsPath(slug)`, que é um path de navegação e não a URL descrita no VEVENT.
|
||||
- **`src/lib/calendarEvent.ts`**:
|
||||
- estender `CalendarEventInput` com `url` opcional;
|
||||
- centralizar sufixo, composição da descrição e validação de URL absoluta;
|
||||
- formatar os instantes como horário civil de `America/Bahia` para os campos de evento;
|
||||
- emitir `ctz=America/Bahia` no Google, sem `Z` em `dates`;
|
||||
- emitir `VTIMEZONE`, `TZID` e campos locais no ICS;
|
||||
- manter `METHOD`, `PRODID`, `UID`, `DTSTAMP`, `LOCATION`, `resolveCalendarEventWindow`, o fallback de 2h e o retorno `null` sem início válido;
|
||||
- continuar usando `escapeICalText` e `foldICalLine` em todas as linhas lógicas do ICS.
|
||||
- **`src/lib/campaignTime.ts`**: adicionar um export puro para data/hora local IANA com segundos, reaproveitando `getZonedParts`; não alterar `formatIsoAsBahiaDateTimeInput`, `formatBahiaEventDateLabel` nem os contratos usados pelo admin.
|
||||
- **`src/lib/shareLink.ts`, `src/lib/ical.ts` e `src/utilities/seo.ts`**: reutilizar `shareLinkPath`, os primitivos iCal e o resolver de SEO; não criar helpers gêmeos.
|
||||
- **`src/utilities/calendarFeed.ts` e `src/lib/googleCalendarEventMapping.ts`**: nenhum toque. O feed de campanha continua em UTC e o mapeamento da API Google continua com seu offset próprio.
|
||||
- **Migration:** nenhuma. Não alterar `ShareLink`, Payload types, hooks, access ou banco; não executar `migrate:create`, `migrate` ou `generate:types`.
|
||||
- **Access / Consent:** nenhuma mudança. Não há escrita, PII, pessoa ou nova chave de consentimento; o reader público continua fail-closed para rascunho.
|
||||
- **UI:** Impeccable A. Nenhum componente, shell, token, CSS, designer ou screenshot novo; a mudança é limitada ao href gerado e ao corpo do `.ics`.
|
||||
- **Changelog:** uma entrada curta em `docs/changelog/2026-09-25-s41.md`, sem editar o agregado.
|
||||
|
||||
## Dados → forma
|
||||
|
||||
- **data-presentation:** não aplicável. Não há métrica, gráfico, série, ranking ou painel; o dado alterado é o payload de um evento de calendário, não uma visualização de dados.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer de domínio puro (~40% do appetite)**
|
||||
- Atualizar `calendarEvent.ts` e o export de data/hora Bahia.
|
||||
- Fechar a matriz de título já assinado, título simples, descrição vazia, URL presente/ausente, URL relativa/inválida, escape, newline, URL longa, início ausente, fim inválido e duração de 2h.
|
||||
- O exemplo de boundary deve confirmar que `2026-10-03T22:00:00.000Z` vira `19:00` da Bahia no Google e no ICS, enquanto `DTSTAMP` permanece UTC.
|
||||
- Rodar:
|
||||
- `pnpm test:unit -- tests/unit/calendarEvent.unit.spec.ts tests/unit/ical.unit.spec.ts tests/unit/shareLinkAnnouncement.unit.spec.ts`
|
||||
- Critério: os dois builders produzem o mesmo título/descrição/fuso e o ICS continua com CRLF, escape e folding válidos.
|
||||
|
||||
2. **Wiring server/client (~35%)**
|
||||
- Adicionar o reader de canonical e alimentar `canonicalUrl` no view, menu e rota.
|
||||
- Preservar a página sem canonical, o menu com início válido, a ausência de menu sem início, o 404 de despublicado/desconhecido e o comportamento de link direto/live.
|
||||
- Cobrir no int a precedência `metadata.URL` sobre `NEXT_PUBLIC_SITE_URL`, o caminho `/<slug>` e o retorno ausente sem URL.
|
||||
- Rodar:
|
||||
- `pnpm test:int -- tests/int/shareLink.int.spec.ts`
|
||||
- `pnpm test:unit -- tests/unit/calendarEvent.unit.spec.ts tests/unit/ical.unit.spec.ts tests/unit/shareLinkAnnouncement.unit.spec.ts`
|
||||
- Critério: a rota e a página usam a mesma origem canônica; nenhum link gerado usa destino, path relativo ou `window.origin`.
|
||||
|
||||
3. **Integração e fechamento (~25%)**
|
||||
- Atualizar `tests/e2e/frontendShareLink.e2e.spec.ts` para abrir o menu no browser e verificar o `href` do Google sem abrir o serviço externo.
|
||||
- No mesmo spec, baixar o `.ics` e verificar, após unfold, `SUMMARY`, `DESCRIPTION`, `URL`, `VTIMEZONE`, `TZID`, `DTSTART` e `DTEND` locais; confirmar que o `<h1>` continua sem o sufixo e que os textos visíveis não mudaram.
|
||||
- Manter os casos sem data, slug desconhecido, despublicado e live redirect intactos.
|
||||
- Rodar:
|
||||
- `pnpm test:e2e --no-deps -- tests/e2e/frontendShareLink.e2e.spec.ts`
|
||||
- `pnpm test:e2e:affected`
|
||||
- `pnpm gate:fast`
|
||||
- `pnpm push`
|
||||
- `pnpm push` fecha a entrega e executa o gate de CI; não usar `git push` diretamente.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Novo campo de URL no `ShareLink`:** cortar. A URL vem do slug e do canonical já existente.
|
||||
- **Hardcode de `NEXT_PUBLIC_EVENT_URL` ou de novo env:** cortar. A origem segue o contrato de metadata/SEO.
|
||||
- **Usar `window.location.origin`, host da requisição ou `resolveDeploymentOrigin`:** cortar. Nenhum deles representa o canonical público.
|
||||
- **Colocar a URL em `LOCATION` do Google:** cortar. O local continua sendo o local configurado.
|
||||
- **Adicionar `URL` ao Google template:** cortar. Google recebe a URL na descrição; `.ics` recebe a propriedade própria.
|
||||
- **Criar um segundo calendário, feed público ou registry de eventos:** cortar. A agenda pública é PUB3 e o motor/sync de campanha tem owner separado.
|
||||
- **Alterar `src/utilities/calendarFeed.ts` ou `src/lib/googleCalendarEventMapping.ts`:** cortar. O `VTIMEZONE` novo pertence apenas ao evento do link de anúncio.
|
||||
- **Adicionar recorrência, convidados, múltiplas sessões, dia inteiro, mapa/endereço, RSVP, lembrete, notificação, analytics, UTM ou encurtador:** fora da intenção.
|
||||
- **Mudar o heading, os rótulos, a posição do menu ou o cadastro:** fora do escopo. A assinatura aparece somente no evento exportado.
|
||||
- **Truncar título ou descrição para respeitar limite de um calendário consumidor:** fora. O cadastro tem seus limites; o evento deve preservar o texto e o link.
|
||||
- **Criar parser de ICS, biblioteca de timezone ou importador de calendário:** YAGNI; esta entrega só serializa um VEVENT.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Origem canônica divergir entre página e `.ics`:** centralizar a política em `resolveShareLinkCanonicalUrl`, usar `shareLinkPath` e comparar no e2e a URL da descrição com `URL:` do ICS.
|
||||
- **Fallback para URL relativa quando o metadata está ausente:** o reader devolve `undefined`; builders mantêm título, descrição e horário, mas não inventam origem. O caso é coberto por unit e int.
|
||||
- **Misturar horário local com `Z`:** Google e ICS recebem valores locais sem `Z`; `DTSTAMP` é o único campo de evento que continua com `Z`. Os testes verificam a consulta e o corpo após unfold.
|
||||
- **Cliente não reconhecer `TZID`:** o `.ics` inclui `VTIMEZONE` completo, `TZID` e offsets fixos de `-0300`; não confiar apenas em um `X-WR-TIMEZONE` não padronizado.
|
||||
- **Sufixo duplicado:** a transformação é idempotente para um título que já termina no literal; não procura a assinatura no meio do título nem reescreve o texto configurado.
|
||||
- **Linha de descrição com espaço ou quebra inesperado:** compor a descrição antes de escapar, usar uma linha em branco entre as partes e testar descrição vazia, multilinha e URL ausente.
|
||||
- **URL ou descrição longa quebrar o ICS:** continuar passando todas as linhas por `foldICalLine`; o teste de `ical.unit.spec.ts` confirma limite de 75 octets e unfold sem alteração.
|
||||
- **Mudança involuntária no feed de campanha:** não editar seus owners; rodar os testes de `calendarFeed` como regressão, sem esperar mudança no formato atual.
|
||||
- **Regressão de página, redirect ou kill switch:** o e2e existente de `frontendShareLink` continua verificando noindex, redirect pós-live, 404 e republicação.
|
||||
- **Metadata ausente no build:** `resolveSiteMetadata` e `absoluteSitePath` mantêm a degradação existente; a ausência de URL é um estado válido do evento, não motivo para 404.
|
||||
- **Export novo não usado:** `resolveShareLinkCanonicalUrl` terá consumidores na página e na rota; o formatter Bahia terá consumidores no builder e teste. `knip` e `check:cycles` entram no gate.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Google Agenda e `.ics` usam a URL canônica do anúncio, nunca o destino, o `.ics` ou uma origem relativa.
|
||||
- [ ] O título exportado termina uma única vez com ` - Jorge Solla 1313`, sem alterar o título da página.
|
||||
- [ ] A descrição configurada vem antes de uma linha em branco e de `Página do evento: <url>`.
|
||||
- [ ] O `.ics` contém `URL` de `VEVENT`; o Google não recebe um campo de URL paralelo.
|
||||
- [ ] Google usa horário local da Bahia com `ctz=America/Bahia`; o ICS usa `TZID` e `VTIMEZONE` fixo `America/Bahia`/UTC-3, sem DST.
|
||||
- [ ] `DTSTAMP` e o restante do contrato iCal continuam válidos; escape, CRLF e folding são preservados.
|
||||
- [ ] Sem canonical resolvido, o evento continua válido com título e fuso, mas sem linha de link e sem `URL` no ICS.
|
||||
- [ ] Duração padrão de 2h, início obrigatório para o menu e 404 para link despublicado/desconhecido continuam iguais.
|
||||
- [ ] Página, menu visível, cadastro e dados do `ShareLink` não ganham campo, seção, rótulo ou fluxo novo.
|
||||
- [ ] `calendarFeed.ts` e `googleCalendarEventMapping.ts` permanecem intocados.
|
||||
- [ ] Unit, int e e2e afetados cobrem a matriz acima; `pnpm gate:fast` e `pnpm push` ficam verdes.
|
||||
- [ ] Não há migration, alteração de schema, `Consent` novo, escrita multi-collection ou acesso a DB de produção.
|
||||
|
||||
## Self-score (decision-quality)
|
||||
|
||||
1. **Decisões caras com rejeitadas:** 5/5 — canonical, fuso, `VTIMEZONE`, URL do VEVENT, owner das transformações e camadas de teste têm opções explícitas.
|
||||
2. **Cabe no appetite:** 5/5 — helpers puros, duas frontiers server/client e atualização de specs; sem schema, admin ou UI nova.
|
||||
3. **Rabbit holes nomeados:** 5/5 — domínio paralelo, feed público, sync da campanha, parser, recurrence, RSVP e integração externa estão fora.
|
||||
4. **Depth check:** 5/5 — reusa `shareLinkPath`, o reader server-only, o resolver de SEO, `campaignTime`, `ical` e os consumers existentes; não cria uma agenda gêmea.
|
||||
5. **Intenção preservada:** 5/5 — todos os bullets do aceite têm arquivo, formato de saída e verificação correspondentes.
|
||||
|
||||
**Média: 5,0/5 — gate de decisão aprovado para execução `--auto`.**
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { buildCalendarEventIcs } from '@/lib/calendarEvent'
|
||||
import { shareLinkIcsUid } from '@/lib/shareLink'
|
||||
import { getCachedPublishedShareLinkBySlug } from '@/utilities/shareLinkReads'
|
||||
import {
|
||||
getCachedPublishedShareLinkBySlug,
|
||||
resolveShareLinkCanonicalUrl,
|
||||
} from '@/utilities/shareLinkReads'
|
||||
|
||||
type RouteParams = { type: string }
|
||||
|
||||
|
|
@ -16,11 +19,13 @@ export async function GET(_request: Request, { params }: { params: Promise<Route
|
|||
const link = await getCachedPublishedShareLinkBySlug(slug, 0)()
|
||||
if (!link) return new Response(null, { status: 404 })
|
||||
|
||||
const canonicalUrl = await resolveShareLinkCanonicalUrl(slug)
|
||||
const body = buildCalendarEventIcs({
|
||||
uid: shareLinkIcsUid(slug),
|
||||
title: link.title,
|
||||
description: link.description,
|
||||
location: link.location,
|
||||
url: canonicalUrl,
|
||||
startsAt: link.startsAt,
|
||||
endsAt: link.endsAt,
|
||||
updatedAt: link.updatedAt,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
normalizeShareLinkDescription,
|
||||
resolveLiveShareLinkDestination,
|
||||
resolveShareLinkMode,
|
||||
shareLinkPath,
|
||||
type ShareLinkLiveTarget,
|
||||
} from '@/lib/shareLink'
|
||||
import { buildShareLinkAnnouncementView } from '@/lib/shareLinkAnnouncement'
|
||||
|
|
@ -19,6 +18,7 @@ import { POST_TYPE_LABELS, getVisiblePosts, isPostType } from '@/utilities/posts
|
|||
import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
|
||||
import {
|
||||
getCachedPublishedShareLinkBySlug,
|
||||
resolveShareLinkCanonicalUrl,
|
||||
resolveShareLinkOgImageUrl,
|
||||
} from '@/utilities/shareLinkReads'
|
||||
import type { Metadata } from 'next'
|
||||
|
|
@ -60,8 +60,8 @@ const loadPublishedShareLink = async (slug: string): Promise<ResolvedShareLink |
|
|||
|
||||
const resolveShareLinkMetadata = async (link: ShareLink): Promise<Metadata> => {
|
||||
const globalMetadata = await getCachedGlobal('metadata')()
|
||||
const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata)
|
||||
const canonicalUrl = absoluteSitePath(siteUrl, shareLinkPath(link.slug))
|
||||
const { siteName, twitterCreator } = resolveSiteMetadata(globalMetadata)
|
||||
const canonicalUrl = await resolveShareLinkCanonicalUrl(link.slug)
|
||||
const description = normalizeShareLinkDescription(link.description)
|
||||
const imageUrl = await resolveShareLinkOgImageUrl(link)
|
||||
const images = imageUrl ? [{ url: imageUrl, alt: link.title }] : []
|
||||
|
|
@ -139,8 +139,13 @@ export default async function Page({ params }: { params: Promise<RouteParams> })
|
|||
// The rendered page uses the media proxy path (same origin); the absolute
|
||||
// deployment-origin URL is only for the OG card in `resolveShareLinkMetadata`.
|
||||
const image = typeof link.image === 'object' ? link.image : null
|
||||
const canonicalUrl = await resolveShareLinkCanonicalUrl(link.slug)
|
||||
const showJingles = await hasPublishedJingles()
|
||||
const view = buildShareLinkAnnouncementView({ link, imageUrl: image?.url ?? null })
|
||||
const view = buildShareLinkAnnouncementView({
|
||||
link,
|
||||
imageUrl: image?.url ?? null,
|
||||
canonicalUrl: canonicalUrl ?? null,
|
||||
})
|
||||
|
||||
return <ShareLinkAnnouncement view={view} showJingles={showJingles} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ type ShareLinkAgendaMenuProps = {
|
|||
title: string
|
||||
description: string
|
||||
location: string | null
|
||||
canonicalUrl: string | null
|
||||
startsAt: string | null
|
||||
endsAt: string | null
|
||||
}
|
||||
|
|
@ -27,13 +28,21 @@ export const ShareLinkAgendaMenu = ({
|
|||
title,
|
||||
description,
|
||||
location,
|
||||
canonicalUrl,
|
||||
startsAt,
|
||||
endsAt,
|
||||
}: ShareLinkAgendaMenuProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const googleUrl = startsAt
|
||||
? buildGoogleCalendarEventUrl({ title, description, location, startsAt, endsAt })
|
||||
? buildGoogleCalendarEventUrl({
|
||||
title,
|
||||
description,
|
||||
location,
|
||||
url: canonicalUrl,
|
||||
startsAt,
|
||||
endsAt,
|
||||
})
|
||||
: null
|
||||
if (!googleUrl) return null
|
||||
|
||||
|
|
|
|||
|
|
@ -221,6 +221,7 @@ export const ShareLinkAnnouncement = ({
|
|||
title={view.title}
|
||||
description={view.description}
|
||||
location={view.location}
|
||||
canonicalUrl={view.canonicalUrl}
|
||||
startsAt={view.startsAt}
|
||||
endsAt={view.endsAt}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { BAHIA_TIME_ZONE, formatBahiaCalendarDateTime } from '@/lib/campaignTime'
|
||||
import { escapeICalText, foldICalLine, formatICalDate } from '@/lib/ical'
|
||||
import { normalizeAbsoluteHttpUrl } from '@/lib/shareLink'
|
||||
|
||||
/**
|
||||
* S29 — pure builders for the calendar options of an announcement link
|
||||
|
|
@ -7,16 +9,38 @@ import { escapeICalText, foldICalLine, formatICalDate } from '@/lib/ical'
|
|||
*/
|
||||
|
||||
export const DEFAULT_EVENT_DURATION_MS = 2 * 60 * 60 * 1000
|
||||
const EVENT_TITLE_SUFFIX = ' - Jorge Solla 1313'
|
||||
|
||||
export type CalendarEventInput = {
|
||||
title: string
|
||||
description?: string | null
|
||||
location?: string | null
|
||||
url?: string | null
|
||||
startsAt?: string | null
|
||||
endsAt?: string | null
|
||||
}
|
||||
|
||||
const formatInstant = (date: Date): string => formatICalDate(date.toISOString())
|
||||
const formatCalendarInstant = (date: Date): string => formatBahiaCalendarDateTime(date)
|
||||
|
||||
const resolveEventUrl = (url: string | null | undefined): string | null =>
|
||||
normalizeAbsoluteHttpUrl(url)
|
||||
|
||||
const resolveCalendarEventContent = (event: CalendarEventInput) => {
|
||||
const title = event.title.trim()
|
||||
const url = resolveEventUrl(event.url)
|
||||
const descriptionParts: string[] = []
|
||||
const configuredDescription = event.description
|
||||
|
||||
if (configuredDescription?.trim()) descriptionParts.push(configuredDescription)
|
||||
if (url) descriptionParts.push(`Página do evento: ${url}`)
|
||||
|
||||
return {
|
||||
title: title.endsWith(EVENT_TITLE_SUFFIX) ? title : `${title}${EVENT_TITLE_SUFFIX}`,
|
||||
description: descriptionParts.length > 0 ? descriptionParts.join('\n\n') : null,
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The event window: no `startsAt` (or an invalid one) means "no event" — the
|
||||
|
|
@ -46,12 +70,14 @@ export const buildGoogleCalendarEventUrl = (event: CalendarEventInput): string |
|
|||
const window = resolveCalendarEventWindow(event.startsAt, event.endsAt)
|
||||
if (!window) return null
|
||||
|
||||
const content = resolveCalendarEventContent(event)
|
||||
const params = new URLSearchParams({
|
||||
action: 'TEMPLATE',
|
||||
text: event.title,
|
||||
dates: `${formatInstant(window.start)}/${formatInstant(window.end)}`,
|
||||
text: content.title,
|
||||
dates: `${formatCalendarInstant(window.start)}/${formatCalendarInstant(window.end)}`,
|
||||
ctz: BAHIA_TIME_ZONE,
|
||||
})
|
||||
if (event.description) params.set('details', event.description)
|
||||
if (content.description) params.set('details', content.description)
|
||||
if (event.location) params.set('location', event.location)
|
||||
|
||||
return `https://calendar.google.com/calendar/render?${params.toString()}`
|
||||
|
|
@ -69,6 +95,7 @@ export const buildCalendarEventIcs = ({
|
|||
const window = resolveCalendarEventWindow(event.startsAt, event.endsAt)
|
||||
if (!window) return null
|
||||
|
||||
const content = resolveCalendarEventContent(event)
|
||||
const updated = updatedAt ? new Date(updatedAt) : null
|
||||
const dtstamp = !updated || Number.isNaN(updated.getTime()) ? window.start : updated
|
||||
|
||||
|
|
@ -78,15 +105,26 @@ export const buildCalendarEventIcs = ({
|
|||
'PRODID:-//Teqo//Link de compartilhamento//PT',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
'BEGIN:VTIMEZONE',
|
||||
`TZID:${BAHIA_TIME_ZONE}`,
|
||||
`X-LIC-LOCATION:${BAHIA_TIME_ZONE}`,
|
||||
'BEGIN:STANDARD',
|
||||
'DTSTART:19700101T000000',
|
||||
'TZOFFSETFROM:-0300',
|
||||
'TZOFFSETTO:-0300',
|
||||
'TZNAME:-03',
|
||||
'END:STANDARD',
|
||||
'END:VTIMEZONE',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${escapeICalText(uid)}`,
|
||||
`DTSTAMP:${formatInstant(dtstamp)}`,
|
||||
`DTSTART:${formatInstant(window.start)}`,
|
||||
`DTEND:${formatInstant(window.end)}`,
|
||||
`SUMMARY:${escapeICalText(event.title)}`,
|
||||
`DTSTART;TZID=${BAHIA_TIME_ZONE}:${formatCalendarInstant(window.start)}`,
|
||||
`DTEND;TZID=${BAHIA_TIME_ZONE}:${formatCalendarInstant(window.end)}`,
|
||||
`SUMMARY:${escapeICalText(content.title)}`,
|
||||
]
|
||||
|
||||
if (event.description) lines.push(`DESCRIPTION:${escapeICalText(event.description)}`)
|
||||
if (content.description) lines.push(`DESCRIPTION:${escapeICalText(content.description)}`)
|
||||
if (content.url) lines.push(`URL:${content.url}`)
|
||||
if (event.location) lines.push(`LOCATION:${escapeICalText(event.location)}`)
|
||||
|
||||
lines.push('END:VEVENT', 'END:VCALENDAR')
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const BAHIA_TIME_ZONE = 'America/Bahia'
|
||||
export const BAHIA_TIME_ZONE = 'America/Bahia'
|
||||
|
||||
type CivilDateTime = {
|
||||
year: number
|
||||
|
|
@ -102,6 +102,12 @@ export const formatIsoAsBahiaDateTimeInput = (iso: string): string => {
|
|||
return `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}`
|
||||
}
|
||||
|
||||
export const formatBahiaCalendarDateTime = (date: Date): string => {
|
||||
const { year, month, day, hour, minute, second } = getZonedParts(date)
|
||||
|
||||
return `${year}${pad(month)}${pad(day)}T${pad(hour)}${pad(minute)}${pad(second)}`
|
||||
}
|
||||
|
||||
const bahiaDateTimeDisplayFormatter = new Intl.DateTimeFormat('pt-BR', {
|
||||
timeZone: BAHIA_TIME_ZONE,
|
||||
day: '2-digit',
|
||||
|
|
|
|||
|
|
@ -104,18 +104,33 @@ export const isValidShareLinkSlug = (value: string): boolean => SHARE_LINK_SLUG_
|
|||
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
|
||||
export const normalizeAbsoluteHttpUrl = (value: string | null | undefined): string | null => {
|
||||
if (typeof value !== 'string') return null
|
||||
if (
|
||||
[...value].some((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return code < 32 || code === 127
|
||||
})
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed || !/^https?:\/\//i.test(trimmed)) return null
|
||||
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
const url = new URL(trimmed)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
||||
return url.href
|
||||
} catch {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Only `http`/`https` absolute URLs are accepted; anything else fails closed. */
|
||||
export const isValidShareLinkDestination = (value: string): boolean =>
|
||||
normalizeAbsoluteHttpUrl(value) !== null
|
||||
|
||||
/** Collapse whitespace so the value is safe inside a single-line meta tag. */
|
||||
export const normalizeShareLinkDescription = (value: string): string =>
|
||||
value.replace(/\s+/g, ' ').trim()
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export type ShareLinkAnnouncementView = {
|
|||
location: string | null
|
||||
startsAt: string | null
|
||||
endsAt: string | null
|
||||
canonicalUrl: string | null
|
||||
}
|
||||
|
||||
const isMedia = (
|
||||
|
|
@ -40,9 +41,11 @@ const isMedia = (
|
|||
export const buildShareLinkAnnouncementView = ({
|
||||
link,
|
||||
imageUrl,
|
||||
canonicalUrl,
|
||||
}: {
|
||||
link: ShareLinkAnnouncementSource
|
||||
imageUrl: string | null
|
||||
canonicalUrl: string | null
|
||||
}): ShareLinkAnnouncementView => {
|
||||
const mediaAlt = isMedia(link.image) ? link.image.alt?.trim() : null
|
||||
const location = link.location?.trim()
|
||||
|
|
@ -57,5 +60,6 @@ export const buildShareLinkAnnouncementView = ({
|
|||
location: location || null,
|
||||
startsAt: link.startsAt ?? null,
|
||||
endsAt: link.endsAt ?? null,
|
||||
canonicalUrl: canonicalUrl ?? null,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
import 'server-only'
|
||||
|
||||
import { resolveLiveShareLinkDestination, type ShareLinkLiveTarget } from '@/lib/shareLink'
|
||||
import {
|
||||
normalizeAbsoluteHttpUrl,
|
||||
resolveLiveShareLinkDestination,
|
||||
shareLinkPath,
|
||||
type ShareLinkLiveTarget,
|
||||
} from '@/lib/shareLink'
|
||||
import { getCollectionListingTag } from '@/utilities/documents'
|
||||
import { getCachedGlobal } from '@/utilities/globalReads'
|
||||
import { resolveOgImage } from '@/utilities/ogImageReads'
|
||||
import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
|
||||
import configPromise from '@payload-config'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
import { getPayload } from 'payload'
|
||||
|
|
@ -43,6 +50,12 @@ export const getCachedPublishedShareLinkBySlug = (slug: string, depth = 1) =>
|
|||
},
|
||||
)
|
||||
|
||||
export const resolveShareLinkCanonicalUrl = async (slug: string): Promise<string | undefined> => {
|
||||
const globalMetadata = await getCachedGlobal('metadata')()
|
||||
const { siteUrl } = resolveSiteMetadata(globalMetadata)
|
||||
return normalizeAbsoluteHttpUrl(absoluteSitePath(siteUrl, shareLinkPath(slug))) ?? undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* S29 — the fresh read behind the announcement page's activation poll. It must
|
||||
* NOT be cached: the whole point is seeing the "no ar" flag flip without a
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import { request as playwrightRequest } from '@playwright/test'
|
|||
import { Client } from 'pg'
|
||||
|
||||
import {
|
||||
formatBahiaCalendarDateTime,
|
||||
formatBahiaCivilDate,
|
||||
formatBahiaEventDateLabel,
|
||||
parseBahiaDateTimeInput,
|
||||
} from '../../src/lib/campaignTime.js'
|
||||
import { formatICalDate } from '../../src/lib/ical.js'
|
||||
import { adminHeaders } from '../helpers/adminApi'
|
||||
import { metaContent } from '../helpers/metaContent'
|
||||
import { seedTestUser } from '../helpers/seedUser'
|
||||
|
|
@ -45,6 +45,9 @@ const ANNOUNCEMENT_STARTS_AT = (() => {
|
|||
return startsAt
|
||||
})()
|
||||
|
||||
const TWO_HOURS_MS = 2 * 60 * 60 * 1000
|
||||
const unfold = (value: string): string => value.replace(/\r\n /g, '')
|
||||
|
||||
// 1×1 opaque PNG — enough for the upload (no dimensions are enforced on purpose).
|
||||
const TEST_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
|
|
@ -313,7 +316,8 @@ test.describe('Frontend share-link announcement (S29)', () => {
|
|||
return { id, slug }
|
||||
}
|
||||
|
||||
test('serves the announcement page (disabled Entrar, Bahia date, .ics) pre-broadcast', async ({
|
||||
test('serves the announcement page with the signed, Bahia-time calendar event', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const headers = await adminHeaders(request, BASE_URL)
|
||||
|
|
@ -324,27 +328,54 @@ test.describe('Frontend share-link announcement (S29)', () => {
|
|||
const response = await anonymous.get(`/${slug}`)
|
||||
expect(response.status()).toBe(200)
|
||||
const html = await response.text()
|
||||
const canonicalUrl = html.match(/<link rel="canonical" href="([^"]*)"/)?.[1] ?? ''
|
||||
|
||||
// The OG card is the same as S19, always noindex, and no interstice.
|
||||
expect(metaContent(html, 'property', 'og:title')).toBe('Plenária da saúde')
|
||||
expect(metaContent(html, 'name', 'robots')).toBe('noindex, nofollow')
|
||||
expect(metaContent(html, 'http-equiv', 'refresh')).toBeNull()
|
||||
expect(html).not.toContain('window.location.replace')
|
||||
|
||||
// Honest pre-broadcast state: visible but disabled, with the warning.
|
||||
expect(canonicalUrl).toMatch(new RegExp(`^https?://[^/]+/${slug}$`))
|
||||
expect(html).toContain('A transmissão ainda não começou.')
|
||||
expect(html).toMatch(/<button[^>]*disabled[^>]*>[\s\S]*?Entrar/)
|
||||
expect(html).toContain(formatBahiaEventDateLabel(ANNOUNCEMENT_STARTS_AT))
|
||||
expect(html).toContain('Horário da Bahia')
|
||||
expect(html).toContain('Online')
|
||||
|
||||
await page.goto(`/${slug}`)
|
||||
await expect(page.getByRole('heading', { name: 'Plenária da saúde' })).toHaveText(
|
||||
'Plenária da saúde',
|
||||
)
|
||||
await page.getByRole('button', { name: 'Adicionar à agenda' }).click()
|
||||
const googleHref = await page
|
||||
.getByRole('link', { name: /Google Agenda/ })
|
||||
.getAttribute('href')
|
||||
const googleParams = new URL(googleHref ?? '').searchParams
|
||||
const startsAt = new Date(ANNOUNCEMENT_STARTS_AT)
|
||||
const endsAt = new Date(startsAt.getTime() + TWO_HOURS_MS)
|
||||
const localStart = formatBahiaCalendarDateTime(startsAt)
|
||||
const localEnd = formatBahiaCalendarDateTime(endsAt)
|
||||
|
||||
expect(googleParams.get('text')).toBe('Plenária da saúde - Jorge Solla 1313')
|
||||
expect(googleParams.get('dates')).toBe(`${localStart}/${localEnd}`)
|
||||
expect(googleParams.get('ctz')).toBe('America/Bahia')
|
||||
expect(googleParams.get('details')).toBe(
|
||||
`Encontro online da campanha.\n\nPágina do evento: ${canonicalUrl}`,
|
||||
)
|
||||
|
||||
const ics = await anonymous.get(`/${slug}/evento.ics`)
|
||||
expect(ics.status()).toBe(200)
|
||||
expect(ics.headers()['content-type']).toContain('text/calendar')
|
||||
expect(ics.headers()['content-disposition']).toContain(`filename="${slug}.ics"`)
|
||||
const body = await ics.text()
|
||||
expect(body).toContain('BEGIN:VEVENT')
|
||||
expect(body).toContain(`DTSTART:${formatICalDate(ANNOUNCEMENT_STARTS_AT)}`)
|
||||
const body = unfold(await ics.text())
|
||||
expect(body).toContain('BEGIN:VTIMEZONE')
|
||||
expect(body).toContain('TZID:America/Bahia')
|
||||
expect(body).toContain(`DTSTART;TZID=America/Bahia:${localStart}`)
|
||||
expect(body).toContain(`DTEND;TZID=America/Bahia:${localEnd}`)
|
||||
expect(body).toContain('SUMMARY:Plenária da saúde - Jorge Solla 1313')
|
||||
expect(body).toContain(
|
||||
`DESCRIPTION:Encontro online da campanha.\\n\\nPágina do evento: ${canonicalUrl}`,
|
||||
)
|
||||
expect(body).toContain(`URL:${canonicalUrl}`)
|
||||
expect(body).toContain(`UID:${slug}@teqo.jorgesolla.com.br`)
|
||||
} finally {
|
||||
await anonymous.dispose()
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import config from '@/payload.config'
|
|||
import {
|
||||
getCachedPublishedShareLinkBySlug,
|
||||
loadPublishedShareLinkLiveTarget,
|
||||
resolveShareLinkCanonicalUrl,
|
||||
resolveShareLinkOgImageUrl,
|
||||
} from '@/utilities/shareLinkReads'
|
||||
|
||||
|
|
@ -147,6 +148,25 @@ describe('shareLink', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('resolves the canonical announcement URL from metadata before the environment fallback', async () => {
|
||||
getCachedGlobalMock.mockReturnValue(async () => ({ URL: 'https://canonical.example/' }))
|
||||
|
||||
expect(await resolveShareLinkCanonicalUrl('plenaria-saude')).toBe(
|
||||
'https://canonical.example/plenaria-saude',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the environment fallback and omits the canonical URL when neither source exists', async () => {
|
||||
getCachedGlobalMock.mockReturnValue(async () => ({ URL: null }))
|
||||
|
||||
expect(await resolveShareLinkCanonicalUrl('plenaria-saude')).toBe(
|
||||
'https://site.test/plenaria-saude',
|
||||
)
|
||||
|
||||
delete process.env.NEXT_PUBLIC_SITE_URL
|
||||
expect(await resolveShareLinkCanonicalUrl('plenaria-saude')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('creates a draft by default and busts the shareLinks tag', async () => {
|
||||
const doc = await createShareLink({
|
||||
title: 'Plenária da saúde',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
|
||||
const STARTS_AT = '2026-10-03T22:00:00.000Z'
|
||||
const ENDS_AT = '2026-10-04T00:00:00.000Z'
|
||||
const TWO_HOURS_MS = 2 * 60 * 60 * 1000
|
||||
|
||||
describe('resolveCalendarEventWindow', () => {
|
||||
it('returns null without a start, so the calendar options hide', () => {
|
||||
|
|
@ -18,20 +19,21 @@ describe('resolveCalendarEventWindow', () => {
|
|||
})
|
||||
|
||||
it('falls back to the 2-hour default without an end', () => {
|
||||
expect(DEFAULT_EVENT_DURATION_MS).toBe(TWO_HOURS_MS)
|
||||
const window = resolveCalendarEventWindow(STARTS_AT, null)
|
||||
expect(window?.end.getTime()).toBe(new Date(STARTS_AT).getTime() + DEFAULT_EVENT_DURATION_MS)
|
||||
expect(window?.end.getTime()).toBe(new Date(STARTS_AT).getTime() + TWO_HOURS_MS)
|
||||
})
|
||||
|
||||
it('falls back when the end is invalid or does not move forward', () => {
|
||||
const startMs = new Date(STARTS_AT).getTime()
|
||||
expect(resolveCalendarEventWindow(STARTS_AT, 'lixo')?.end.getTime()).toBe(
|
||||
startMs + DEFAULT_EVENT_DURATION_MS,
|
||||
startMs + TWO_HOURS_MS,
|
||||
)
|
||||
expect(resolveCalendarEventWindow(STARTS_AT, STARTS_AT)?.end.getTime()).toBe(
|
||||
startMs + DEFAULT_EVENT_DURATION_MS,
|
||||
startMs + TWO_HOURS_MS,
|
||||
)
|
||||
expect(resolveCalendarEventWindow(STARTS_AT, '2026-10-03T21:00:00.000Z')?.end.getTime()).toBe(
|
||||
startMs + DEFAULT_EVENT_DURATION_MS,
|
||||
startMs + TWO_HOURS_MS,
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -41,11 +43,12 @@ describe('resolveCalendarEventWindow', () => {
|
|||
})
|
||||
|
||||
describe('buildGoogleCalendarEventUrl', () => {
|
||||
it('builds an action=TEMPLATE URL with UTC dates, details and location', () => {
|
||||
it('builds an action=TEMPLATE URL with Bahia dates, details, canonical link and location', () => {
|
||||
const url = buildGoogleCalendarEventUrl({
|
||||
title: 'Plenária da saúde',
|
||||
description: 'Encontro online da campanha.',
|
||||
location: 'Online',
|
||||
url: 'https://jorgesolla1313.com.br/plenaria-saude',
|
||||
startsAt: STARTS_AT,
|
||||
endsAt: ENDS_AT,
|
||||
})
|
||||
|
|
@ -54,16 +57,42 @@ describe('buildGoogleCalendarEventUrl', () => {
|
|||
const parsed = new URL(url as string)
|
||||
expect(`${parsed.origin}${parsed.pathname}`).toBe('https://calendar.google.com/calendar/render')
|
||||
expect(parsed.searchParams.get('action')).toBe('TEMPLATE')
|
||||
expect(parsed.searchParams.get('text')).toBe('Plenária da saúde')
|
||||
expect(parsed.searchParams.get('dates')).toBe('20261003T220000Z/20261004T000000Z')
|
||||
expect(parsed.searchParams.get('details')).toBe('Encontro online da campanha.')
|
||||
expect(parsed.searchParams.get('text')).toBe('Plenária da saúde - Jorge Solla 1313')
|
||||
expect(parsed.searchParams.get('dates')).toBe('20261003T190000/20261003T210000')
|
||||
expect(parsed.searchParams.get('ctz')).toBe('America/Bahia')
|
||||
expect(parsed.searchParams.get('details')).toBe(
|
||||
'Encontro online da campanha.\n\nPágina do evento: https://jorgesolla1313.com.br/plenaria-saude',
|
||||
)
|
||||
expect(parsed.searchParams.get('location')).toBe('Online')
|
||||
})
|
||||
|
||||
it('does not duplicate the signature or emit a relative link', () => {
|
||||
const url = buildGoogleCalendarEventUrl({
|
||||
title: 'Plenária - Jorge Solla 1313',
|
||||
url: '/plenaria-saude',
|
||||
startsAt: STARTS_AT,
|
||||
})
|
||||
const parsed = new URL(url as string)
|
||||
expect(parsed.searchParams.get('text')).toBe('Plenária - Jorge Solla 1313')
|
||||
expect(parsed.searchParams.get('details')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the canonical link alone when the configured description is empty', () => {
|
||||
const url = buildGoogleCalendarEventUrl({
|
||||
title: 'Plenária',
|
||||
description: ' ',
|
||||
url: 'https://jorgesolla1313.com.br/plenaria',
|
||||
startsAt: STARTS_AT,
|
||||
})
|
||||
expect(new URL(url as string).searchParams.get('details')).toBe(
|
||||
'Página do evento: https://jorgesolla1313.com.br/plenaria',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the 2-hour default when the end is missing', () => {
|
||||
const url = buildGoogleCalendarEventUrl({ title: 'Plenária', startsAt: STARTS_AT })
|
||||
const parsed = new URL(url as string)
|
||||
expect(parsed.searchParams.get('dates')).toBe('20261003T220000Z/20261004T000000Z')
|
||||
expect(parsed.searchParams.get('dates')).toBe('20261003T190000/20261003T210000')
|
||||
expect(parsed.searchParams.get('details')).toBeNull()
|
||||
expect(parsed.searchParams.get('location')).toBeNull()
|
||||
})
|
||||
|
|
@ -75,12 +104,13 @@ describe('buildGoogleCalendarEventUrl', () => {
|
|||
})
|
||||
|
||||
describe('buildCalendarEventIcs', () => {
|
||||
it('builds a single VEVENT with CRLF and escaped fields', () => {
|
||||
it('builds a single VEVENT with Bahia timezone, canonical URL and escaped fields', () => {
|
||||
const ics = buildCalendarEventIcs({
|
||||
uid: 'plenaria@teqo.jorgesolla.com.br',
|
||||
title: 'Plenária, saúde',
|
||||
description: 'Linha 1\nLinha 2',
|
||||
location: 'Online; sala 1',
|
||||
url: 'https://jorgesolla1313.com.br/plenaria-saude',
|
||||
startsAt: STARTS_AT,
|
||||
endsAt: ENDS_AT,
|
||||
updatedAt: '2026-09-23T12:00:00.000Z',
|
||||
|
|
@ -92,14 +122,21 @@ describe('buildCalendarEventIcs', () => {
|
|||
expect(body.endsWith('END:VCALENDAR')).toBe(true)
|
||||
expect(body).toContain('UID:plenaria@teqo.jorgesolla.com.br\r\n')
|
||||
expect(body).toContain('DTSTAMP:20260923T120000Z\r\n')
|
||||
expect(body).toContain('DTSTART:20261003T220000Z\r\n')
|
||||
expect(body).toContain('DTEND:20261004T000000Z\r\n')
|
||||
expect(body).toContain('SUMMARY:Plenária\\, saúde\r\n')
|
||||
expect(body).toContain('DESCRIPTION:Linha 1\\nLinha 2\r\n')
|
||||
expect(body).toContain('BEGIN:VTIMEZONE\r\n')
|
||||
expect(body).toContain('TZID:America/Bahia\r\n')
|
||||
expect(body).toContain('TZOFFSETFROM:-0300\r\n')
|
||||
expect(body).toContain('TZOFFSETTO:-0300\r\n')
|
||||
expect(body).toContain('DTSTART;TZID=America/Bahia:20261003T190000\r\n')
|
||||
expect(body).toContain('DTEND;TZID=America/Bahia:20261003T210000\r\n')
|
||||
expect(body).toContain('SUMMARY:Plenária\\, saúde - Jorge Solla 1313\r\n')
|
||||
expect(body.replace(/\r\n /g, '')).toContain(
|
||||
'DESCRIPTION:Linha 1\\nLinha 2\\n\\nPágina do evento: https://jorgesolla1313.com.br/plenaria-saude\r\n',
|
||||
)
|
||||
expect(body).toContain('URL:https://jorgesolla1313.com.br/plenaria-saude\r\n')
|
||||
expect(body).toContain('LOCATION:Online\\; sala 1\r\n')
|
||||
})
|
||||
|
||||
it('omits optional lines and falls back to the start for DTSTAMP', () => {
|
||||
it('omits optional lines and falls back to the start for DTSTAMP without a canonical URL', () => {
|
||||
const body = buildCalendarEventIcs({
|
||||
uid: 'x@teqo.jorgesolla.com.br',
|
||||
title: 'Plenária',
|
||||
|
|
@ -108,8 +145,46 @@ describe('buildCalendarEventIcs', () => {
|
|||
}) as string
|
||||
|
||||
expect(body).toContain('DTSTAMP:20261003T220000Z\r\n')
|
||||
expect(body).toContain('DTSTART;TZID=America/Bahia:20261003T190000\r\n')
|
||||
expect(body).toContain('SUMMARY:Plenária - Jorge Solla 1313\r\n')
|
||||
expect(body).not.toContain('DESCRIPTION:')
|
||||
expect(body).not.toContain('LOCATION:')
|
||||
expect(body).not.toContain('URL:')
|
||||
expect(body).not.toMatch(/(?:^|\r\n)LOCATION:/)
|
||||
})
|
||||
|
||||
it('uses the canonical link alone when the configured description is empty', () => {
|
||||
const body = buildCalendarEventIcs({
|
||||
uid: 'x@teqo.jorgesolla.com.br',
|
||||
title: 'Plenária',
|
||||
description: '',
|
||||
url: 'https://jorgesolla1313.com.br/plenaria',
|
||||
startsAt: STARTS_AT,
|
||||
}) as string
|
||||
|
||||
expect(body.replace(/\r\n /g, '')).toContain(
|
||||
'DESCRIPTION:Página do evento: https://jorgesolla1313.com.br/plenaria\r\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('omits a relative or invalid event URL', () => {
|
||||
const body = buildCalendarEventIcs({
|
||||
uid: 'x@teqo.jorgesolla.com.br',
|
||||
title: 'Plenária',
|
||||
url: '/evento',
|
||||
startsAt: STARTS_AT,
|
||||
}) as string
|
||||
|
||||
expect(body).not.toContain('URL:')
|
||||
expect(body).not.toContain('Página do evento')
|
||||
|
||||
const controlBody = buildCalendarEventIcs({
|
||||
uid: 'x@teqo.jorgesolla.com.br',
|
||||
title: 'Plenária',
|
||||
url: 'https://jorgesolla1313.com.br/evento\nX-Injected: yes',
|
||||
startsAt: STARTS_AT,
|
||||
}) as string
|
||||
expect(controlBody).not.toContain('URL:')
|
||||
expect(controlBody).not.toContain('Página do evento')
|
||||
})
|
||||
|
||||
it('returns null without a valid start — never an empty file', () => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
civilDateDaysBetween,
|
||||
civilDateToUtcMidnightIso,
|
||||
floorToMinuteStep,
|
||||
formatBahiaCalendarDateTime,
|
||||
formatBahiaCivilDate,
|
||||
formatBahiaCivilDateTimeLabel,
|
||||
formatBahiaEventDateLabel,
|
||||
|
|
@ -31,6 +32,12 @@ describe('Bahia civil date helpers (B57)', () => {
|
|||
expect(today).toBe('2026-08-08')
|
||||
expect(subtractBahiaCivilDays(today, 7)).toBe('2026-08-01')
|
||||
})
|
||||
|
||||
it('formats a calendar instant as Bahia local time with seconds', () => {
|
||||
expect(formatBahiaCalendarDateTime(new Date('2026-10-03T22:00:00.000Z'))).toBe(
|
||||
'20261003T190000',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bahia civil datetime picker helpers (C97)', () => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
isReservedShareLinkSlug,
|
||||
isValidShareLinkDestination,
|
||||
isValidShareLinkSlug,
|
||||
normalizeAbsoluteHttpUrl,
|
||||
normalizeShareLinkDescription,
|
||||
resolveLiveShareLinkDestination,
|
||||
resolveShareLinkMode,
|
||||
|
|
@ -83,10 +84,16 @@ describe('shareLink destination', () => {
|
|||
'mailto:x@example.com',
|
||||
'http:foo',
|
||||
'https:example.com',
|
||||
'https://example.com/path\nX-Injected: yes',
|
||||
'',
|
||||
])('rejects %s', (value) => {
|
||||
expect(isValidShareLinkDestination(value)).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes absolute HTTP URLs and rejects control characters', () => {
|
||||
expect(normalizeAbsoluteHttpUrl(' https://example.com/path ')).toBe('https://example.com/path')
|
||||
expect(normalizeAbsoluteHttpUrl('https://example.com/path\r\nX-Injected: yes')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shareLink mode (S29)', () => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ describe('buildShareLinkAnnouncementView', () => {
|
|||
location: ' Online ',
|
||||
},
|
||||
imageUrl: '/api/media/file/plenaria.jpg',
|
||||
canonicalUrl: 'https://jorgesolla1313.com.br/plenaria-saude',
|
||||
})
|
||||
|
||||
expect(view).toEqual({
|
||||
|
|
@ -26,6 +27,7 @@ describe('buildShareLinkAnnouncementView', () => {
|
|||
location: 'Online',
|
||||
startsAt: '2026-10-03T22:00:00.000Z',
|
||||
endsAt: '2026-10-04T00:00:00.000Z',
|
||||
canonicalUrl: 'https://jorgesolla1313.com.br/plenaria-saude',
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -39,6 +41,7 @@ describe('buildShareLinkAnnouncementView', () => {
|
|||
location: ' ',
|
||||
},
|
||||
imageUrl: null,
|
||||
canonicalUrl: null,
|
||||
})
|
||||
|
||||
expect(view.imageAlt).toBe('Foto do encontro')
|
||||
|
|
@ -47,6 +50,7 @@ describe('buildShareLinkAnnouncementView', () => {
|
|||
expect(view.location).toBeNull()
|
||||
expect(view.startsAt).toBeNull()
|
||||
expect(view.endsAt).toBeNull()
|
||||
expect(view.canonicalUrl).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the event label when the date cannot be parsed', () => {
|
||||
|
|
@ -58,6 +62,7 @@ describe('buildShareLinkAnnouncementView', () => {
|
|||
startsAt: 'lixo',
|
||||
},
|
||||
imageUrl: null,
|
||||
canonicalUrl: null,
|
||||
})
|
||||
|
||||
expect(view.eventLabel).toBeNull()
|
||||
|
|
|
|||
Loading…
Reference in a new issue