Merge pull request #473 from fsolla/C93-gerar-link-de-import-sem-filtros-agenda-do-escopo

feat(C93): allow generating calendar import link without filters
This commit is contained in:
Francisco Solla 2026-08-09 09:16:39 -03:00 committed by GitHub
commit 76915b9410
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 325 additions and 39 deletions

View file

@ -4,11 +4,14 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-09):** **C93** (Issue #437) — botão "Link de import" da agenda destravado **sem filtros** (sucessor direto do C16): com zero filtros ativos, staff gera o link do feed **completo dentro do escopo de leitura do criador** — coordinator/candidate = tudo; advisor = portfólio dos municípios que administra — com fail-closed/revogação de C16 intactos. O servidor já cobria o caso (vazio = escopo do criador em `buildFeedWhere`/`resolveFeedCreatorAccess`; descrição do campo `filterMunicipality`); a entrega removeu o gate `hasFilters → disabled` de `CalendarFeedButton` (prop morto `state` incluído), manteve `hasFilters` só no "Limpar filtros" e neutralizou a copy do diálogo ("este recorte" → "a agenda"). Testes: 3 int de conteúdo sem filtro (coordinator = A+B; advisor = só o portfólio, nada fora vaza; advisor sem município → feed vazio fail-closed) + unit component (botão habilitado com zero filtros e fluxo nomear→criar). Sem migration, access, Consent ou schema. Intenção [`c93-gerar-link-import-sem-filtros.md`](plans/c93-gerar-link-import-sem-filtros.md); impl [`c93-gerar-link-import-sem-filtros-impl.md`](plans/c93-gerar-link-import-sem-filtros-impl.md).
**Recently resolved (2026-08-09):** **B176** (Issue #458) — `/campanha/municipios` ganhou três dimensões de filtro na omnibox — **Dobradinha** (nome do contato), **Liderança** (nome do contato) e **Partido** (sigla) — com recortes "Sem dobradinha"/"Sem liderança"/"Sem partido" (precedente "Sem nível"). Params aditivos `stateDeputy`/`leadership`/`party` na URL congelada (sentinelas `sem_dobradinha`/`sem_lideranca`, reuso de `sem_partido`); `buildMunicipalityListWhere` ganhou um catálogo request-scoped fail-closed (`municipalityRelationSets.ts`, lazy: metade liderança só com filtro ativo) que vira as leituras reversa (`leadership.municipalities`) e cruzada (`stateDeputy.party`) em `where` — sem migration, paginação nativa mantida; facets estendidas (`stateDeputyIDs`/`leadershipOptions`/`parties`) e mapa compartilha o catálogo. **Coluna Dobradinhas liberada ao Assessor** (staff-wide): servidor já estava scoped (B37/B157 `reloadStaffActor` + `canUpdateMunicipality`), a mudança foi só de gate de UI (lista/mobile/picker) + catálogo full-staff para a célula (decisão do gate humano 2026-08-09). Extrato do par "sentinela + `exists:false`" em `campaignListUrl` (`NO_PARTY_FILTER_VALUE`, `splitAbsenceFilterValues`, `collapseListWhereOrBranches`) com 5 call sites. E2E: assessor vê/edita a coluna no portfólio; filtros pelas três dimensões; teste de coluna-restrita invertido. Intenção [`filtros-municipios-dobradinha-lideranca.md`](plans/filtros-municipios-dobradinha-lideranca.md); impl [`filtros-municipios-dobradinha-lideranca-impl.md`](plans/filtros-municipios-dobradinha-lideranca-impl.md).
**Recently resolved (2026-08-09):** **B174 reverso** (sem Issue — pedido direto) — a Sollinha (`/campanha/api/ai-chat`) voltou do `@ai-sdk/deepinfra` para o **`@ai-sdk/deepseek`** (API oficial `api.deepseek.com`): o cache-hit do V4-Flash direto custa **$0.0028/M** contra **$0.018/M** na Deep Infra (~6,4× mais barato), e a Sollinha tem prefixo estável (system prompt + tools + histórico) — o hit ratio alto derruba o custo efetivo mesmo com miss ($0.14 vs $0.09) e output ($0.28 vs $0.18) mais caros. Mesmo modelo (`deepseek-v4-flash`), mesmas tools; `@ai-sdk/deepseek` **pinado em `3.0.19`** (não `^3.0.19`, que flutuaria a 3.0.26 e puxaria a ferroviária `provider@4.0.7` duplicada — o lock ficou na `4.0.4/5.0.18` do `ai@7.0.47`, verificado no lockfile). **STT fica na Deep Infra** (B173): `whisper-large-v3` a $0.00045/min de áudio é o mais barato verificado (Groq $0.0019, OpenAI $0.006); `.env.example` agora documenta **as duas chaves** — `DEEPSEEK_API_KEY` (LLM) e `DEEPINFRA_API_KEY` (STT). Aviso da DeepSeek: aumento de preço anunciado ("em breve"). Sem migration, access, UI ou teste novo; smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar.
**Recently resolved (2026-08-09):** **OPS20** (Issue #464) — o e2e de cada worktree agora usa a porta do slot próprio (`3100+slot`), nunca o fallback `3000`: o `.env.test.local` (que o `playwright.config.ts` lê em `playwright.config.ts:14`) passou a carregar `PLAYWRIGHT_BASE_URL` — antes ele só ia para o `.env.local` (que o config **não** lê), então dois `pnpm test:e2e` em worktrees paralelos subiam dois dev servers na porta `3000`. Causa-raiz tratada além do sintoma: os dois blocos env inline do `provision` (que já tinham derivado uma vez) viraram um builder puro único `worktreeEnvFileContents` em `scripts/lib/worktree-env.mjs` que deriva `NEXT_PUBLIC_SITE_URL` + `PLAYWRIGHT_BASE_URL` do mesmo `env.devPort` para **ambos** `.env.local`/`.env.test.local` (paridade estrutural, drift impossível) + unit tests de paridade em `tests/unit/worktree.unit.spec.ts` (falham se o drift voltar). `playwright.config.ts` intocado (a variável certa já existia; só faltava chegar). Sem Docker (fallback) continua degradando ao compartilhado — fora de escopo. Sem migration. Intenção [`porta-e2e-isolada-por-worktree.md`](plans/porta-e2e-isolada-por-worktree.md); impl [`porta-e2e-isolada-por-worktree-impl.md`](plans/porta-e2e-isolada-por-worktree-impl.md).
**Recently resolved (2026-08-08):** **B174** (Issue #449) — a Sollinha (`/campanha/api/ai-chat`) trocou o provider de `@ai-sdk/deepseek` (API oficial `api.deepseek.com`) pelo **`@ai-sdk/deepinfra`** no caminho OpenAI-compatível da Deep Infra — **mesmo modelo** (`deepseek-ai/DeepSeek-V4-Flash`), **mesmas tools** e **mesma chave** que os agentes do time usam (um provedor, uma chave, uma fatura). Swap de 2 linhas no `route.ts`; dep `@ai-sdk/deepinfra@3.0.20` pinada na mesma ferroviária do `ai@7.0.47` (provider 4.0.4 / provider-utils 5.0.18, `pnpm why` confirma cópia única — nada de 4.0.7 duplicado), `@ai-sdk/deepseek` removido (dead dep, knip); `.env.example` `DEEPSEEK_API_KEY` → `DEEPINFRA_API_KEY` (B173 STT/Whisper reusa a mesma chave). Sem migration, access, UI ou teste novo — e2e já mocka o SSE (sem chave real no CI); rollback documentado = `git revert` (restaura provider/baseURL antiga); smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar o flip. Intenção [`sollinha-no-deepinfra.md`](plans/sollinha-no-deepinfra.md); impl [`sollinha-no-deepinfra-impl.md`](plans/sollinha-no-deepinfra-impl.md).
**Recently resolved (2026-08-08):** **B170** (Issue #441, follow-up B169) — o **contrato do colapso de chips virou spec unitário** (`tests/unit/relationChipCellCollapse.unit.spec.tsx`): `RelationChipCell` renderiza 16 chips com `getBoundingClientRect` stubado (4/linha, 20px, gap 6 — a matemática real de `COLLAPSED_CHIP_ROWS`/`CHIP_GAP_PX`) e o spec pina que o slice fica em 12 (3 linhas), "Ver mais…" expande para 16 e "Ver menos" recolhe para 12; casos tudo-cabe (8) e exatamente-cheio (12) não mostram toggle. Antes, os specs das células usavam `measureOverflow: false` ou colapsavam para 1 chip — a mudança de timing do B169 (medição em `useLayoutEffect`) não tinha regressão do comportamento de colapso. **F2 medido e deferido em vez de eliminado**: `PerformanceObserver('layout-shift')` no worktree confirma o resíduo pré-hidratação exatamente como registrado no B169 — load SSR→hidratação com shifts únicos `0.139/0.043/0.257` (fontes: shell — sidebar/Drawer/main/col-resize — nenhuma célula de chips) e a linha da célula indo `97px/42chips → 57px/0chips` numa única transição; **ordenação client-side (RSC) → zero shifts novos**. Eliminar a paint pré-hidratação exigiria estimativa de largura por label (rejeitada pelo dono, que mede layout real de propósito) ou mudança de layout/regra de colapso (anti-goal) — o shift único do primeiro load é aceitável num painel interno staff. Sem migration, dados, access ou mudança visual. Intenção [`estabilizar-lista-dobradinhas-followup.md`](docs/plans/estabilizar-lista-dobradinhas-followup.md); impl [`estabilizar-lista-dobradinhas-followup-impl.md`](docs/plans/estabilizar-lista-dobradinhas-followup-impl.md).

View file

@ -0,0 +1,86 @@
# Impl: C93 — Gerar link de import sem filtros (agenda completa do escopo)
Status: aprovado
Atualizado em: 2026-08-09
Issue: #437
Intenção: docs/plans/c93-gerar-link-import-sem-filtros.md
Appetite restante: ~0,5 dia eng (herdado; entrega menor que o appetite)
## Leitura da intenção
- **Outcome:** com **nenhum filtro ativo**, o botão "Link de import" permite gerar o link; o feed sem filtro cobre a agenda completa **dentro do escopo de leitura do criador** (coordinator/candidate = tudo; advisor = municípios administrados); revogar/listar e o endpoint de leitura seguem fail-closed (invariante de C16).
- **O que NÃO negociar:** fail-closed no read do feed (segredo não vaza / criador desativado → para de servir); escopo do criador preservado no feed "sem filtro" (não é backdoor); líder lockdown; sem PII/Consent novo; revogação intacta.
- **O que reavaliar:** a hipótese da intenção ("construir feed sem filtros = só o escopo do criador" em `calendarFeed.ts`/action). **Explorando o código, o servidor já cobre o caso sem filtro por inteiro** — o único bloqueio real é o gate de UI `hasFilters → disabled`. Isso muda o formato da entrega: unlock de UI + testes que provam o aceite de conteúdo (que hoje não existem), sem tocar schema/server.
## Abordagem recomendada
```mermaid
flowchart LR
A[ActivityAgendaFilters] -- para de passar hasFilters/state --> B[CalendarFeedButton]
B -- sempre habilitado, copy neutra --> C[diálogo nomear→copiar→revogar]
C --> D[createCalendarFeedLink com filtros todos vazios]
D --> E[servidor: filterMunicipality vazio = escopo do criador — já pronto]
F[int test: feed conteúdo sem filtro] --> G[coordinator=tudo / advisor=portfolio]
```
**Opções consideradas:**
- **A. Unlock de UI + testes de conteúdo (recomendada):** remover o gate `hasFilters` e o prop morto `state` de `CalendarFeedButton`; manter `hasFilters` apenas para o botão "Limpar filtros"; ajustar copy neutra no diálogo; adicionar int tests (feed sem filtro respeita o escopo do criador) e um unit component test (botão habilitado sem filtros).
- **B. Médio:** como A, mas sem remover o prop `state` de `CalendarFeedButton` (mantendo `_state` morto "para o C94"). Deixa código morto num arquivo que já estamos editando.
- **C. Servidor explícito ("all" marker):** adicionar uma flag/breakpoint no schema/action tipo `scope: 'all'` para "sem filtro". Internet do work: o vazio **já significa** "tudo no escopo" (descrição do campo `filterMunicipality` na collection + `buildFeedWhere`/`resolveFeedCreatorAccess`); inventar uma representação paralela é adicionar superfície sem ganho.
**Recomendação: A** — porque mantém o outcome da intenção com a menor superfície: o servidor já está pronto (decisão de C16), o bloqueio é puramente o `disabled` do botão; remove código morto (`state: _state`, `hasFilters` feed-only) no caminho; e os testes novos fecham a lacuna de verificação do aceite de conteúdo — hoje nenhum teste prova que um feed **sem filtro** serve a agenda completa do escopo (os int atuais só cobrem create/revoke/access, e o unit só o `generateICalFeed`).
**Rejeitadas:** B porque deixa `state: _state` morto num arquivo que editamos (C94 reconstroi a localização do controle de qualquer forma — o prop não é necessário como ponte); C porque inventa representação paralela ao contrato existente de C16 ("vazio = escopo do criador") e infla o appetite sem demand de produto.
### Componentes / mudanças
> **Reconciliação com o C94 (merge em main durante a execução):** o C94 moveu o
> "Link de import" do card de filtros para o header do app (ícone) + FAB mobile,
> e o gate `hasFilters → disabled` passou a viver como `canGenerate`
> (`page.tsx` → `AgendaFeedChrome` → `CalendarFeedDialog`), com o C94 documentando
> explicitamente "C93 cobre gerar sem filtro". A execução final removeu o gate
> **no estado final do C94**: o `CalendarFeedButton` antigo foi apagado (deleção
> do C94 mantida; as mudanças C93 nele tornaram-se obsoletas) e o gate virou
> remoção estrutural do `canGenerate` — sem knob morto com um único estado.
- **`agenda/page.tsx`**: removido `canGenerateFeed` e o prop `canGenerate` do `AgendaFeedChrome` — staff sempre pode gerar (zero filtros = escopo do criador no servidor).
- **`AgendaFeedChrome`** (`src/components/campaign/activity/AgendaFeedChrome.tsx`): removido o prop `canGenerate`; ícone do header sem `disabled`/tooltip condicional.
- **`CalendarFeedDialog`** (`src/components/campaign/activity/CalendarFeedDialog.tsx`): removido o prop `canGenerate` (guard do create, aviso "Aplique filtros…" e `disabled` do "Gerar link"); copy neutra ("este recorte" → "a agenda").
- **`activityQuickActions.ts`**: copy do FAB "Link de import" neutra (recorte → agenda).
- **Migration:** sem migration (UI/copy apenas; schema inalterado).
- **Access / Consent:** sem mudança — servidor e access de C16/C96 intactos; sem PII/Consent novo.
- **UI:** Impeccable A — encaixe nos controles do C94 (ícone header + FAB + diálogo) sem rearranjo.
### Testes
- **Int** (`tests/int/calendarFeed.int.spec.ts`, adicionar 2 testes, padrão `validActivityInput`/`stub<ActivityCreateData>` de `campaignActivity.int.spec.ts`):
1. _Coordinator cria feed sem filtros e o feed cobre todas as municipali­dades do escopo:_ atividades `confirmado` amanhã (janela 90/365 dias) nas municipali­dades A e B → feed só com `label` → `resolveFeedCreatorAccess` = `{ accessible: true, municipalityIds: null }` → `loadFeedActivities` retorna atividades de A **e** B.
2. _Advisor cria feed sem filtros e o feed cobre só as municipali­dades que administra:_ advisor administra A; atividades em A e B → `resolveFeedCreatorAccess` = `{ accessible: true, municipalityIds: [A] }` → `loadFeedActivities` retorna só a de A; a de B **nunca** vaza.
- **Unit component** (`tests/unit/calendarFeedDialog.unit.spec.tsx`, novo, Testing Library): o diálogo do C94 abre o formulário de criação **sem gate de filtros** (sem aviso "Aplique filtros…") e cria o feed com apenas o label via `onCreateFeed` — aceite C93 no estado final do C94.
## Fases verificáveis
1. **UI unlock (estado final do C94)** — `page.tsx` + `AgendaFeedChrome` + `CalendarFeedDialog` + `activityQuickActions` (remoção do gate `canGenerate` + copy neutra).
2. **Testes** — int de conteúdo de escopo (coordinator/advisor, sem filtro) + unit component do diálogo sem gate.
3. **Gates** — `pnpm gate:fast` (lint/tsc/unit), `pnpm test` (unit+int), `pnpm build` local.
## Rabbit holes / Não escopo (engenharia)
- **C94 (botão vira ícone no header/FAB)** — **já mergeado em main durante a execução**; a entrega reconciliou com o estado final (remoção estrutural do `canGenerate`), sem rearranjo de layout.
- **Representação explícita "all" no schema/action** — corte: vazio já é o contrato de C16; sem superfície nova.
- **Prompt de confirmação "tudo vs recorte"** — produto decidiu: feed sem filtro é direto.
- **Refactor de `hasFilters` geral / copy do placeholder do label** — fora do escopo (sem regressão visual; placeholder segue válido).
## Riscos e mitigação
- **Regressão em consumers do `CalendarFeedButton`:** único call site é `ActivityAgendaFilters` (grep); TS pega qualquer outro.
- **Int test depender de janela deslizante (90/365 dias):** usar `startAt` amanhã (padrão `validActivityInput`) — sempre dentro da janela.
- **Contaminação entre testes (atividades/municípios):** fixtures alocam município único via sequência e limpam `activity`/`touchedMunicipalities`; atividades criadas via `payload.create` + `fixtures().own('activity', …)`.
- **`filterDeputyPresent: false` gravado quando sem filtro:** semanticamente vazio para o leitor (`if (feed.filterDeputyPresent)`), sem efeito no conteúdo — já coberto pelo int test de conteúdo.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (botão habilitado sem filtros; feed = escopo do criador; fail-closed/revogação intactos)
- [ ] Invariantes AGENTS/engineering-standards (identificadores EN, copy pt-BR, sem toque em schema/access de C16)
- [ ] Testes: int de conteúdo de escopo sem filtro (coordinator=tudo, advisor=portfolio) + unit do botão habilitado
- [ ] `pnpm gate:fast` + `pnpm test` + `pnpm build` verdes

View file

@ -53,8 +53,6 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
const accessibleHref = buildActivityAgendaHref(state)
if (accessibleHref !== resolvedUrl.href) redirect(accessibleHref)
const canGenerateFeed = Boolean(state.municipality || state.deputyPresent || state.tag)
return (
<CampaignPageShell className="gap-6">
<AgendaFeedChrome
@ -63,7 +61,6 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
label: f.label,
createdAt: f.createdAt,
}))}
canGenerate={canGenerateFeed}
onCreateFeed={async (label) => {
'use server'
return createCalendarFeedLink({

View file

@ -14,23 +14,17 @@ import { Button } from '@/components/ui/button'
type AgendaFeedChromeProps = {
feeds: CalendarFeedSummary[]
/** Whether the current agenda recorte has filters (feed gate, C93 handles "sem filtros"). */
canGenerate: boolean
onCreateFeed: (label: string) => Promise<CreateCalendarFeedResult>
onRevokeFeed: (feedId: number) => Promise<{ ok: boolean }>
}
/**
* Agenda page → app chrome bridge for the calendar-feed surface (C94):
* registers the desktop header icon (agenda-contextual), exposes the sheet to
* the mobile FAB quick action and owns the single shared dialog instance.
* Agenda page → app chrome bridge for the calendar-feed surface (C94, gate
* unlocked by C93): registers the desktop header icon (agenda-contextual),
* exposes the sheet to the mobile FAB quick action and owns the single shared
* dialog instance.
*/
export const AgendaFeedChrome = ({
feeds,
canGenerate,
onCreateFeed,
onRevokeFeed,
}: AgendaFeedChromeProps) => {
export const AgendaFeedChrome = ({ feeds, onCreateFeed, onRevokeFeed }: AgendaFeedChromeProps) => {
const { setContext } = useCampaignQuickActionContext()
const [open, setOpen] = useState(false)
const openFeed = useCallback(() => setOpen(true), [])
@ -50,14 +44,13 @@ export const AgendaFeedChrome = ({
size="icon"
className="size-11 hidden shrink-0 md:inline-flex"
aria-label="Link de import"
title={canGenerate ? 'Link de import' : 'Aplique filtros para gerar um link de import'}
disabled={!canGenerate}
title="Link de import"
onClick={openFeed}
>
<CalendarIcon className="size-5" aria-hidden />
</Button>
),
[canGenerate, openFeed],
[openFeed],
)
return (
@ -68,7 +61,6 @@ export const AgendaFeedChrome = ({
open={open}
onOpenChange={setOpen}
feeds={feeds}
canGenerate={canGenerate}
onCreateFeed={onCreateFeed}
onRevokeFeed={onRevokeFeed}
/>

View file

@ -38,8 +38,6 @@ type CalendarFeedDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
feeds: CalendarFeedSummary[]
/** Whether the current agenda recorte can generate a feed (has filters). */
canGenerate: boolean
onCreateFeed: (label: string) => Promise<CreateCalendarFeedResult>
onRevokeFeed: (feedId: number) => Promise<{ ok: boolean }>
}
@ -47,13 +45,14 @@ type CalendarFeedDialogProps = {
/**
* Calendar feed dialog (nomear → copiar → revogar). Shared by the desktop
* header icon and the mobile FAB quick action — rendered once per host, opened
* by either trigger. Bottom sheet on mobile, dialog on desktop (C94).
* by either trigger. Bottom sheet on mobile, dialog on desktop (C94). Always
* enabled for staff (C93): with zero filters the feed covers the creator's
* full read scope.
*/
export const CalendarFeedDialog = ({
open,
onOpenChange,
feeds,
canGenerate,
onCreateFeed,
onRevokeFeed,
}: CalendarFeedDialogProps) => {
@ -65,7 +64,7 @@ export const CalendarFeedDialog = ({
const [copied, setCopied] = useState(false)
const handleCreate = async () => {
if (!label.trim() || !canGenerate || isCreating) return
if (!label.trim() || isCreating) return
setIsCreating(true)
setError(null)
const result = await onCreateFeed(label.trim())
@ -154,12 +153,6 @@ export const CalendarFeedDialog = ({
</div>
) : (
<div className="space-y-4">
{!canGenerate ? (
<p className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">
Aplique filtros à agenda para gerar um link de import.
</p>
) : null}
<div className="space-y-2">
<label htmlFor="feed-label" className="text-sm font-medium">
Nome do feed
@ -211,11 +204,7 @@ export const CalendarFeedDialog = ({
Cancelar
</Button>
) : null}
<Button
type="button"
onClick={handleCreate}
disabled={!label.trim() || !canGenerate || isCreating}
>
<Button type="button" onClick={handleCreate} disabled={!label.trim() || isCreating}>
{isCreating ? 'Gerando...' : 'Gerar link'}
</Button>
</div>
@ -231,8 +220,8 @@ export const CalendarFeedDialog = ({
<DrawerHeader className="text-left">
<DrawerTitle>Sincronizar com Google Calendar</DrawerTitle>
<DrawerDescription>
Gere um link de import para sincronizar este recorte da agenda com o Google Calendar.
Alterações em Teqo serão refletidas automaticamente.
Gere um link de import para sincronizar a agenda com o Google Calendar. Alterações em
Teqo serão refletidas automaticamente.
</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto px-4 pb-2">{body}</div>
@ -250,8 +239,8 @@ export const CalendarFeedDialog = ({
<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.
Gere um link de import para sincronizar a agenda com o Google Calendar. Alterações em
Teqo serão refletidas automaticamente.
</DialogDescription>
</DialogHeader>
{body}

View file

@ -37,7 +37,7 @@ const listQuickActions = (context: CampaignQuickActionContext): readonly Campaig
id: 'import-calendar',
label: 'Link de import',
icon: Calendar,
description: 'Gerar link para sincronizar este recorte da agenda com o Google Calendar',
description: 'Gerar link para sincronizar a agenda com o Google Calendar',
onAction: context.openCalendarFeed,
},
]

View file

@ -357,4 +357,135 @@ describe('campaign calendar feed domain', () => {
const activities = await loadFeedActivities(payload, feed, access.municipalityIds)
expect(activities.map((doc) => doc.id)).toContain(activity.id)
})
it('covers every municipality of the coordinator scope in a filterless feed (C93)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipalityA = await fixtures.getMunicipality()
const municipalityB = await fixtures.getMunicipality()
const activityInA = await createActivityRecord(
payload,
coordinator,
validActivityInput(municipalityA.id, fixtures.value('Atividade A')),
)
fixtures.own('activity', activityInA.id)
const activityInB = await createActivityRecord(
payload,
coordinator,
validActivityInput(municipalityB.id, fixtures.value('Atividade B')),
)
fixtures.own('activity', activityInB.id)
const ok = requireOk(
await createCalendarFeedLinkRecord(payload, coordinator, { label: 'Agenda completa' }),
)
fixtures.own('calendarFeed', ok.feedId)
const feed = await payload.findByID({
collection: 'calendarFeed',
id: ok.feedId,
depth: 0,
overrideAccess: true,
})
expect(feed.filterMunicipality).toBeFalsy()
const access = await resolveFeedCreatorAccess(payload, feed)
expect(access.accessible).toBe(true)
expect(access.municipalityIds).toBeNull()
const activities = await loadFeedActivities(payload, feed, access.municipalityIds)
const ids = activities.map((activity) => activity.id)
expect(ids).toContain(activityInA.id)
expect(ids).toContain(activityInB.id)
})
it('scopes a filterless feed to every municipality the advisor administers (C93)', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipalityA = await fixtures.getMunicipality()
const municipalityB = await fixtures.getMunicipality()
const municipalityC = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipalityA, [advisor.id])
await fixtures.assignMunicipalityAdvisors(municipalityB, [advisor.id])
const activityInScopeA = await createActivityRecord(
payload,
coordinator,
validActivityInput(municipalityA.id, fixtures.value('Atividade administrada A')),
)
fixtures.own('activity', activityInScopeA.id)
const activityInScopeB = await createActivityRecord(
payload,
coordinator,
validActivityInput(municipalityB.id, fixtures.value('Atividade administrada B')),
)
fixtures.own('activity', activityInScopeB.id)
const activityOutOfScope = await createActivityRecord(
payload,
coordinator,
validActivityInput(municipalityC.id, fixtures.value('Atividade fora do escopo')),
)
fixtures.own('activity', activityOutOfScope.id)
const ok = requireOk(
await createCalendarFeedLinkRecord(payload, advisor, { label: 'Meu portfólio' }),
)
fixtures.own('calendarFeed', ok.feedId)
const feed = await payload.findByID({
collection: 'calendarFeed',
id: ok.feedId,
depth: 0,
overrideAccess: true,
})
const access = await resolveFeedCreatorAccess(payload, feed)
expect(access.accessible).toBe(true)
const advisorMunicipalities = access.municipalityIds as number[]
expect(advisorMunicipalities).toHaveLength(2)
expect(advisorMunicipalities).toEqual(
expect.arrayContaining([municipalityA.id, municipalityB.id]),
)
const activities = await loadFeedActivities(payload, feed, advisorMunicipalities)
const ids = activities.map((activity) => activity.id)
expect(ids).toContain(activityInScopeA.id)
expect(ids).toContain(activityInScopeB.id)
expect(ids).not.toContain(activityOutOfScope.id)
})
it('serves an empty filterless feed when the advisor administers no municipality (C93)', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const coordinator = await fixtures.createCampaignUser('coordinator')
const unadministered = await fixtures.getMunicipality()
const activity = await createActivityRecord(
payload,
coordinator,
validActivityInput(unadministered.id, fixtures.value('Nunca servida')),
)
fixtures.own('activity', activity.id)
const ok = requireOk(
await createCalendarFeedLinkRecord(payload, advisor, { label: 'Sem municípios' }),
)
fixtures.own('calendarFeed', ok.feedId)
const feed = await payload.findByID({
collection: 'calendarFeed',
id: ok.feedId,
depth: 0,
overrideAccess: true,
})
const access = await resolveFeedCreatorAccess(payload, feed)
expect(access.accessible).toBe(true)
expect(access.municipalityIds).toEqual([])
const activities = await loadFeedActivities(payload, feed, access.municipalityIds)
expect(activities).toHaveLength(0)
})
})

View file

@ -0,0 +1,88 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { CalendarFeedDialog } from '@/components/campaign/activity/CalendarFeedDialog'
import { TooltipProvider } from '@/components/ui/tooltip'
type DialogProps = Parameters<typeof CalendarFeedDialog>[0]
const renderDialog = (overrides: Partial<DialogProps> = {}) => {
const onCreateFeed = vi.fn()
const onRevokeFeed = vi.fn()
render(
<TooltipProvider delayDuration={300}>
<CalendarFeedDialog
open
onOpenChange={vi.fn()}
feeds={[]}
onCreateFeed={onCreateFeed}
onRevokeFeed={onRevokeFeed}
{...overrides}
/>
</TooltipProvider>,
)
return { onCreateFeed, onRevokeFeed }
}
const matchMediaMock = vi.fn()
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
Element.prototype.scrollIntoView = () => {}
})
beforeEach(() => {
matchMediaMock.mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
vi.stubGlobal('matchMedia', matchMediaMock)
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
afterAll(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
})
describe('CalendarFeedDialog (C93 — no filters needed)', () => {
it('opens the naming form without any filter gate', () => {
renderDialog()
const dialog = screen.getByRole('dialog')
expect(within(dialog).getByText('Sincronizar com Google Calendar')).toBeTruthy()
expect(within(dialog).getByLabelText('Nome do feed')).toBeTruthy()
expect(within(dialog).queryByText(/Aplique filtros/)).toBeNull()
})
it('creates the filterless feed through onCreateFeed with the chosen label', async () => {
const FEED_URL = 'https://teqo.dev/campanha/agenda/ical/secret'
const { onCreateFeed } = renderDialog()
onCreateFeed.mockResolvedValue({ ok: true, feedUrl: FEED_URL })
const dialog = screen.getByRole('dialog')
fireEvent.change(within(dialog).getByLabelText('Nome do feed'), {
target: { value: 'Agenda completa' },
})
fireEvent.click(within(dialog).getByRole('button', { name: 'Gerar link' }))
await waitFor(() => expect(onCreateFeed).toHaveBeenCalledWith('Agenda completa'))
await within(dialog).findByDisplayValue(FEED_URL)
})
})