mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 17:43:04 +00:00
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:
parent
4736533723
commit
25773d0296
18 changed files with 14379 additions and 1 deletions
133
docs/plans/sync-teqo-google-calendar-impl.md
Normal file
133
docs/plans/sync-teqo-google-calendar-impl.md
Normal 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)
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
125
src/app/(campaign)/campanha/actions/calendarFeed.ts
Normal file
125
src/app/(campaign)/campanha/actions/calendarFeed.ts
Normal 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
|
||||
}
|
||||
77
src/app/(campaign)/campanha/agenda/ical/[secret]/route.ts
Normal file
77
src/app/(campaign)/campanha/agenda/ical/[secret]/route.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
98
src/collections/CalendarFeed.ts
Normal file
98
src/collections/CalendarFeed.ts
Normal 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 }),
|
||||
],
|
||||
}
|
||||
|
|
@ -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.'}
|
||||
|
|
|
|||
216
src/components/campaign/activity/CalendarFeedButton.tsx
Normal file
216
src/components/campaign/activity/CalendarFeedButton.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
14
src/lib/schemas/calendarFeed.ts
Normal file
14
src/lib/schemas/calendarFeed.ts
Normal 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>
|
||||
13171
src/migrations/20260808_134911_add_calendar_feed.json
Normal file
13171
src/migrations/20260808_134911_add_calendar_feed.json
Normal file
File diff suppressed because it is too large
Load diff
39
src/migrations/20260808_134911_add_calendar_feed.ts
Normal file
39
src/migrations/20260808_134911_add_calendar_feed.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
47
src/utilities/access/calendarFeeds.ts
Normal file
47
src/utilities/access/calendarFeeds.ts
Normal 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)
|
||||
224
src/utilities/calendarFeed.ts
Normal file
224
src/utilities/calendarFeed.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -145,3 +145,11 @@ export {
|
|||
canWriteNotifications,
|
||||
canWritePushSubscriptions,
|
||||
} from '@/utilities/access/notifications'
|
||||
|
||||
export {
|
||||
canCreateCalendarFeed,
|
||||
canDeleteCalendarFeed,
|
||||
canReadCalendarFeed,
|
||||
canSetCalendarFeedSystemField,
|
||||
canUpdateCalendarFeed,
|
||||
} from '@/utilities/access/calendarFeeds'
|
||||
|
|
|
|||
120
tests/unit/calendarFeed.unit.spec.ts
Normal file
120
tests/unit/calendarFeed.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue