feat(C151): marca atividade como evento público

Adiciona o checkbox publicEvent ("Evento público", default false, indexado)
à collection activity para registrar a intenção de expor o compromisso na
futura agenda pública do site — sem consumo agora e sem mudar agenda, lista,
detalhe ou o espelho do Google.

- Activity.ts: campo checkbox + snapshot do guard de liderança
- zod/formData/view model do form + ActivityOverlay (ao lado de "Todo o dia")
- migration add_activity_public_event + types regenerados
- unit (overlay), int (parse/persistência/reabertura) e e2e (roundtrip)
This commit is contained in:
Francisco Solla 2026-09-12 19:59:19 -03:00
parent bf4a4d9807
commit 6d676609cd
14 changed files with 14364 additions and 0 deletions

View file

@ -0,0 +1 @@
**C151 — Atividade: marcar evento como público (2026-09-12):** o staff agora marca/desmarca "Evento público" no modal de atividade (criar e editar), uma flag durável e consultável que registra a INTENÇÃO de expor o compromisso na futura agenda pública do site — a exposição em si é item PUB posterior. Campo `publicEvent` (checkbox, default `false`, indexado) na collection `activity` existente, com o mesmo padrão de `deputyPresent`/`allDay`; migration `20260912_224719_add_activity_public_event`. O parser de FormData, o zod de entrada, o `ActivityFormViewModel`/`activityFormSelect` e o `ActivityOverlay` (checkbox ao lado de "Todo o dia", default desmarcado, volta correto ao reabrir) acompanham o campo; o snapshot do guard de liderança em `Activity.ts` inclui a flag (defesa em profundidade). Nada no site público, na agenda, na lista, no detalhe ou no espelho do Google (C114) muda: `publicEvent` fica fora de `SYNC_RELEVANT_ACTIVITY_FIELDS` e dos selects de agenda/lista/detalhe, sem filtro/badge novo. Testes: unit do overlay (render, submit e prefill), int de parse e persistência/reabertura, e o e2e `campaignActivity` cobre marcar→salvar→reabrir. Plano: `docs/plans/c151-atividade-evento-publico-impl.md`.

View file

@ -0,0 +1,116 @@
# Impl: Atividade: marcar evento como público (base para exposição no site)
Status: aprovado
Atualizado em: 2026-09-12
Issue: #936
Intenção: docs/plans/c151-atividade-evento-publico.md
Appetite restante: herdado (~0,5 dia eng) — cabe sem corte.
## Leitura da intenção
- **Outcome:** o staff marca/desmarca "Evento público" no modal de atividade; a marcação é
durável, volta correta ao reabrir e fica consultável — sem nenhuma tela mudar de comportamento.
- **O que NÃO negociar:** exposição pública é item futuro (PUB), nada no `(frontend)`; sem filtro/badge
na lista; sem collection paralela; `leader` segue lockdown (não vê o campo); o espelho do Google
(C114) não é influenciado em v1.
- **O que reavaliar:** a hipótese do plano de intenção ("a marcação vive na atividade existente")
confirma-se — nenhuma surpresa de acoplamento na exploração.
## Abordagem recomendada
```mermaid
flowchart LR
O[ActivityOverlay<br/>checkbox publicEvent] -->|FormData| P[parseSharedActivityFormData]
P --> Z[activityCreate/UpdateSchema]
Z --> A[create/updateActivityRecord]
A --> C[collection activity<br/>publicEvent checkbox]
C -->|migration| DB[(activity.public_event)]
A -.nao inclui.-> G[sync Google C114]
```
**Opções consideradas:** A) campo boolean na `activity` existente (draft) | B) collection/entidade
"evento público" separada | C) global/config paralela.
**Recomendação:** A — a intenção fixa a flag na atividade existente; é um checkbox de negócio, indexado,
sem access especial (quem edita a atividade marca).
**Rejeitadas:** B porque duplica cadastro e cria divergência de dados (anti-goal explícito da intenção);
C porque não há escopo por item — a flag é por atividade.
### Componentes / mudanças
- **`publicEvent`** (`src/collections/Activity.ts`): `type: 'checkbox'`, `label: 'Evento público'`,
`defaultValue: false`, `index: true`, `admin.description` "Quando marcado, este evento poderá ser
publicado no site do candidato (futuro)." — espelha `deputyPresent`/`allDay` (sem `access` de field:
qualquer staff com linha pode marcar).
- **`activityStaffFieldSnapshot`** (`src/collections/Activity.ts`): incluir
`publicEvent: Boolean(doc.publicEvent)` no snapshot do guard de liderança — mantém a defesa em
profundidade (liderança, se algum dia alcançar o write path, não marca campo de staff). Access
`canReadActivity`/`canUpdateActivity` já negam `leader`; isto é só cinto de segurança barato.
- **`src/lib/schemas/activity.ts`**: adicionar `publicEvent: z.boolean().optional()` em
`activityFieldsSchema` (herdado por create/update `.partial()`).
- **`src/utilities/activityFormData.ts`**: `publicEvent: checkboxFormValue(formData, 'publicEvent')`
em `parseSharedActivityFormData` — sem isso o checkbox é silenciosamente descartado no submit.
- **`src/utilities/activityViewModels.ts`**: `ActivityFormViewModel.publicEvent: boolean` +
`activityFormSelect.publicEvent` + `toActivityFormViewModel`. **Não** tocar em
agenda/list/detail selects/view models (nada de badge/coluna/consumo novo).
- **`src/components/campaign/activity/ActivityOverlay.tsx`**: checkbox "Evento público" na
`scheduleSection`, logo após "Todo o dia" (posição do rascunho UI aprovado), `name="publicEvent"`,
`defaultChecked={isEdit ? editDraft?.publicEvent : false}`, `aria-label="Evento público"`.
- **Migration:** `pnpm migrate:create add_activity_public_event` — copia exata do precedente
`20260810_010844_add_activity_all_day` (`ADD COLUMN "public_event" boolean DEFAULT false` +
índice btree `activity_public_event_idx`). Depois `pnpm generate:types` (`src/payload-types.ts`).
- **Google Calendar (C114):** `SYNC_RELEVANT_ACTIVITY_FIELDS` **não** recebe `publicEvent` — decisão
v1 da intenção; editar só a flag não re-dispara sync nem carimba `lastMirroredChangeAt`.
- **UI:** Impeccable B (encaixe no modal). Reusa `Checkbox` + `Field` do próprio overlay; sem seção
nova, sem shell novo. Shape já aprovado no rascunho `c151-atividade-evento-publico-ui-draft.html`.
### Dados → forma (se aplicável)
- Não aplicável: nenhuma superfície de dados neste item (a intenção já decidiu "não apresentar dados").
## Fases verificáveis
1. **Tracer schema+server** — campo + migration + zod + parser + select/view model do form;
int test prova persistência e reabertura (`loadActivityEditDraftRecord` devolve `publicEvent`).
2. **UI** — checkbox no `scheduleSection`; unit test do overlay (render no create e no edit, submit
manda `publicEvent=on` só quando marcado).
3. **Gates** — `pnpm gate:fast`; `pnpm test` (unit+int) na entrega; push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Expor no site público, agenda pública, SEO ou share kit (item PUB futuro).
- Badge/coluna/filtro na lista de atividades, na agenda ou no detalhe.
- Ligar a flag ao espelho do Google (C114) — decidido fora em v1.
- Tornar `publicEvent` system field (`canSetActivitySystemField`) ou restricted access — não pedido.
- Marcação em lote / spreadsheet mode.
## Riscos e mitigação
- **Risco:** checkbox silenciosamente descartado no submit se o parser/zod não acompanhar. **Mitigação:**
unit test do overlay + int test de parse/persistência.
- **Risco:** a flag vazar para abas/visões como comportamento novo. **Mitigação:** mudanças restritas a
form select/view model; nenhum agenda/list/detail select alterado.
- **Risco:** migration sem regenerar tipos → erro de `tsc`. **Mitigação:** `migrate:create` +
`generate:types` + `pnpm typecheck` no gate.
## Débitos triados (simplify)
- **Já resolvido:** o `migrate:create` arrastou drift de `supporter_import_batch.actor_id`
(snapshot stale dos hand-written 20260824) com `down` rollback-inseguro — removido, a migration
só mexe em `public_event`; changelog da entrega criado; racional C126 do `farFuture` no int;
status do plano finalizado.
- **Descartado (YAGNI):** índice parcial em vez de btree pleno para um boolean de 2 valores — sem
medição que justifique; a intenção pediu "indexado" e o precedente `all_day` usa btree pleno.
- **Defer (gatilho):** extrair um `OverlayCheckboxField` local para os três checkboxes idênticos do
overlay (`deputyPresent`/`allDay`/`publicEvent`) — só quando um 4º checkbox ou mudança real de UX
no bloco justificar.
- **Defer (gatilho):** teste de regressão pinando `publicEvent` fora de `SYNC_RELEVANT_ACTIVITY_FIELDS`
e dos selects de agenda/lista — hoje a type honesty (view models sem o campo) cobre agenda/lista/
detalhe; adicionar o pin quando o item PUB ligar a flag a um consumidor.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (marca/desmarca, durável, reabre correto, nada muda)
- [x] Invariantes AGENTS/engineering-standards (leader lockdown, copy pt-BR/identificadores EN)
- [x] Testes de domínio previstos (unit do overlay + int de persistência/parse + e2e de roundtrip)

View file

@ -57,6 +57,7 @@ const activityStaffFieldSnapshot = (doc: Record<string, unknown>) => ({
municipality: relationshipId(doc.municipality),
locality: trimmedText(doc.locality),
deputyPresent: Boolean(doc.deputyPresent),
publicEvent: Boolean(doc.publicEvent),
organizations: relationshipIds(doc.organizations),
responsible: JSON.stringify(
parseActivityResponsibleEntries(doc.responsible)
@ -514,6 +515,21 @@ export const Activity: CollectionConfig = {
description: 'Compromisso que ocupa um ou mais dias inteiros, sem horário.',
},
},
{
// C151 — marks a staff intent to expose this activity as a public event
// in the candidate's website (a future PUB item). The flag is durable and
// consultable; nothing consumes it yet and it does not affect the agenda,
// the list, or the Google Calendar mirror.
name: 'publicEvent',
type: 'checkbox',
label: 'Evento público',
defaultValue: false,
index: true,
admin: {
description:
'Quando marcado, este evento poderá ser publicado no site do candidato (futuro).',
},
},
{
name: 'startAt',
type: 'date',

View file

@ -352,6 +352,20 @@ const ActivityOverlayForm = ({
<span>Todo o dia</span>
</Field>
<Field
orientation="horizontal"
className={
sheet ? 'min-h-11 gap-2 rounded-none border-0 px-0' : 'min-h-11 rounded-lg border p-3'
}
>
<Checkbox
name="publicEvent"
defaultChecked={isEdit ? editDraft?.publicEvent : false}
aria-label="Evento público"
/>
<span>Evento público</span>
</Field>
{sheet ? startEndFields : <div className="grid gap-3 sm:grid-cols-2">{startEndFields}</div>}
</>
)

View file

@ -133,6 +133,7 @@ const activityFieldsSchema = z.object({
description: trimmedOptionalText(4000),
deputyPresent: z.boolean().optional(),
allDay: z.boolean().optional(),
publicEvent: z.boolean().optional(),
startAt: z.string().datetime().optional().nullable(),
endAt: z.string().datetime().optional().nullable(),
municipality: positiveRelationshipId,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "activity" ADD COLUMN "public_event" boolean DEFAULT false;
CREATE INDEX "activity_public_event_idx" ON "activity" USING btree ("public_event");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP INDEX "activity_public_event_idx";
ALTER TABLE "activity" DROP COLUMN "public_event";`)
}

View file

@ -65,6 +65,7 @@ import * as migration_20260823_161905_add_municipality_update_deliberation from
import * as migration_20260824_000000_reconcile_allocation_decision_rationale_nullable from './20260824_000000_reconcile_allocation_decision_rationale_nullable';
import * as migration_20260824_010000_make_supporter_import_batch_actor_nullable from './20260824_010000_make_supporter_import_batch_actor_nullable';
import * as migration_20260912_052226_add_google_calendar_oauth_connection from './20260912_052226_add_google_calendar_oauth_connection';
import * as migration_20260912_224719_add_activity_public_event from './20260912_224719_add_activity_public_event';
export const migrations = [
{
@ -402,4 +403,9 @@ export const migrations = [
down: migration_20260912_052226_add_google_calendar_oauth_connection.down,
name: '20260912_052226_add_google_calendar_oauth_connection'
},
{
up: migration_20260912_224719_add_activity_public_event.up,
down: migration_20260912_224719_add_activity_public_event.down,
name: '20260912_224719_add_activity_public_event'
},
];

View file

@ -776,6 +776,10 @@ export interface Activity {
* Compromisso que ocupa um ou mais dias inteiros, sem horário.
*/
allDay?: boolean | null;
/**
* Quando marcado, este evento poderá ser publicado no site do candidato (futuro).
*/
publicEvent?: boolean | null;
startAt?: string | null;
endAt?: string | null;
municipality: number | Municipality;
@ -1915,6 +1919,7 @@ export interface ActivitySelect<T extends boolean = true> {
description?: T;
deputyPresent?: T;
allDay?: T;
publicEvent?: T;
startAt?: T;
endAt?: T;
municipality?: T;

View file

@ -163,6 +163,7 @@ const parseSharedActivityFormData = (formData: FormData) => {
description: optionalFormText(formData, 'description'),
deputyPresent: checkboxFormValue(formData, 'deputyPresent'),
allDay,
publicEvent: checkboxFormValue(formData, 'publicEvent'),
startAt: parseScheduleFormField(formData, 'startAt', allDay),
endAt: parseScheduleFormField(formData, 'endAt', allDay),
municipality: requiredRelationshipFormValue(formData, 'municipality'),

View file

@ -228,6 +228,7 @@ export const activityFormSelect = {
description: true,
deputyPresent: true,
allDay: true,
publicEvent: true,
startAt: true,
endAt: true,
municipality: true,
@ -253,6 +254,7 @@ export type ActivityFormViewModel = {
description: string | null
deputyPresent: boolean
allDay: boolean
publicEvent: boolean
startAt: string | null
endAt: string | null
municipalityId: number | null
@ -274,6 +276,7 @@ export const toActivityFormViewModel = (activity: Activity): ActivityFormViewMod
description: activity.description ?? null,
deputyPresent: Boolean(activity.deputyPresent),
allDay: Boolean(activity.allDay),
publicEvent: Boolean(activity.publicEvent),
startAt: activity.startAt ?? null,
endAt: activity.endAt ?? null,
municipalityId: relationshipId(activity.municipality),

View file

@ -412,6 +412,8 @@ test.describe('Agenda — calendário operacional', () => {
// regression): saving must actually persist.
await editModal.getByLabel('Hora de Término').selectOption('15')
await editModal.getByLabel('Local (opcional)').fill(newLocality)
// C151 — the public-event intent is marked in the same save.
await editModal.getByLabel('Evento público').click()
await editModal.getByRole('button', { name: 'Salvar alterações' }).click()
// Save closes the overlay, never navigates; the calendar reflects the edit.
@ -426,6 +428,8 @@ test.describe('Agenda — calendário operacional', () => {
await page.getByText(title, { exact: true }).first().click()
const detailModal = page.getByRole('dialog', { name: 'Editar atividade' })
await expect(detailModal.getByLabel('Título *')).toBeVisible({ timeout: 30_000 })
// C151 — the mark survives the save and comes back on reopening the overlay.
await expect(detailModal.getByLabel('Evento público')).toHaveAttribute('aria-checked', 'true')
await detailModal.getByRole('link', { name: 'Ver detalhes' }).click()
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+(?:\?tab=overview)?$/, {
timeout: 30_000,

View file

@ -647,6 +647,57 @@ describe('activity domain', () => {
expect(parsed.endAt).toBe(allDayEndInstant('2026-08-12'))
})
it('parses the public event flag from the activity form (C151)', () => {
const formData = new FormData()
formData.set('title', 'Comício público')
formData.set('status', 'confirmado')
formData.set('municipality', '1')
formData.set('tagsJson', '[]')
formData.set('demandsJson', '[]')
// Unchecked checkbox is absent from FormData — parses as false.
expect(parseActivityCreateFormData(formData).publicEvent).toBe(false)
formData.set('publicEvent', 'on')
expect(parseActivityCreateFormData(formData).publicEvent).toBe(true)
})
it('persists the public event flag and returns it when reopening the overlay (C151)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const [markedMunicipality, ordinaryMunicipality] = await Promise.all([
fixtures.getMunicipality(),
fixtures.getMunicipality(),
])
// Far-future startAt keeps these rows OUTSIDE the Google sync engine's
// 90/365-day scan window, avoiding the C126 parallel-flake class.
const farFuture = new Date(Date.now() + 400 * 86_400_000).toISOString()
const marked = await createActivityRecord(payload, coordinator, {
...validActivityInput(markedMunicipality.id),
title: fixtures.value('Evento público marcado'),
startAt: farFuture,
publicEvent: true,
})
fixtures.own('activity', marked.id)
const ordinary = await createActivityRecord(payload, coordinator, {
...validActivityInput(ordinaryMunicipality.id),
title: fixtures.value('Atividade interna'),
startAt: farFuture,
})
fixtures.own('activity', ordinary.id)
expect(marked.publicEvent).toBe(true)
expect(ordinary.publicEvent).toBe(false)
expect((await loadActivityEditDraftRecord(payload, coordinator, marked.id)).publicEvent).toBe(
true,
)
expect((await loadActivityEditDraftRecord(payload, coordinator, ordinary.id)).publicEvent).toBe(
false,
)
})
it('loads only accessible events matching the agenda range and filters', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')

View file

@ -49,6 +49,7 @@ const EDIT_VIEW_MODEL: ActivityFormViewModel = {
description: 'Panfletagem no centro histórico',
deputyPresent: true,
allDay: false,
publicEvent: true,
startAt: '2026-08-07T16:00:00.000Z',
endAt: '2026-08-07T17:00:00.000Z',
municipalityId: 12,
@ -170,6 +171,7 @@ describe('ActivityOverlay — criação (modal central)', () => {
expect(screen.getByText('Demandas')).toBeTruthy()
expect(screen.getByLabelText('Descrição')).toBeTruthy()
expect(screen.getByLabelText('Deputado presente')).toBeTruthy()
expect(screen.getByLabelText('Evento público')).toBeTruthy()
expect(screen.getByRole('combobox', { name: /Organizações apoiadoras/ })).toBeTruthy()
})
@ -218,6 +220,7 @@ describe('ActivityOverlay — criação (modal central)', () => {
target: { value: 'Café da manhã com lideranças' },
})
fireEvent.click(screen.getByLabelText('Deputado presente'))
fireEvent.click(screen.getByLabelText('Evento público'))
fireEvent.change(screen.getByRole('combobox', { name: 'Minuto de Término' }), {
target: { value: '45' },
})
@ -232,6 +235,7 @@ describe('ActivityOverlay — criação (modal central)', () => {
expect(formData.get('locality')).toBe('Centro histórico')
expect(formData.get('description')).toBe('Café da manhã com lideranças')
expect(formData.get('deputyPresent')).toBe('on')
expect(formData.get('publicEvent')).toBe('on')
expect(formData.get('status')).toBe('confirmado')
expect(formData.get('allDay')).toBeNull()
expect(formData.get('id')).toBeNull()
@ -365,6 +369,7 @@ describe('ActivityOverlay — edição (modal central)', () => {
expect((screen.getByLabelText('Descrição') as HTMLInputElement).value).toBe(
'Panfletagem no centro histórico',
)
expect(screen.getByLabelText('Evento público').getAttribute('aria-checked')).toBe('true')
expect((screen.getByLabelText('Título *') as HTMLInputElement).readOnly).toBe(true)
expect(screen.getByText('Comício')).toBeTruthy()
expect(screen.getByRole('link', { name: 'Ver detalhes' }).getAttribute('href')).toBe(
@ -380,6 +385,7 @@ describe('ActivityOverlay — edição (modal central)', () => {
const formData = mocks.updateOverlay.mock.calls[0][0] as FormData
expect(formData.get('id')).toBe('42')
expect(formData.get('title')).toBe('Comício na feira')
expect(formData.get('publicEvent')).toBe('on')
expect(formData.get('startAt')).toBe('2026-08-07T13:00')
expect(formData.get('endAt')).toBe('2026-08-07T14:30')
expect(onSaved).toHaveBeenCalledTimes(1)