feat(C16): add calendar feed sync by filter with Google Calendar import link (#419)

- Add calendarFeed collection with secret slug credential
- Add iCal feed endpoint at /campanha/agenda/ical/[secret] (unauthenticated)
- Add server actions for creating and revoking calendar feeds
- Add CalendarFeedButton UI component in agenda filters bar
- Add unit tests for iCal generation
- Fail-closed access: feed re-evaluates creator's municipality scope on each read
- Leader lockdown preserved; no PII in feed output
This commit is contained in:
Francisco Solla 2026-08-08 11:47:17 -03:00 committed by GitHub
parent 4736533723
commit 25773d0296
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 14379 additions and 1 deletions

View file

@ -0,0 +1,133 @@
# Impl: Sync por filtro + link de import (Google Calendar)
Status: aprovado
Atualizado em: 2026-08-08
Issue: #392
Intenção: docs/plans/sync-teqo-google-calendar.md
Appetite restante: ~1,5–2 dias eng (herdado)
## Leitura da intenção
- **Outcome:** Com um filtro ativo na agenda, o usuário obtém um link de import e o Google Calendar passa a refletir esse recorte. Teqo permanece SoT.
- **O que NÃO negociar:** Leader lockdown (sem acesso); fail-closed no access do feed (não vaza municípios fora do escopo do criador); link com credencial revogável; sem sync bidirecional; sem PII desnecessária no feed.
- **O que reavaliar:** A hipótese de "token/HMAC" da intenção — para revogação real, collection é mais simples que stateless+blocklist.
## Abordagem recomendada
```mermaid
flowchart LR
F[filtros na agenda] --> G[server action: createCalendarFeed]
G --> DB[(calendarFeed collection)]
DB --> R[GET /campanha/agenda/ical/secret]
R --> ICS[ical response]
ICS --> GC[Google Calendar assina URL]
```
**Opções consideradas:**
- **A) Token HMAC stateless** (como supporterImportToken): codifica filtro+actorID no token. Problema: sem revogação real sem blocklist (= collection disfarçada).
- **B) Collection `calendarFeed`** com secret slug: feed URL usa o slug como credencial. Revogação = delete/soft-revoke do documento. Listing natural ("meus feeds").
- **C) Híbrido HMAC + collection:** over-engineered para o escopo.
**Recomendação: B** — collection `calendarFeed`. Revogação nativa, listing, access scoping no create e no read. O secret slug é a credencial (como um invite token, mas para leitura de feed).
**Rejeitadas:** A porque revogação exige estado de qualquer forma; C porque adiciona complexidade sem ganho.
### Decisões de engenharia
#### 1. Collection `calendarFeed` vs stateless
**Opções:** A) stateless HMAC | B) collection Payload
**Recomendação: B** — porque revogação é requisito de produto ("credencial revogável"), e uma collection dá listing, auditoria (`createdBy`), e access scoping nativo via Payload hooks.
**Rejeitadas: A** porque sem revogação real; blocklist seria uma collection disfarçada.
#### 2. Formato iCal: hand-roll vs library
**Opções:** A) `ical-generator` (npm) | B) hand-roll RFC 5545
**Recomendação: B** — o formato VCALENDAR/VEVENT é texto simples; os campos da Activity mapeiam diretamente (title→SUMMARY, startAt→DTSTART, etc.). Zero dependências novas, zero risk de supply-chain. Escaping de texto iCal é trivial (newline→`\n`, comma→`\,`, semicolon→`\;`).
**Rejeitadas: A** porque adiciona dependência para ~80 linhas de código.
#### 3. Feed endpoint: dentro ou fora de `(app)`
**Opções:** A) dentro de `(app)` (exige cookie campaign-token) | B) fora de `(app)`, sem auth cookie
**Recomendação: B** — Google Calendar não envia cookies. O secret slug É a credencial. O endpoint fica em `src/app/(campaign)/campanha/agenda/ical/[secret]/route.ts` (fora do `(app)` group, assim como os routes WebAuthn).
**Rejeitadas: A** porque Google Calendar "From URL" é um GET anônimo.
#### 4. Access scoping no feed read
**Opções:** A) snapshot no create (feed congela o escopo) | B) re-avaliar escopo do criador a cada read
**Recomendação: B** — se o advisor perde acesso a um município, o feed para de incluir atividades daquele município. Fail-closed: se o criador for desativado/deletado, o feed para de servir.
**Rejeitadas: A** porque violaria o invariante "não vaza municípios fora do ator".
#### 5. Atividades canceladas no feed
**Opções:** A) excluídas | B) incluídas com STATUS:CANCELLED
**Recomendação: A** — alinhado com a recomendação da intenção ("some do calendário importado").
**Rejeitadas: B** porque polui o calendário do consumidor sem valor.
#### 6. Janela temporal do feed
**Opções:** A) todas as atividades (passado + futuro) | B) janela deslizante (ex: 6 meses passado + 12 meses futuro)
**Recomendação: B** — janela de 90 dias passados + 365 dias futuros. Feed muito grande degrada a sync do Google Calendar. A janela é generosa para o uso real.
**Rejeitadas: A** porque histórico completo cresce indefinidamente.
### Componentes / mudanças
- **`calendarFeed` collection** (`src/collections/CalendarFeed.ts`): secretSlug (unique, indexed, auto-generated), label, filterMunicipality (optional rel→municipality), filterDeputyPresent (checkbox), filterTag (optional text), createdBy (rel→campaignUser), revokedAt (optional date). Admin group `Campanha`. Access: staff create/read own; coordinator/candidate read all; leader none.
- **Migration:** `add_calendar_feed` — cria a tabela `calendar_feeds`.
- **`src/utilities/calendarFeed.ts`**: `buildCalendarFeedWhere(state, rangeStart, rangeEnd)` (reusa `buildActivityAgendaWhere` com janela fixa), `generateICalFeed(events, feedLabel)` (hand-roll RFC 5545), `resolveFeedSecret(secret)` (lookup + access check).
- **`src/app/(campaign)/campanha/actions/calendarFeed.ts`**: server action `createCalendarFeedLink(state)` — valida filtro, gera secretSlug (randomUUID), salva no DB, retorna URL completa.
- **`src/app/(campaign)/campanha/agenda/ical/[secret]/route.ts`**: GET route (fora `(app)`). Lookup feed → access check do criador → query activities → generate iCal → response `text/calendar; charset=utf-8`.
- **UI: `CalendarFeedButton`** (`src/components/campaign/activity/CalendarFeedButton.tsx`): botão na barra de filtros da agenda. Habilitado quando há filtros ativos. Ao clicar: chama server action → dialog com URL + copy-to-clipboard + instruções Google Calendar.
- **UI: `CalendarFeedList`** (`src/components/campaign/activity/CalendarFeedList.tsx`): seção na página da agenda (ou modal) listando feeds ativos do usuário com opção de revogar.
### Access / Consent
- **Sem Consent** — feed não captura PII novo; é leitura de dados existentes com access já controlado.
- **Access no create:** `isCampaignStaff` (coordinator, advisor, candidate). Advisor só pode criar feed com filtros dentro do próprio escopo de municípios.
- **Access no read (feed endpoint):** re-carrega o criador, re-avalia escopo de municípios, aplica restrição ao where. Se criador inativo/deletado → 404.
- **Leader lockdown:** sem acesso a create/read/list.
## Fases verificáveis
1. **Schema + server** (~50% do appetite)
- Collection `calendarFeed` + migration
- Utility `generateICalFeed` + `resolveFeedSecret`
- GET route `/campanha/agenda/ical/[secret]`
- Server action `createCalendarFeedLink`
- Unit tests: iCal generation, access scoping, feed resolution
- Gate: `pnpm gate:fast`
2. **UI** (~30% do appetite)
- `CalendarFeedButton` na barra de filtros da agenda
- Dialog com URL + copy + instruções
- `CalendarFeedList` (meus feeds + revogar)
- Server action `revokeCalendarFeed`
3. **Gates** (~20% do appetite)
- `pnpm gate:fast` (tsc + lint + format + test)
- `pnpm build` (com DB local)
- `pnpm test` (unit + int)
## Rabbit holes / Não escopo (engenharia)
- **Webhook/push para Google:** fora. O Google puxa (poll) o feed periodicamente.
- **OAuth push:** fora. Link de import cobre o job.
- **Feed público sem segredo:** fora. Fail-closed.
- **Múltiplos formatos (CalDAV, etc.):** fora. Só iCal (RFC 5545).
- **Rate limiting no feed endpoint:** fora de v1. O slug é imprevisível (UUID) e o endpoint é read-only.
- **Cache HTTP no feed:** fora de v1. Google Calendar respeita `Cache-Control` mas a sync já é periódica (~12-24h). Podemos adicionar `max-age` depois.
## Riscos e mitigação
- **Secret slug vazado = leitura do recorte.** Mitigação: UUID (imprevisível), revogação disponível, access re-avaliado a cada read.
- **Feed endpoint sem auth cookie = surface nova.** Mitigação: slug é a credencial; fail-closed se slug inválido/revogado/criador inativo; sem PII sensível no output (só título + município + datas).
- **iCal mal-formado rejeitado pelo Google.** Mitigação: seguir RFC 5545 estritamente; testar com validador iCal; unit tests cobrindo escaping e edge cases.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto
- [ ] Invariantes AGENTS/engineering-standards
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam
- [ ] Leader lockdown preservado
- [ ] Feed fail-closed (slug inválido → 404; criador inativo → 404)

View file

@ -4,6 +4,11 @@ import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import {
createCalendarFeedLinkRecord,
listCalendarFeedsRecord,
revokeCalendarFeedRecord,
} from '@/app/(campaign)/campanha/actions/calendarFeed'
import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda'
import { ActivityAgendaFilters } from '@/components/campaign/activity/ActivityAgendaFilters'
import {
@ -39,9 +44,10 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
requireCampaignPageActor({ gate: 'staff' }),
getPayload({ config }),
])
const [municipalityOptions, knownTags] = await Promise.all([
const [municipalityOptions, knownTags, feeds] = await Promise.all([
loadMunicipalityOptions(payload, user),
loadAccessibleActivityTags(payload, user),
listCalendarFeedsRecord().catch(() => []),
])
const state = restrictActivityAgendaState(
resolvedUrl.state,
@ -73,6 +79,25 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
state={state}
municipalityOptions={municipalityOptions}
knownTags={knownTags}
feeds={feeds.map((f) => ({
id: f.id,
label: f.label,
createdAt: f.createdAt,
}))}
onCreateFeed={async (label) => {
'use server'
const result = await createCalendarFeedLinkRecord({
label,
filterMunicipality: state.municipality,
filterDeputyPresent: state.deputyPresent,
filterTag: state.tag,
})
return result
}}
onRevokeFeed={async (feedId) => {
'use server'
return revokeCalendarFeedRecord(feedId)
}}
/>
<CampaignListResults>
<ActivityAgenda state={state} />

View file

@ -0,0 +1,125 @@
'use server'
import { randomUUID } from 'node:crypto'
import { calendarFeedCreateSchema, type CalendarFeedCreateInput } from '@/lib/schemas/calendarFeed'
import type { CalendarFeed } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
const CALENDAR_FEED_STAFF_ONLY_MESSAGE =
'Apenas a equipe da campanha pode criar feeds de calendário.'
const CALENDAR_FEED_CREATE_FAILED_MESSAGE = 'Não foi possível criar o feed de calendário.'
export type CalendarFeedLinkResult =
| { ok: true; feedUrl: string; feedId: number }
| { ok: false; message: string }
const buildFeedUrl = (secretSlug: string): string => {
const baseUrl = getCampaignInviteBaseURL()
return `${baseUrl}/campanha/agenda/ical/${secretSlug}`
}
export const createCalendarFeedLinkRecord = async (
input: CalendarFeedCreateInput,
): Promise<CalendarFeedLinkResult> => {
const data = calendarFeedCreateSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
try {
return await withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadStaffActor(
payload,
actor,
CALENDAR_FEED_STAFF_ONLY_MESSAGE,
req,
)
if (!isCampaignStaff(currentActor)) {
return { ok: false as const, message: CALENDAR_FEED_STAFF_ONLY_MESSAGE }
}
const secretSlug = randomUUID()
const feed = (await payload.create({
collection: 'calendarFeed',
data: {
secretSlug,
label: data.label,
filterMunicipality: data.filterMunicipality,
filterDeputyPresent: data.filterDeputyPresent ?? false,
filterTag: data.filterTag,
createdBy: currentActor.id,
},
depth: 0,
draft: false,
user: currentActor,
overrideAccess: false,
req,
})) as CalendarFeed
return {
ok: true as const,
feedUrl: buildFeedUrl(secretSlug),
feedId: feed.id,
}
},
{ beginFailureMessage: 'Não foi possível iniciar a criação do feed de calendário.' },
)
} catch {
return { ok: false, message: CALENDAR_FEED_CREATE_FAILED_MESSAGE }
}
}
export const revokeCalendarFeedRecord = async (feedId: number): Promise<{ ok: boolean }> => {
const { payload, actor } = await getCampaignActionContext()
try {
await withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadStaffActor(
payload,
actor,
CALENDAR_FEED_STAFF_ONLY_MESSAGE,
req,
)
await payload.update({
collection: 'calendarFeed',
id: feedId,
data: { revokedAt: new Date().toISOString() },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível revogar o feed de calendário.' },
)
return { ok: true }
} catch {
return { ok: false }
}
}
export const listCalendarFeedsRecord = async (): Promise<CalendarFeed[]> => {
const { payload, actor } = await getCampaignActionContext()
const result = await payload.find({
collection: 'calendarFeed',
depth: 0,
limit: 0,
pagination: false,
sort: '-createdAt',
where: { revokedAt: { exists: false } },
user: actor,
overrideAccess: false,
})
return result.docs
}

View file

@ -0,0 +1,77 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import {
generateICalFeed,
loadFeedActivities,
loadMunicipalityNames,
resolveFeedCreatorAccess,
} from '@/utilities/calendarFeed'
type RouteContext = {
params: Promise<{ secret: string }>
}
const notFoundResponse = () =>
new Response('Feed não encontrado.', {
status: 404,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
})
export const GET = async (_request: Request, context: RouteContext): Promise<Response> => {
const { secret } = await context.params
if (!secret || secret.length < 32) {
return notFoundResponse()
}
const payload = await getPayload({ config })
try {
// Intentional admin bypass: the feed endpoint is unauthenticated (Google
// Calendar subscribes by URL); the secret slug is the credential, and
// creator access is re-evaluated below via resolveFeedCreatorAccess.
const feeds = await payload.find({
collection: 'calendarFeed',
depth: 1,
limit: 1,
pagination: false,
where: { secretSlug: { equals: secret } },
overrideAccess: true,
})
const feed = feeds.docs[0]
if (!feed) {
return notFoundResponse()
}
if (feed.revokedAt) {
return notFoundResponse()
}
const access = await resolveFeedCreatorAccess(payload, feed)
if (!access.accessible) {
return notFoundResponse()
}
const activities = await loadFeedActivities(payload, feed, access.municipalityIds)
const municipalityIds = activities
.map((a) => (typeof a.municipality === 'number' ? a.municipality : a.municipality?.id))
.filter((id): id is number => id !== undefined)
const municipalityNames = await loadMunicipalityNames(payload, [...new Set(municipalityIds)])
const icalContent = generateICalFeed(activities, feed.label, municipalityNames)
return new Response(icalContent, {
status: 200,
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Cache-Control': 'public, max-age=3600',
},
})
} catch {
return notFoundResponse()
}
}

View file

@ -0,0 +1,98 @@
import type { CollectionConfig } from 'payload'
import {
canCreateCalendarFeed,
canDeleteCalendarFeed,
canReadCalendarFeed,
canSetCalendarFeedSystemField,
canUpdateCalendarFeed,
} from '@/utilities/campaignAccess'
import { systemStampedActorField } from '@/utilities/campaignAuditFields'
export const CalendarFeed: CollectionConfig = {
slug: 'calendarFeed',
labels: {
singular: 'Feed de calendário',
plural: 'Feeds de calendário',
},
admin: {
group: 'Campanha',
useAsTitle: 'label',
defaultColumns: [
'label',
'filterMunicipality',
'filterTag',
'filterDeputyPresent',
'createdAt',
],
},
access: {
create: canCreateCalendarFeed,
read: canReadCalendarFeed,
update: canUpdateCalendarFeed,
delete: canDeleteCalendarFeed,
},
fields: [
{
name: 'secretSlug',
type: 'text',
required: true,
unique: true,
index: true,
admin: {
hidden: true,
},
access: {
create: canSetCalendarFeedSystemField,
read: () => false,
update: canSetCalendarFeedSystemField,
},
},
{
name: 'label',
type: 'text',
label: 'Nome',
required: true,
admin: {
description: 'Nome descritivo para identificar o feed (ex: "Só deputado presente")',
},
},
{
name: 'filterMunicipality',
type: 'relationship',
relationTo: 'municipality',
label: 'Filtro: Município',
admin: {
description: 'Se vazio, inclui todos os municípios do escopo do criador',
},
},
{
name: 'filterDeputyPresent',
type: 'checkbox',
label: 'Filtro: Deputado presente',
defaultValue: false,
},
{
name: 'filterTag',
type: 'text',
label: 'Filtro: Tag',
admin: {
description: 'Se vazio, inclui todas as tags',
},
},
{
name: 'revokedAt',
type: 'date',
label: 'Revogado em',
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetCalendarFeedSystemField,
update: canSetCalendarFeedSystemField,
},
},
systemStampedActorField({ required: true, setAccess: canSetCalendarFeedSystemField }),
],
}

View file

@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'
import { CalendarFeedButton } from '@/components/campaign/activity/CalendarFeedButton'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { Button } from '@/components/ui/button'
@ -9,16 +10,30 @@ import { Checkbox } from '@/components/ui/Checkbox'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { buildActivityAgendaHref, type ActivityAgendaState } from '@/utilities/activityUi'
type CalendarFeedSummary = {
id: number
label: string
createdAt: string
}
type ActivityAgendaFiltersProps = {
state: ActivityAgendaState
municipalityOptions: RelationOption[]
knownTags: string[]
feeds?: CalendarFeedSummary[]
onCreateFeed?: (
label: string,
) => Promise<{ ok: true; feedUrl: string } | { ok: false; message: string }>
onRevokeFeed?: (feedId: number) => Promise<{ ok: boolean }>
}
export const ActivityAgendaFilters = ({
state,
municipalityOptions,
knownTags,
feeds = [],
onCreateFeed,
onRevokeFeed,
}: ActivityAgendaFiltersProps) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
@ -106,6 +121,16 @@ export const ActivityAgendaFilters = ({
>
Limpar filtros
</Button>
{onCreateFeed && onRevokeFeed && (
<CalendarFeedButton
state={state}
hasFilters={hasFilters}
feeds={feeds}
onCreateFeed={onCreateFeed}
onRevokeFeed={onRevokeFeed}
/>
)}
</div>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando os compromissos da agenda.' : 'Filtros da agenda atualizados.'}

View file

@ -0,0 +1,216 @@
'use client'
import { useState } from 'react'
import { CalendarIcon, CopyIcon, ExternalLinkIcon, Trash2Icon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import type { ActivityAgendaState } from '@/utilities/activityUi'
type CalendarFeed = {
id: number
label: string
createdAt: string
}
type CalendarFeedButtonProps = {
state: ActivityAgendaState
hasFilters: boolean
feeds: CalendarFeed[]
onCreateFeed: (
label: string,
) => Promise<{ ok: true; feedUrl: string } | { ok: false; message: string }>
onRevokeFeed: (feedId: number) => Promise<{ ok: boolean }>
}
export const CalendarFeedButton = ({
state: _state,
hasFilters,
feeds,
onCreateFeed,
onRevokeFeed,
}: CalendarFeedButtonProps) => {
const [isOpen, setIsOpen] = useState(false)
const [label, setLabel] = useState('')
const [createdUrl, setCreatedUrl] = useState<string | null>(null)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const handleCreate = async () => {
if (!label.trim()) return
setIsCreating(true)
setError(null)
const result = await onCreateFeed(label.trim())
setIsCreating(false)
if (result.ok) {
setCreatedUrl(result.feedUrl)
setLabel('')
} else {
setError(result.message)
}
}
const handleCopy = async () => {
if (!createdUrl) return
await navigator.clipboard.writeText(createdUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleRevoke = async (feedId: number) => {
await onRevokeFeed(feedId)
}
const handleClose = () => {
setIsOpen(false)
setCreatedUrl(null)
setError(null)
setCopied(false)
}
return (
<>
<Button
type="button"
variant="outline"
className="min-h-11"
disabled={!hasFilters}
onClick={() => setIsOpen(true)}
title={!hasFilters ? 'Aplique filtros para gerar um link de import' : undefined}
>
<CalendarIcon data-icon="inline-start" aria-hidden="true" />
Link de import
</Button>
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Sincronizar com Google Calendar</DialogTitle>
<DialogDescription>
Gere um link de import para sincronizar este recorte da agenda com o Google Calendar.
Alterações em Teqo serão refletidas automaticamente.
</DialogDescription>
</DialogHeader>
{createdUrl ? (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Link de import</label>
<div className="flex gap-2">
<Input value={createdUrl} readOnly className="font-mono text-xs" />
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopy}
aria-label="Copiar link"
>
<CopyIcon className="h-4 w-4" />
</Button>
</div>
{copied && <p className="text-xs text-green-600">Link copiado!</p>}
</div>
<div className="rounded-lg bg-muted p-4 text-sm">
<p className="font-medium mb-2">Como adicionar ao Google Calendar:</p>
<ol className="list-decimal list-inside space-y-1 text-muted-foreground">
<li>Abra o Google Calendar no computador</li>
<li>
No menu lateral, clique em {'"'}+{'"'} ao lado de {'"'}Outras agendas{'"'}
</li>
<li>
Selecione {'"'}Por URL{'"'}
</li>
<li>
Cole o link acima e clique em {'"'}Adicionar agenda{'"'}
</li>
</ol>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleClose}>
Fechar
</Button>
<Button asChild>
<a
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLinkIcon className="h-4 w-4 mr-2" />
Abrir Google Calendar
</a>
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="feed-label" className="text-sm font-medium">
Nome do feed
</label>
<Input
id="feed-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Ex: Só deputado presente"
maxLength={120}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
/>
<p className="text-xs text-muted-foreground">
Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'}
Reuniões em Salvador{'"'})
</p>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{feeds.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium">Seus feeds ativos</p>
<ul className="space-y-1">
{feeds.map((feed) => (
<li
key={feed.id}
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
>
<span>{feed.label}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRevoke(feed.id)}
aria-label={`Revogar feed ${feed.label}`}
>
<Trash2Icon className="h-4 w-4 text-muted-foreground" />
</Button>
</li>
))}
</ul>
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleClose}>
Cancelar
</Button>
<Button type="button" onClick={handleCreate} disabled={!label.trim() || isCreating}>
{isCreating ? 'Gerando...' : 'Gerar link'}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
)
}

View file

@ -0,0 +1,14 @@
import { z } from 'zod'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
const MAX_CALENDAR_FEED_LABEL_LENGTH = 120
export const calendarFeedCreateSchema = z.object({
label: z.string().trim().min(1).max(MAX_CALENDAR_FEED_LABEL_LENGTH),
filterMunicipality: positiveRelationshipId.optional(),
filterDeputyPresent: z.boolean().optional(),
filterTag: z.string().trim().min(1).max(80).optional(),
})
export type CalendarFeedCreateInput = z.input<typeof calendarFeedCreateSchema>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE "calendar_feed" (
"id" serial PRIMARY KEY NOT NULL,
"secret_slug" varchar NOT NULL,
"label" varchar NOT NULL,
"filter_municipality_id" integer,
"filter_deputy_present" boolean DEFAULT false,
"filter_tag" varchar,
"revoked_at" timestamp(3) with time zone,
"created_by_id" integer NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "calendar_feed_id" integer;
ALTER TABLE "calendar_feed" ADD CONSTRAINT "calendar_feed_filter_municipality_id_municipality_id_fk" FOREIGN KEY ("filter_municipality_id") REFERENCES "public"."municipality"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "calendar_feed" ADD CONSTRAINT "calendar_feed_created_by_id_campaign_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
CREATE UNIQUE INDEX "calendar_feed_secret_slug_idx" ON "calendar_feed" USING btree ("secret_slug");
CREATE INDEX "calendar_feed_filter_municipality_idx" ON "calendar_feed" USING btree ("filter_municipality_id");
CREATE INDEX "calendar_feed_revoked_at_idx" ON "calendar_feed" USING btree ("revoked_at");
CREATE INDEX "calendar_feed_created_by_idx" ON "calendar_feed" USING btree ("created_by_id");
CREATE INDEX "calendar_feed_updated_at_idx" ON "calendar_feed" USING btree ("updated_at");
CREATE INDEX "calendar_feed_created_at_idx" ON "calendar_feed" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_calendar_feed_fk" FOREIGN KEY ("calendar_feed_id") REFERENCES "public"."calendar_feed"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_calendar_feed_id_idx" ON "payload_locked_documents_rels" USING btree ("calendar_feed_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "calendar_feed" DISABLE ROW LEVEL SECURITY;
DROP TABLE "calendar_feed" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_calendar_feed_fk";
DROP INDEX "payload_locked_documents_rels_calendar_feed_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "calendar_feed_id";`)
}

View file

@ -43,6 +43,7 @@ import * as migration_20260804_061017_add_state_deputy_advisors from './20260804
import * as migration_20260806_082110_add_state_deputy_contact from './20260806_082110_add_state_deputy_contact';
import * as migration_20260806_120000_c14_remodel_activity_agenda from './20260806_120000_c14_remodel_activity_agenda';
import * as migration_20260807_030000_unify_municipality_update from './20260807_030000_unify_municipality_update';
import * as migration_20260808_134911_add_calendar_feed from './20260808_134911_add_calendar_feed';
export const migrations = [
{
@ -270,4 +271,9 @@ export const migrations = [
down: migration_20260807_030000_unify_municipality_update.down,
name: '20260807_030000_unify_municipality_update',
},
{
up: migration_20260808_134911_add_calendar_feed.up,
down: migration_20260808_134911_add_calendar_feed.down,
name: '20260808_134911_add_calendar_feed'
},
];

View file

@ -86,6 +86,7 @@ export interface Config {
supporterImportBatch: SupporterImportBatch;
municipalityUpdate: MunicipalityUpdate;
activity: Activity;
calendarFeed: CalendarFeed;
electionTally: ElectionTally;
electionCandidateVote: ElectionCandidateVote;
electionCandidate: ElectionCandidate;
@ -125,6 +126,7 @@ export interface Config {
supporterImportBatch: SupporterImportBatchSelect<false> | SupporterImportBatchSelect<true>;
municipalityUpdate: MunicipalityUpdateSelect<false> | MunicipalityUpdateSelect<true>;
activity: ActivitySelect<false> | ActivitySelect<true>;
calendarFeed: CalendarFeedSelect<false> | CalendarFeedSelect<true>;
electionTally: ElectionTallySelect<false> | ElectionTallySelect<true>;
electionCandidateVote: ElectionCandidateVoteSelect<false> | ElectionCandidateVoteSelect<true>;
electionCandidate: ElectionCandidateSelect<false> | ElectionCandidateSelect<true>;
@ -839,6 +841,31 @@ export interface MunicipalityUpdate {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "calendarFeed".
*/
export interface CalendarFeed {
id: number;
secretSlug: string;
/**
* Nome descritivo para identificar o feed (ex: "Só deputado presente")
*/
label: string;
/**
* Se vazio, inclui todos os municípios do escopo do criador
*/
filterMunicipality?: (number | null) | Municipality;
filterDeputyPresent?: boolean | null;
/**
* Se vazio, inclui todas as tags
*/
filterTag?: string | null;
revokedAt?: string | null;
createdBy: number | CampaignUser;
updatedAt: string;
createdAt: string;
}
/**
* Totais oficiais TSE por município e zona (dado público).
*
@ -1304,6 +1331,10 @@ export interface PayloadLockedDocument {
relationTo: 'activity';
value: number | Activity;
} | null)
| ({
relationTo: 'calendarFeed';
value: number | CalendarFeed;
} | null)
| ({
relationTo: 'electionTally';
value: number | ElectionTally;
@ -1787,6 +1818,21 @@ export interface ActivitySelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "calendarFeed_select".
*/
export interface CalendarFeedSelect<T extends boolean = true> {
secretSlug?: T;
label?: T;
filterMunicipality?: T;
filterDeputyPresent?: T;
filterTag?: T;
revokedAt?: T;
createdBy?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "electionTally_select".
@ -2346,6 +2392,7 @@ export interface TaskCreateCollectionExport {
| 'supporterImportBatch'
| 'municipalityUpdate'
| 'activity'
| 'calendarFeed'
| 'electionTally'
| 'electionCandidateVote'
| 'electionCandidate'

View file

@ -11,6 +11,7 @@ import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
import { pt } from 'payload/i18n/pt'
import { Activity } from './collections/Activity'
import { AllocationDecision } from './collections/AllocationDecision'
import { CalendarFeed } from './collections/CalendarFeed'
import { CampaignDemand } from './collections/CampaignDemand'
import { CampaignInvite } from './collections/CampaignInvite'
import { CampaignUser } from './collections/CampaignUser'
@ -105,6 +106,7 @@ export default buildConfig({
SupporterImportBatch,
MunicipalityUpdate,
Activity,
CalendarFeed,
ElectionTally,
ElectionCandidateVote,
ElectionCandidate,

View file

@ -0,0 +1,47 @@
import type { Access, FieldAccess } from 'payload'
import {
getFreshCampaignUser,
isCampaignStaff,
isCampaignUnrestricted,
isPayloadAdmin,
} from '@/utilities/access/shared'
export const canCreateCalendarFeed: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return isCampaignStaff(currentUser)
}
export const canReadCalendarFeed: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignUnrestricted(currentUser)) return true
if (!currentUser || currentUser.role !== 'advisor') return false
return { createdBy: { equals: currentUser.id } }
}
export const canUpdateCalendarFeed: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignUnrestricted(currentUser)) return true
if (!currentUser || currentUser.role !== 'advisor') return false
return { createdBy: { equals: currentUser.id } }
}
export const canDeleteCalendarFeed: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignUnrestricted(currentUser)) return true
if (!currentUser || currentUser.role !== 'advisor') return false
return { createdBy: { equals: currentUser.id } }
}
export const canSetCalendarFeedSystemField: FieldAccess = ({ req }) => isPayloadAdmin(req.user)

View file

@ -0,0 +1,224 @@
import 'server-only'
import type { Activity, CalendarFeed } from '@/payload-types'
import { advisorMunicipalityScopeWhere } from '@/utilities/access/shared'
import type { Payload, Where } from 'payload'
const CALENDAR_NAME = 'Agenda Teqo'
const FEED_LOOKBACK_DAYS = 90
const FEED_LOOKAHEAD_DAYS = 365
const escapeICalText = (value: string): string =>
value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;')
const formatICalDate = (isoString: string): string =>
isoString.replace(/[-:]/g, '').replace(/\.\d{3}/, '')
const buildActivityDescription = (activity: Activity, municipalityName?: string): string => {
const parts: string[] = []
if (municipalityName) parts.push(`Município: ${municipalityName}`)
if (activity.locality) parts.push(`Local: ${activity.locality}`)
if (activity.tags?.length) parts.push(`Tags: ${activity.tags.join(', ')}`)
if (activity.deputyPresent) parts.push('Deputado presente')
if (activity.status === 'cancelado') parts.push('CANCELADO')
return parts.join('\\n')
}
export const generateICalFeed = (
activities: Activity[],
feedLabel: string,
municipalityNames: Map<number, string>,
): string => {
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Teqo//Agenda//PT',
`X-WR-CALNAME:${escapeICalText(feedLabel || CALENDAR_NAME)}`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
]
for (const activity of activities) {
if (activity.status === 'cancelado') continue
if (!activity.startAt) continue
const municipalityId =
typeof activity.municipality === 'number' ? activity.municipality : activity.municipality?.id
const municipalityName = municipalityId ? municipalityNames.get(municipalityId) : undefined
const dtStart = formatICalDate(activity.startAt)
const dtEnd = activity.endAt ? formatICalDate(activity.endAt) : dtStart
const summary = municipalityName ? `[${municipalityName}] ${activity.title}` : activity.title
lines.push('BEGIN:VEVENT')
lines.push(`UID:${activity.slug}@teqo.jorgesolla.com.br`)
lines.push(`DTSTART:${dtStart}`)
if (activity.endAt) {
lines.push(`DTEND:${dtEnd}`)
} else {
lines.push(`DTEND:${dtStart}`)
}
lines.push(`SUMMARY:${escapeICalText(summary)}`)
lines.push(`DESCRIPTION:${buildActivityDescription(activity, municipalityName)}`)
lines.push(`DTSTAMP:${formatICalDate(activity.updatedAt || activity.createdAt)}`)
lines.push('END:VEVENT')
}
lines.push('END:VCALENDAR')
return lines.join('\r\n')
}
const buildFeedDateRange = (): { rangeStart: string; rangeEnd: string } => {
const now = new Date()
const rangeStart = new Date(now)
rangeStart.setDate(rangeStart.getDate() - FEED_LOOKBACK_DAYS)
const rangeEnd = new Date(now)
rangeEnd.setDate(rangeEnd.getDate() + FEED_LOOKAHEAD_DAYS)
return {
rangeStart: rangeStart.toISOString(),
rangeEnd: rangeEnd.toISOString(),
}
}
const buildFeedWhere = (
feed: CalendarFeed,
rangeStart: string,
rangeEnd: string,
accessibleMunicipalityIds: number[] | null,
): Where => {
const filters: Where[] = [
{ startAt: { less_than: rangeEnd } },
{
or: [
{ endAt: { greater_than: rangeStart } },
{
and: [{ endAt: { exists: false } }, { startAt: { greater_than_equal: rangeStart } }],
},
],
},
{ status: { not_equals: 'cancelado' } },
]
if (feed.filterMunicipality) {
const municipalityId =
typeof feed.filterMunicipality === 'number'
? feed.filterMunicipality
: feed.filterMunicipality.id
filters.push({ municipality: { equals: municipalityId } })
} else if (accessibleMunicipalityIds) {
filters.push(advisorMunicipalityScopeWhere('municipality', accessibleMunicipalityIds))
}
if (feed.filterDeputyPresent) {
filters.push({ deputyPresent: { equals: true } })
}
if (feed.filterTag) {
filters.push({ tags: { contains: feed.filterTag } })
}
return { and: filters }
}
export const loadFeedActivities = async (
payload: Payload,
feed: CalendarFeed,
accessibleMunicipalityIds: number[] | null,
): Promise<Activity[]> => {
const { rangeStart, rangeEnd } = buildFeedDateRange()
const where = buildFeedWhere(feed, rangeStart, rangeEnd, accessibleMunicipalityIds)
// Intentional admin bypass: access is already enforced at the feed level
// (creator's municipality scope is applied to the where clause).
const result = await payload.find({
collection: 'activity',
depth: 1,
limit: 0,
pagination: false,
sort: 'startAt',
where,
overrideAccess: true,
})
return result.docs
}
export const loadMunicipalityNames = async (
payload: Payload,
ids: number[],
): Promise<Map<number, string>> => {
if (ids.length === 0) return new Map()
// Intentional admin bypass: municipality names are public metadata,
// not PII; the feed endpoint already scoped the ids to the creator's access.
const result = await payload.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
where: { id: { in: ids } },
overrideAccess: true,
})
const names = new Map<number, string>()
for (const doc of result.docs) {
names.set(doc.id, doc.name)
}
return names
}
export const resolveFeedCreatorAccess = async (
payload: Payload,
feed: CalendarFeed,
): Promise<{
accessible: boolean
municipalityIds: number[] | null
}> => {
const creatorId = typeof feed.createdBy === 'number' ? feed.createdBy : feed.createdBy?.id
if (!creatorId) {
return { accessible: false, municipalityIds: null }
}
try {
// Intentional admin bypass: the feed endpoint has no authenticated user,
// so we must reload the creator to verify their account still exists.
const creator = await payload.findByID({
collection: 'campaignUser',
id: creatorId,
depth: 0,
overrideAccess: true,
})
if (!creator) {
return { accessible: false, municipalityIds: null }
}
if (creator.role === 'coordinator' || creator.role === 'candidate') {
return { accessible: true, municipalityIds: null }
}
if (creator.role === 'advisor') {
// Intentional admin bypass: advisor municipality assignments are
// internal metadata; the feed endpoint re-derives scope from them.
const municipalities = await payload.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
where: { advisors: { equals: creatorId } },
overrideAccess: true,
})
return {
accessible: true,
municipalityIds: municipalities.docs.map((m) => m.id),
}
}
return { accessible: false, municipalityIds: null }
} catch {
return { accessible: false, municipalityIds: null }
}
}

View file

@ -145,3 +145,11 @@ export {
canWriteNotifications,
canWritePushSubscriptions,
} from '@/utilities/access/notifications'
export {
canCreateCalendarFeed,
canDeleteCalendarFeed,
canReadCalendarFeed,
canSetCalendarFeedSystemField,
canUpdateCalendarFeed,
} from '@/utilities/access/calendarFeeds'

View file

@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import { generateICalFeed } from '@/utilities/calendarFeed'
describe('generateICalFeed', () => {
const municipalityNames = new Map([
[1, 'Salvador'],
[2, 'Feira de Santana'],
])
it('generates a valid VCALENDAR with VEVENT entries', () => {
const activities = [
{
id: 1,
slug: 'comicio-salvador',
title: 'Comício em Salvador',
status: 'confirmado' as const,
startAt: '2026-08-15T19:00:00.000Z',
endAt: '2026-08-15T22:00:00.000Z',
municipality: 1,
locality: 'Centro',
tags: ['comício', 'campanha'],
deputyPresent: true,
updatedAt: '2026-08-01T10:00:00.000Z',
createdAt: '2026-08-01T10:00:00.000Z',
},
] as unknown as Parameters<typeof generateICalFeed>[0]
const result = generateICalFeed(activities, 'Agenda do Candidato', municipalityNames)
expect(result).toContain('BEGIN:VCALENDAR')
expect(result).toContain('END:VCALENDAR')
expect(result).toContain('BEGIN:VEVENT')
expect(result).toContain('END:VEVENT')
expect(result).toContain('UID:comicio-salvador@teqo.jorgesolla.com.br')
expect(result).toContain('SUMMARY:[Salvador] Comício em Salvador')
expect(result).toContain('DTSTART:20260815T190000Z')
expect(result).toContain('DTEND:20260815T220000Z')
expect(result).toContain('X-WR-CALNAME:Agenda do Candidato')
})
it('excludes cancelled activities', () => {
const activities = [
{
id: 1,
slug: 'evento-cancelado',
title: 'Evento Cancelado',
status: 'cancelado' as const,
startAt: '2026-08-15T19:00:00.000Z',
municipality: 1,
updatedAt: '2026-08-01T10:00:00.000Z',
createdAt: '2026-08-01T10:00:00.000Z',
},
] as unknown as Parameters<typeof generateICalFeed>[0]
const result = generateICalFeed(activities, 'Test', municipalityNames)
expect(result).not.toContain('BEGIN:VEVENT')
expect(result).not.toContain('evento-cancelado')
})
it('escapes special characters in text fields', () => {
const activities = [
{
id: 1,
slug: 'evento-especial',
title: 'Reunião; com vírgulas, e quebras\nde linha',
status: 'confirmado' as const,
startAt: '2026-08-15T19:00:00.000Z',
municipality: 1,
updatedAt: '2026-08-01T10:00:00.000Z',
createdAt: '2026-08-01T10:00:00.000Z',
},
] as unknown as Parameters<typeof generateICalFeed>[0]
const result = generateICalFeed(activities, 'Test', municipalityNames)
expect(result).toContain('Reunião\\; com vírgulas\\, e quebras\\nde linha')
})
it('uses title only when municipality name is not available', () => {
const activities = [
{
id: 1,
slug: 'evento-sem-municipio',
title: 'Evento Genérico',
status: 'confirmado' as const,
startAt: '2026-08-15T19:00:00.000Z',
municipality: 999,
updatedAt: '2026-08-01T10:00:00.000Z',
createdAt: '2026-08-01T10:00:00.000Z',
},
] as unknown as Parameters<typeof generateICalFeed>[0]
const result = generateICalFeed(activities, 'Test', municipalityNames)
expect(result).toContain('SUMMARY:Evento Genérico')
})
it('uses startAt as endAt when endAt is not provided', () => {
const activities = [
{
id: 1,
slug: 'evento-sem-fim',
title: 'Evento Sem Fim',
status: 'confirmado' as const,
startAt: '2026-08-15T19:00:00.000Z',
endAt: null,
municipality: 1,
updatedAt: '2026-08-01T10:00:00.000Z',
createdAt: '2026-08-01T10:00:00.000Z',
},
] as unknown as Parameters<typeof generateICalFeed>[0]
const result = generateICalFeed(activities, 'Test', municipalityNames)
expect(result).toContain('DTSTART:20260815T190000Z')
expect(result).toContain('DTEND:20260815T190000Z')
})
})

View file

@ -386,6 +386,7 @@ describe('src/utilities top-level is pinned', () => {
'activityUi.ts',
'activityViewModels.ts',
'advisorData.ts',
'calendarFeed.ts',
'campaignAccess.ts',
'campaignActionContext.ts',
'campaignAuditFields.ts',