Merge pull request #456 from fsolla/C92-link-de-import-corrigir-criacao-do-feed
fix(C92): corrige criação do link de import da agenda (feed)
This commit is contained in:
commit
c9134becd1
9 changed files with 620 additions and 129 deletions
95
docs/plans/c92-corrigir-criacao-link-import-agenda-impl.md
Normal file
95
docs/plans/c92-corrigir-criacao-link-import-agenda-impl.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Impl: C92 — Link de import da agenda: corrigir criação do feed
|
||||
|
||||
Status: em execução
|
||||
Atualizado em: 2026-08-08
|
||||
Issue: #436
|
||||
Intenção: docs/plans/c92-corrigir-criacao-link-import-agenda.md
|
||||
Appetite restante: ~0,5–1 dia eng (herdado; entrega menor que o appetite)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** staff (coordenador, assessor, candidato) gera o link de import nomeando o feed, sem erro; o link responde; revogar e listar continuam; erro real não é engolido; não vaza município fora do escopo do criador.
|
||||
- **O que NÃO negociar:** leader lockdown; fail-closed no read do feed (o segredo não vaza); credencial revogável; sem sync bidirecional; invariante C16 de escopo do criador.
|
||||
- **O que reavaliar:** a "forma do create" — a hipótese da intenção (ajuste de acesso vs forma) foi respondida por repro: o padrão que já funciona no repo é **hook-stamp do `createdBy` + acesso de campo relaxado onde o servidor minta o valor**, exatamente como `Organization`/`Leadership`/`StateDeputy` (que criam com `overrideAccess:false`).
|
||||
|
||||
## Causa raiz (reproduzida localmente com actor real `campaignUser`)
|
||||
|
||||
`createCalendarFeedLinkRecord` cria com `overrideAccess: false` + `user: campaignUser`, `data` inclui `secretSlug` e `createdBy` — ambos `required` com `access.create: canSetCalendarFeedSystemField` (só Payload admin). Payload **strip** os campos negados → `ValidationError` (400) → catch engole numa mensagem genérica.
|
||||
|
||||
**Prova (repro em `tests/int`, DB local `teqo_wt92`):**
|
||||
|
||||
- `payload.create(calendarFeed, { secretSlug, createdBy }, user: coordinator, overrideAccess:false)` → `ValidationError: ... Secret Slug, Criado por` (o bug).
|
||||
- Mesmo create **com `beforeChange` hook que grava `createdBy = req.user.id`** (e sem passar `createdBy` do lado da action) → **OK**, `createdBy` gravado = id do actor. ← o fix (padrão `stampCampaignCreatedBy`).
|
||||
- **Bug nº 2 (mesma raiz, silencioso):** `revokeCalendarFeedRecord` faz `payload.update({ revokedAt })` com `overrideAccess:false`; `revokedAt.access.update` é admin-only → `revokedAt` é stripado → update retorna ok mas **nunca revoga**. Aceite C16 "revogar continua funcionando" está quebrado. Prova: `revokedAt set? false` com update `overrideAccess:false`.
|
||||
- Leader create → bloqueado (acesso de collection já OK). Confirmado.
|
||||
|
||||
## Componentes / mudanças
|
||||
|
||||
### 1. `src/utilities/access/calendarFeeds.ts`
|
||||
|
||||
- **Manter** `canSetCalendarFeedSystemField` (admin-only) — continua para `secretSlug.update`, `revokedAt.create` e `createdBy`.
|
||||
- **`canCreateCalendarFeed`** agora recebe `data` e verifica `filterMunicipality` no escopo do assessor (precedente `canCreateSupporter` em `supporters.ts`), fail-closed — fecha o bypass por REST/`payload.create` cru, não só pela action.
|
||||
- **Novo `canSetCalendarFeedSecret: FieldAccess`** — `isPayloadAdmin || isCampaignStaff(getFreshCampaignUser)`; usado só em `secretSlug.access.create` (staff minta o segredo **no create**; `read: () => false` e `update` admin-only inalterados → segredo nunca re-lido, nunca rotacionado).
|
||||
- **Novo `canSetCalendarFeedRevocation: FieldAccess`** — `isPayloadAdmin || isCampaignStaff(getFreshCampaignUser)`; usado só em `revokedAt.access.update` (staff marca revogado; `canUpdateCalendarFeed` já escopa advisor a `createdBy` próprio → staff só revoga feed próprio).
|
||||
|
||||
### 2. `src/collections/CalendarFeed.ts`
|
||||
|
||||
- `secretSlug.access.create` → `canSetCalendarFeedSecret` (`read` e `update` inalterados).
|
||||
- `revokedAt.access.update` → `canSetCalendarFeedRevocation` (`create` admin-only inalterado).
|
||||
- Adicionar `hooks.beforeChange: [stampCampaignCreatedBy]` (mesmo hook de Organization/Leadership/StateDeputy — `campaignAuditFields.ts`).
|
||||
- **Sem migration** — mudança de config, não de schema.
|
||||
|
||||
### 3. `src/app/(campaign)/campanha/actions/calendarFeed.ts`
|
||||
|
||||
- Núcleo testável `createCalendarFeedLinkRecord(payload, actor, input)` + wrappers `createCalendarFeedLink`/`revokeCalendarFeed`/`listCalendarFeeds` (padrão `*Record` do repo — `createActivity`/`createSupporter`; `/simplify` corrigiu a inversão `With`).
|
||||
- **Parar de passar `createdBy`** no create → `hookFilledCreateData<'calendarFeed'>` (tipos: campo `required` preenchido pelo hook; mesmo cast de Organization).
|
||||
- **Escopo de assessor no create (fechar furo do filtro):** validar fail-closed que `filterMunicipality` está no escopo — espelhando o precedente de tour (`activity.ts:166-185`): `payload.find` scoped (`user`, `overrideAccess:false`) e conferir `docs.length === 1`; senão retorna erro amigável. A defesa principal hoje está no acesso de collection (item 1); esta checagem dá a mensagem específica e cobre o caminho da action.
|
||||
- **Não engolir o erro real:** catch do create/revoke passa a `console.error` no servidor (rastro: quebra de acesso/configuração) mantendo a mensagem amigável para o usuário (contrato de UX inalterado; a mensagem de fora-de-escopo é preservada via `error.message`).
|
||||
|
||||
### Access / Consent
|
||||
|
||||
- Sem `Consent` novo (nenhuma PII nova; leitura de dados com acesso já controlado).
|
||||
- `secretSlug`: create=staff, read=fail-closed (`() => false`), update=admin (credencial só nasce no create).
|
||||
- `createdBy`: hook grava de `req.user.id` (inauthenticável por client); admin de /admin ainda seta manualmente via `canSetCalendarFeedSystemField`.
|
||||
|
||||
## Decisões de engenharia
|
||||
|
||||
- **Opções:** A) relaxar `create` do `secretSlug` + `update` do `revokedAt` para staff e hook-stamp `createdBy`; B) `overrideAccess:true` no create (como `Supporter`); C) hook que gera o segredo e action relê com override.
|
||||
- **Recomendação: A** — porque mantém `overrideAccess:false` (invariante: Local API com user → sem override), `read` do segredo continuamente fechado, autor atomizado por hook (sem spoof), e revoga de novo funcionando. Alinha com Organization/Leadership/StateDeputy, os irmãos do mesmo padrão `systemStampedActorField`.
|
||||
- **Rejeitadas:** B porque `overrideAccess:true` desliga TODO o access control do doc (inclui create de leader se a action falhar em checar; e esconde que o write depende de RBAC); C porque o create response strip `secretSlug` (prova: `hasSecret:false` no retorno com `read:false`) — a action não consegue ler o segredo que o hook gerou sem override, então o servidor deve fornecê-lo (como hoje, `randomUUID()`).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Schema/server** — access novo (incl. `canCreateCalendarFeed` com `data` scope) + collection hooks + action (núcleo `*Record`, drop `createdBy`, scope-check advisor, `console.error` no catch).
|
||||
2. **Testes (int)** — `tests/int/calendarFeed.int.spec.ts` novo (10 testes), estilo `campaignSupporter.int.spec.ts` com `installCampaignFixtures`:
|
||||
- coordinator cria → ok, `createdBy` = actor, URL com secret;
|
||||
- candidate cria;
|
||||
- `createdBy` forjado (create cru com `createdBy` de outro) sobrescrito pelo hook;
|
||||
- revoke → `revokedAt` setado (update staff OK);
|
||||
- advisor with `filterMunicipality` no escopo → ok;
|
||||
- advisor fora do escopo → erro (pela action) e `payload.create` cru negado (acesso de collection);
|
||||
- leader → negado;
|
||||
- advisor não revoga feed de outro;
|
||||
- secret nunca retornado em leitura não-admin.
|
||||
- fixture: `calendarFeed` adicionado a `CampaignCollection`/`emptyOwnedIDs`/cleanup loop/`purgeMunicipalityResidue`.
|
||||
3. **Gates** — `pnpm gate:fast` (lint/types/unit), `pnpm test` (unit+int), `pnpm build` local.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Refactor de todos os `canSet*SystemField` do repo** — corte: mudança local em `calendarFeed` (a intenção manda).
|
||||
- **Fechar o furo de `filterMunicipality` também no read** (`buildFeedWhere` interseptar escopo no GET) — **registrado como Issue #455 (C96)** via capture-review-debts (gate humano aprovado): defesa em profundidade, `depends: [436]`, destrava sozinha quando o C92 flipar `done`.
|
||||
- **Create via `/admin` UI** — `secretSlug` hidden+required e `createdBy` readOnly+required: `stampCampaignCreatedBy` só grava `campaignUser`, então admin pelos forms do painel não preenche esses campos. Pré-existente ao C92 (não regressão), fora do escopo (staff cria pelo diálogo da agenda, não pelo painel). Anotado como nota, sem Issue.
|
||||
- **UI (diálogo/lista)** — C94.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Regressão no read do feed:** nenhum toque em `calendarFeed.ts`/route iCal — read intacto.
|
||||
- **Field access relaxado abre escape de `createdBy`?** Não: `createdBy` permanece com `setAccess` admin-only; o hook grava de `req.user` (prova local); client não atinge o valor.
|
||||
- **CampaignUser admin (Users) cria por /admin:** ok — admin tem `canSetCalendarFeedSystemField`.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (create staff ok, URL responde, revogar ok, erro não engolido, sem leak de escopo)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (`overrideAccess:false` com user, copy pt-BR, identificadores EN)
|
||||
- [ ] Testes int de access/write (staff cria; leader não; revoke grava; advisor escopo fail-closed; secret não lido)
|
||||
- [ ] `pnpm gate:fast` + `pnpm test` + `pnpm build` verdes
|
||||
36
docs/plans/c96-ical-feed-intersect-scope-read.md
Normal file
36
docs/plans/c96-ical-feed-intersect-scope-read.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# C96 — Feed iCal: interseptar escopo do criador no read (defesa em profundidade)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-08
|
||||
Issue: #455
|
||||
Priority: P3
|
||||
Model: composer-2.5
|
||||
Model-local: deepseek-v4-flash-high
|
||||
Impeccable: A — N/A sem UI
|
||||
Appetite: ~0,25–0,5 dia eng
|
||||
Responsável: —
|
||||
|
||||
## Intenção
|
||||
|
||||
Débito do `/simplify` do C92 (#436), aprovado no gate humano. O write-side do feed (`canCreateCalendarFeed` + `assertFilterMunicipalityInScope`) já impede um assessor de criar feed pinado a município fora do escopo. Mas o read-side (`buildFeedWhere`, `src/utilities/calendarFeed.ts:104-112`) **confia no `feed.filterMunicipality` sem interseptar os ids acessíveis do criador** quando `accessibleMunicipalityIds` é não-nulo. Consequência: se um assessor é **removido** de um município depois de criar o feed, o feed continua servindo atividades daquele município até o feed ser revogado.
|
||||
|
||||
## Aceite
|
||||
|
||||
- No GET `/campanha/agenda/ical/[secret]`, quando o criador do feed é advisor e `feed.filterMunicipality` está setado, a consulta intersepta o município do filtro com os municípios atualmente administrados pelo criador — município fora do escopo atual → feed não inclui (404/feed vazio, alinhado ao fail-closed do C16).
|
||||
- Coordinator/candidate (unrestricted) e admin: comportamento inalterado.
|
||||
- Sem mudança no write; sem migration (config/consulta only).
|
||||
|
||||
## Direção no codebase
|
||||
|
||||
- `src/utilities/calendarFeed.ts` → `buildFeedWhere`: quando `feed.filterMunicipality` setado E `accessibleMunicipalityIds` não-nulo (advisor), exigir `{ id: { equals: filterMunicipality } } AND { id: { in: accessible } }` (ou validar antes e 404). `resolveFeedCreatorAccess` já calcula `accessibleMunicipalityIds` no read.
|
||||
- Precedente: `advisorMunicipalityScopeWhere` (`src/utilities/access/shared.ts`).
|
||||
- Teste int: `tests/int/calendarFeed.int.spec.ts` — feed criado por advisor em município X, depois advisor removido de X (`assignMunicipalityAdvisors(mun, [])`), GET do feed não lista atividades de X.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Remodelar o modelo de acesso do feed (maior refactor).
|
||||
- UI (C94) / sem-filtros (C93), que já são Issues separadas.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- Nenhuma — fail-closed orientado pelo C16 e pelo C92.
|
||||
|
|
@ -5,9 +5,9 @@ import { redirect } from 'next/navigation'
|
|||
import { getPayload } from 'payload'
|
||||
|
||||
import {
|
||||
createCalendarFeedLinkRecord,
|
||||
listCalendarFeedsRecord,
|
||||
revokeCalendarFeedRecord,
|
||||
createCalendarFeedLink,
|
||||
listCalendarFeeds,
|
||||
revokeCalendarFeed,
|
||||
} from '@/app/(campaign)/campanha/actions/calendarFeed'
|
||||
import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda'
|
||||
import { ActivityAgendaFilters } from '@/components/campaign/activity/ActivityAgendaFilters'
|
||||
|
|
@ -47,7 +47,7 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
|
|||
const [municipalityOptions, knownTags, feeds] = await Promise.all([
|
||||
loadMunicipalityOptions(payload, user),
|
||||
loadAccessibleActivityTags(payload, user),
|
||||
listCalendarFeedsRecord().catch(() => []),
|
||||
listCalendarFeeds().catch(() => []),
|
||||
])
|
||||
const state = restrictActivityAgendaState(
|
||||
resolvedUrl.state,
|
||||
|
|
@ -86,7 +86,7 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
|
|||
}))}
|
||||
onCreateFeed={async (label) => {
|
||||
'use server'
|
||||
const result = await createCalendarFeedLinkRecord({
|
||||
const result = await createCalendarFeedLink({
|
||||
label,
|
||||
filterMunicipality: state.municipality,
|
||||
filterDeputyPresent: state.deputyPresent,
|
||||
|
|
@ -96,7 +96,7 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
|
|||
}}
|
||||
onRevokeFeed={async (feedId) => {
|
||||
'use server'
|
||||
return revokeCalendarFeedRecord(feedId)
|
||||
return revokeCalendarFeed(feedId)
|
||||
}}
|
||||
/>
|
||||
<CampaignListResults>
|
||||
|
|
|
|||
|
|
@ -3,15 +3,21 @@
|
|||
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 type { CalendarFeed, CampaignUser } from '@/payload-types'
|
||||
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
|
||||
import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import { hookFilledCreateData } from '@/utilities/hookFilledData'
|
||||
import {
|
||||
withPayloadTransaction,
|
||||
type PayloadTransactionRequest,
|
||||
} from '@/utilities/payloadTransaction'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
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.'
|
||||
const CALENDAR_FEED_FILTER_OUT_OF_SCOPE_MESSAGE =
|
||||
'Não foi possível criar o feed: o município do filtro está fora do seu escopo.'
|
||||
|
||||
export type CalendarFeedLinkResult =
|
||||
| { ok: true; feedUrl: string; feedId: number }
|
||||
|
|
@ -23,93 +29,117 @@ const buildFeedUrl = (secretSlug: string): string => {
|
|||
}
|
||||
|
||||
export const createCalendarFeedLinkRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: CalendarFeedCreateInput,
|
||||
): Promise<CalendarFeedLinkResult> => {
|
||||
const data = calendarFeedCreateSchema.parse(input)
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await reloadStaffActor(
|
||||
payload,
|
||||
actor,
|
||||
CALENDAR_FEED_STAFF_ONLY_MESSAGE,
|
||||
req,
|
||||
)
|
||||
|
||||
if (data.filterMunicipality) {
|
||||
await assertFilterMunicipalityInScope(payload, currentActor, data.filterMunicipality, req)
|
||||
}
|
||||
|
||||
const secretSlug = randomUUID()
|
||||
|
||||
const feed = (await payload.create({
|
||||
collection: 'calendarFeed',
|
||||
// `createdBy` is filled by the `stampCampaignCreatedBy` beforeChange hook
|
||||
// from the acting campaign user — never taken from the client.
|
||||
data: hookFilledCreateData<'calendarFeed'>({
|
||||
secretSlug,
|
||||
label: data.label,
|
||||
filterMunicipality: data.filterMunicipality,
|
||||
filterDeputyPresent: data.filterDeputyPresent ?? false,
|
||||
filterTag: data.filterTag,
|
||||
}),
|
||||
depth: 0,
|
||||
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.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const createCalendarFeedLink = async (
|
||||
input: CalendarFeedCreateInput,
|
||||
): Promise<CalendarFeedLinkResult> => {
|
||||
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 }
|
||||
return await createCalendarFeedLinkRecord(payload, actor, input)
|
||||
} catch (error) {
|
||||
console.error('createCalendarFeedLink failed', error)
|
||||
const message =
|
||||
error instanceof Error && error.message === CALENDAR_FEED_FILTER_OUT_OF_SCOPE_MESSAGE
|
||||
? error.message
|
||||
: CALENDAR_FEED_CREATE_FAILED_MESSAGE
|
||||
return { ok: false, message }
|
||||
}
|
||||
}
|
||||
|
||||
export const revokeCalendarFeedRecord = async (feedId: number): Promise<{ ok: boolean }> => {
|
||||
export const revokeCalendarFeedRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
feedId: number,
|
||||
): Promise<{ ok: boolean }> => {
|
||||
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 }
|
||||
}
|
||||
|
||||
export const revokeCalendarFeed = 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 await revokeCalendarFeedRecord(payload, actor, feedId)
|
||||
} catch (error) {
|
||||
console.error('revokeCalendarFeed failed', error)
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
export const listCalendarFeedsRecord = async (): Promise<CalendarFeed[]> => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
|
||||
export const listCalendarFeedsRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
): Promise<CalendarFeed[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'calendarFeed',
|
||||
depth: 0,
|
||||
|
|
@ -123,3 +153,37 @@ export const listCalendarFeedsRecord = async (): Promise<CalendarFeed[]> => {
|
|||
|
||||
return result.docs
|
||||
}
|
||||
|
||||
export const listCalendarFeeds = async (): Promise<CalendarFeed[]> => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return listCalendarFeedsRecord(payload, actor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a staff actor may pin `filterMunicipality` to a given municipality —
|
||||
* fail-closed for advisors (the collection create access is a plain staff
|
||||
* boolean and cannot express a per-município constraint, the same reason the
|
||||
* tour composer re-checks its stops in `activity.ts`). The scoped read resolves
|
||||
* against the feed author's own accessible set, so an out-of-scope id never
|
||||
* survives.
|
||||
*/
|
||||
const assertFilterMunicipalityInScope = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
municipalityId: number,
|
||||
req?: PayloadTransactionRequest,
|
||||
): Promise<void> => {
|
||||
const readable = await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { id: { equals: municipalityId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
if (readable.docs.length !== 1) {
|
||||
throw new Error(CALENDAR_FEED_FILTER_OUT_OF_SCOPE_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import {
|
|||
canCreateCalendarFeed,
|
||||
canDeleteCalendarFeed,
|
||||
canReadCalendarFeed,
|
||||
canSetCalendarFeedRevocation,
|
||||
canSetCalendarFeedSecret,
|
||||
canSetCalendarFeedSystemField,
|
||||
canUpdateCalendarFeed,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { systemStampedActorField } from '@/utilities/campaignAuditFields'
|
||||
import { stampCampaignCreatedBy, systemStampedActorField } from '@/utilities/campaignAuditFields'
|
||||
|
||||
export const CalendarFeed: CollectionConfig = {
|
||||
slug: 'calendarFeed',
|
||||
|
|
@ -32,6 +34,9 @@ export const CalendarFeed: CollectionConfig = {
|
|||
update: canUpdateCalendarFeed,
|
||||
delete: canDeleteCalendarFeed,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [stampCampaignCreatedBy],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'secretSlug',
|
||||
|
|
@ -43,7 +48,7 @@ export const CalendarFeed: CollectionConfig = {
|
|||
hidden: true,
|
||||
},
|
||||
access: {
|
||||
create: canSetCalendarFeedSystemField,
|
||||
create: canSetCalendarFeedSecret,
|
||||
read: () => false,
|
||||
update: canSetCalendarFeedSystemField,
|
||||
},
|
||||
|
|
@ -90,7 +95,7 @@ export const CalendarFeed: CollectionConfig = {
|
|||
},
|
||||
access: {
|
||||
create: canSetCalendarFeedSystemField,
|
||||
update: canSetCalendarFeedSystemField,
|
||||
update: canSetCalendarFeedRevocation,
|
||||
},
|
||||
},
|
||||
systemStampedActorField({ required: true, setAccess: canSetCalendarFeedSystemField }),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { Access, FieldAccess } from 'payload'
|
||||
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
|
||||
import {
|
||||
getFreshCampaignUser,
|
||||
isCampaignStaff,
|
||||
|
|
@ -7,11 +9,21 @@ import {
|
|||
isPayloadAdmin,
|
||||
} from '@/utilities/access/shared'
|
||||
|
||||
export const canCreateCalendarFeed: Access = async ({ req }) => {
|
||||
export const canCreateCalendarFeed: Access = async ({ data, req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
const currentUser = await getFreshCampaignUser(req)
|
||||
return isCampaignStaff(currentUser)
|
||||
if (isCampaignUnrestricted(currentUser)) return true
|
||||
if (!currentUser || !isCampaignStaff(currentUser)) return false
|
||||
|
||||
// The remaining staff role is advisor. A pinned filter municipality must be one
|
||||
// the advisor administers (same shape as `canCreateSupporter`); the same scope
|
||||
// is re-derived on every read, so an unfiltered feed stays within scope too.
|
||||
const municipalityID = relationshipId(data?.filterMunicipality)
|
||||
if (!municipalityID) return true
|
||||
|
||||
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
|
||||
return municipalityIDs?.includes(municipalityID) ?? false
|
||||
}
|
||||
|
||||
export const canReadCalendarFeed: Access = async ({ req }) => {
|
||||
|
|
@ -45,3 +57,25 @@ export const canDeleteCalendarFeed: Access = async ({ req }) => {
|
|||
}
|
||||
|
||||
export const canSetCalendarFeedSystemField: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
||||
/**
|
||||
* Staff may mint a feed's secret on create; only Payload admin rotates it.
|
||||
* The read side is kept `() => false` in the collection, so the credential is
|
||||
* never re-read; `update` stays admin-only (revoke+recreate, never in place).
|
||||
*/
|
||||
export const canSetCalendarFeedSecret: FieldAccess = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
return isCampaignStaff(await getFreshCampaignUser(req))
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff may mark a feed revoked (update only). `canUpdateCalendarFeed` already
|
||||
* scopes advisors to their own feeds, so a staff member can only revoke a feed
|
||||
* they created.
|
||||
*/
|
||||
export const canSetCalendarFeedRevocation: FieldAccess = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
return isCampaignStaff(await getFreshCampaignUser(req))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,6 +149,8 @@ export {
|
|||
canCreateCalendarFeed,
|
||||
canDeleteCalendarFeed,
|
||||
canReadCalendarFeed,
|
||||
canSetCalendarFeedRevocation,
|
||||
canSetCalendarFeedSecret,
|
||||
canSetCalendarFeedSystemField,
|
||||
canUpdateCalendarFeed,
|
||||
} from '@/utilities/access/calendarFeeds'
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ type CampaignCollection =
|
|||
| 'contact'
|
||||
| 'campaignUser'
|
||||
| 'consent'
|
||||
| 'calendarFeed'
|
||||
|
||||
type OwnedIDs = Record<CampaignCollection, Set<number>>
|
||||
|
||||
|
|
@ -126,6 +127,7 @@ const emptyOwnedIDs = (): OwnedIDs => ({
|
|||
contact: new Set(),
|
||||
campaignUser: new Set(),
|
||||
consent: new Set(),
|
||||
calendarFeed: new Set(),
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
@ -234,50 +236,59 @@ const purgeMunicipalityResidue = async (
|
|||
payload: Payload,
|
||||
municipalityID: number,
|
||||
): Promise<void> => {
|
||||
const [pledges, updates, demands, leaderships, supporters, activities] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'votePledge',
|
||||
where: { municipality: { equals: municipalityID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: {},
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'municipalityUpdate',
|
||||
where: { municipality: { equals: municipalityID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: {},
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'campaignDemand',
|
||||
where: { municipality: { equals: municipalityID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: {},
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'leadership',
|
||||
where: { municipalities: { in: [municipalityID] } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: {},
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'supporter',
|
||||
where: { municipality: { equals: municipalityID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: {},
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'activity',
|
||||
where: { municipality: { equals: municipalityID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: {},
|
||||
}),
|
||||
])
|
||||
const [pledges, updates, demands, leaderships, supporters, activities, feeds] = await Promise.all(
|
||||
[
|
||||
payload.find({
|
||||
collection: 'votePledge',
|
||||
where: { municipality: { equals: municipalityID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: {},
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'municipalityUpdate',
|
||||
where: { municipality: { equals: municipalityID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: {},
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'campaignDemand',
|
||||
where: { municipality: { equals: municipalityID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: {},
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'leadership',
|
||||
where: { municipalities: { in: [municipalityID] } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: {},
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'supporter',
|
||||
where: { municipality: { equals: municipalityID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: {},
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'activity',
|
||||
where: { municipality: { equals: municipalityID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: {},
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'calendarFeed',
|
||||
where: { filterMunicipality: { equals: municipalityID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: {},
|
||||
}),
|
||||
],
|
||||
)
|
||||
|
||||
// Residue invites reference residue leaderships (FK), so they must be
|
||||
// found and deleted before the leaderships — otherwise the leadership
|
||||
|
|
@ -303,6 +314,7 @@ const purgeMunicipalityResidue = async (
|
|||
| 'leadership'
|
||||
| 'supporter'
|
||||
| 'activity'
|
||||
| 'calendarFeed'
|
||||
ids: number[]
|
||||
}> = [
|
||||
{ collection: 'campaignInvite', ids: invites.docs.map((doc) => doc.id) },
|
||||
|
|
@ -310,6 +322,7 @@ const purgeMunicipalityResidue = async (
|
|||
{ collection: 'municipalityUpdate', ids: updates.docs.map((doc) => doc.id) },
|
||||
{ collection: 'campaignDemand', ids: demands.docs.map((doc) => doc.id) },
|
||||
{ collection: 'activity', ids: activities.docs.map((doc) => doc.id) },
|
||||
{ collection: 'calendarFeed', ids: feeds.docs.map((doc) => doc.id) },
|
||||
{ collection: 'leadership', ids: leadershipIDs },
|
||||
{ collection: 'supporter', ids: supporters.docs.map((doc) => doc.id) },
|
||||
]
|
||||
|
|
@ -1131,6 +1144,7 @@ export class CampaignFixtures {
|
|||
'organization',
|
||||
'stateDeputy',
|
||||
'contact',
|
||||
'calendarFeed',
|
||||
] as const) {
|
||||
await this.deleteOwned(collection, req)
|
||||
}
|
||||
|
|
|
|||
241
tests/int/calendarFeed.int.spec.ts
Normal file
241
tests/int/calendarFeed.int.spec.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { getPayload, type Payload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createCalendarFeedLinkRecord,
|
||||
listCalendarFeedsRecord,
|
||||
revokeCalendarFeedRecord,
|
||||
type CalendarFeedLinkResult,
|
||||
} from '@/app/(campaign)/campanha/actions/calendarFeed'
|
||||
import config from '@/payload.config'
|
||||
import { hookFilledCreateData } from '@/utilities/hookFilledData'
|
||||
|
||||
import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
type CalendarFeedWithSecret = { secretSlug?: string }
|
||||
|
||||
/** `{ ok: true }` branch of the create result, narrowed for the caller. */
|
||||
type CalendarFeedOk = Extract<CalendarFeedLinkResult, { ok: true }>
|
||||
|
||||
const requireOk = (result: CalendarFeedLinkResult): CalendarFeedOk => {
|
||||
if (!result.ok) throw new Error(result.message)
|
||||
return result
|
||||
}
|
||||
|
||||
describe('campaign calendar feed domain', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('lets a coordinator create a feed and resolves the secret into a URL', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
const municipality = await campaignFixtures().getMunicipality()
|
||||
|
||||
const ok = requireOk(
|
||||
await createCalendarFeedLinkRecord(payload, coordinator, {
|
||||
label: 'Agenda do comitê',
|
||||
filterMunicipality: relationId(municipality),
|
||||
filterDeputyPresent: true,
|
||||
}),
|
||||
)
|
||||
expect(ok.feedUrl).toMatch(/\/campanha\/agenda\/ical\/.{36}$/)
|
||||
|
||||
const feed = await payload.findByID({
|
||||
collection: 'calendarFeed',
|
||||
id: ok.feedId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(feed.label).toBe('Agenda do comitê')
|
||||
expect(relationId(feed.createdBy)).toBe(coordinator.id)
|
||||
expect(feed.filterDeputyPresent).toBe(true)
|
||||
expect(relationId(feed.filterMunicipality)).toBe(relationId(municipality))
|
||||
campaignFixtures().own('calendarFeed', ok.feedId)
|
||||
})
|
||||
|
||||
it('allows a candidate to create a feed', async () => {
|
||||
const candidate = await campaignFixtures().createCampaignUser('candidate')
|
||||
|
||||
const ok = requireOk(
|
||||
await createCalendarFeedLinkRecord(payload, candidate, {
|
||||
label: 'Agenda do candidato',
|
||||
}),
|
||||
)
|
||||
const feed = await payload.findByID({
|
||||
collection: 'calendarFeed',
|
||||
id: ok.feedId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(relationId(feed.createdBy)).toBe(candidate.id)
|
||||
campaignFixtures().own('calendarFeed', ok.feedId)
|
||||
})
|
||||
|
||||
it('stamps createdBy from the acting staff user, overriding a forged value', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
const other = await campaignFixtures().createCampaignUser('coordinator')
|
||||
|
||||
// The hook must win over a client-supplied `createdBy`, even a bare create
|
||||
// that does not go through the action (a REST `/api/calendarFeeds` hit).
|
||||
const feed = await payload.create({
|
||||
collection: 'calendarFeed',
|
||||
data: {
|
||||
secretSlug: crypto.randomUUID(),
|
||||
label: 'Forjado',
|
||||
createdBy: other.id,
|
||||
},
|
||||
depth: 0,
|
||||
draft: false,
|
||||
user: coordinator,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(relationId(feed.createdBy)).toBe(coordinator.id)
|
||||
campaignFixtures().own('calendarFeed', feed.id)
|
||||
})
|
||||
|
||||
it('lets the owning coordinator mark the feed revoked (field access fix)', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
const ok = requireOk(
|
||||
await createCalendarFeedLinkRecord(payload, coordinator, { label: 'Revogável' }),
|
||||
)
|
||||
campaignFixtures().own('calendarFeed', ok.feedId)
|
||||
|
||||
const revoked = await revokeCalendarFeedRecord(payload, coordinator, ok.feedId)
|
||||
expect(revoked.ok).toBe(true)
|
||||
|
||||
const feed = await payload.findByID({
|
||||
collection: 'calendarFeed',
|
||||
id: ok.feedId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(feed.revokedAt).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides revoked feeds from an active listing', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
const kept = requireOk(
|
||||
await createCalendarFeedLinkRecord(payload, coordinator, { label: 'Ativo' }),
|
||||
)
|
||||
campaignFixtures().own('calendarFeed', kept.feedId)
|
||||
|
||||
const ok = requireOk(
|
||||
await createCalendarFeedLinkRecord(payload, coordinator, { label: 'Revogar logo' }),
|
||||
)
|
||||
campaignFixtures().own('calendarFeed', ok.feedId)
|
||||
await revokeCalendarFeedRecord(payload, coordinator, ok.feedId)
|
||||
|
||||
const listing = await listCalendarFeedsRecord(payload, coordinator)
|
||||
const ids = listing.map((feed) => feed.id)
|
||||
expect(ids).toContain(kept.feedId)
|
||||
expect(ids).not.toContain(ok.feedId)
|
||||
})
|
||||
|
||||
it('lets an advisor create a feed pinned to a municipality they administer', async () => {
|
||||
const advisor = await campaignFixtures().createCampaignUser('advisor')
|
||||
const administered = await campaignFixtures().getMunicipality()
|
||||
await campaignFixtures().assignMunicipalityAdvisors(administered, [advisor.id])
|
||||
|
||||
const ok = requireOk(
|
||||
await createCalendarFeedLinkRecord(payload, advisor, {
|
||||
label: 'Meu município',
|
||||
filterMunicipality: relationId(administered),
|
||||
}),
|
||||
)
|
||||
const feed = await payload.findByID({
|
||||
collection: 'calendarFeed',
|
||||
id: ok.feedId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(relationId(feed.createdBy)).toBe(advisor.id)
|
||||
expect(relationId(feed.filterMunicipality)).toBe(relationId(administered))
|
||||
campaignFixtures().own('calendarFeed', ok.feedId)
|
||||
})
|
||||
|
||||
it('fails close when an advisor pins a municipality outside their scope (action path)', async () => {
|
||||
const advisor = await campaignFixtures().createCampaignUser('advisor')
|
||||
const administered = await campaignFixtures().getMunicipality()
|
||||
const outOfScope = await campaignFixtures().getMunicipality()
|
||||
await campaignFixtures().assignMunicipalityAdvisors(administered, [advisor.id])
|
||||
|
||||
await expect(
|
||||
createCalendarFeedLinkRecord(payload, advisor, {
|
||||
label: 'Fora do escopo',
|
||||
filterMunicipality: relationId(outOfScope),
|
||||
}),
|
||||
).rejects.toThrow(/fora do seu escopo/)
|
||||
})
|
||||
|
||||
it('fails close when an advisor pins an out-of-scope municipality via raw create (collection access)', async () => {
|
||||
const advisor = await campaignFixtures().createCampaignUser('advisor')
|
||||
const administered = await campaignFixtures().getMunicipality()
|
||||
const outOfScope = await campaignFixtures().getMunicipality()
|
||||
await campaignFixtures().assignMunicipalityAdvisors(administered, [advisor.id])
|
||||
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: 'calendarFeed',
|
||||
// `createdBy` is filled by the stamp hook at runtime; `hookFilledCreateData`
|
||||
// keeps the type honest about what the client actually supplies.
|
||||
data: hookFilledCreateData<'calendarFeed'>({
|
||||
secretSlug: crypto.randomUUID(),
|
||||
label: 'Fora do escopo (REST)',
|
||||
filterMunicipality: relationId(outOfScope),
|
||||
}),
|
||||
depth: 0,
|
||||
draft: false,
|
||||
user: advisor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow(/não tem permissão/i)
|
||||
})
|
||||
|
||||
it('blocks a leader from creating a feed', async () => {
|
||||
const leader = await campaignFixtures().createCampaignUser('leader')
|
||||
|
||||
await expect(
|
||||
createCalendarFeedLinkRecord(payload, leader, {
|
||||
label: 'Não permitido',
|
||||
}),
|
||||
).rejects.toThrow(/apenas a equipe da campanha/i)
|
||||
})
|
||||
|
||||
it('refuses an advisor revoking a feed they do not own', async () => {
|
||||
const advisor = await campaignFixtures().createCampaignUser('advisor')
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
const ok = requireOk(
|
||||
await createCalendarFeedLinkRecord(payload, coordinator, { label: 'Do coordenador' }),
|
||||
)
|
||||
campaignFixtures().own('calendarFeed', ok.feedId)
|
||||
|
||||
await expect(revokeCalendarFeedRecord(payload, advisor, ok.feedId)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('never exposes the secret slug to a staff read', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
const ok = requireOk(
|
||||
await createCalendarFeedLinkRecord(payload, coordinator, { label: 'Segredo' }),
|
||||
)
|
||||
campaignFixtures().own('calendarFeed', ok.feedId)
|
||||
|
||||
const feed = await payload.findByID({
|
||||
collection: 'calendarFeed',
|
||||
id: ok.feedId,
|
||||
depth: 0,
|
||||
user: coordinator,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect((feed as unknown as CalendarFeedWithSecret).secretSlug).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue