feat(campaign): C165 — importa eventos do Google como atividades sem município (Closes #1010)

This commit is contained in:
Francisco Solla 2026-09-15 11:08:28 -03:00
parent 352801853f
commit 7750c43201
40 changed files with 17656 additions and 204 deletions

View file

@ -0,0 +1 @@
**C165 — Agenda: eventos do Google viram atividades sozinhas (sem município, a partir de 16/08/2026) (2026-09-15):** o espelho (C114/C115/C164) só enxergava eventos `teqo…`; evento criado à mão no calendário conectado era ignorado de propósito. Agora um evento estrangeiro com início na janela do espelho (90/365) e a partir do corte legal (`CALENDAR_PHASE_ANCHORS.consolidationStart` = 16/08/2026, data civil Bahia, sem backfill) vira atividade `confirmado` sozinho, com título verbatim (sem prefixo `[Município] `), `location → locality` e **descrição ignorada** (o texto vive no Google). O vínculo é system-write — `googleEventId` (unique) + `googleCalendarId` na `Activity` (migration `20260915_142058_activity_google_import_link` também derruba o `NOT NULL` de `municipality_id`) — e é ele que garante idempotência: re-sync converge e editar no Teqo usa um `patchEvent` parcial (PATCH, `sendUpdates: 'none'`, sem `description`) no MESMO evento remoto, nunca um segundo `teqo…`; cancelar no Teqo marca `status: cancelled` (nunca DELETE) e uma edição/cancelamento novo no Google volta pela regra de relógio do C115 (o relógio perde → o evento é re-afirmado); exclusão permanente cancela a atividade e atividade hard-deletada não ressuscita (o id fica no snapshot). Access: sem município, só `coordinator`/`candidate` (precedente `supporter`) — Visão/Edição "Tudo" do assessor passam a devolver `municipality exists`, criar/limpar/reapontar é barrado na action e demandas continuam exigindo município (dereferência null-safe). UI (Impeccable B): overlay com "Município (opcional)" e a opção "Sem município" (aviso de visibilidade), cards com badges "Do Google" (âmbar) e "Sem município" (tracejado) + rodapé "Importado automaticamente", marcador na agenda e filtro `unscoped=1` exclusivo com município na omnibox. Sem `Consent` novo, leader lockdown e evento estrangeiro intocados; testes unit (mapping/PATCH/overlay/omnibox), int (11 casos do motor + access/demandas) e e2e pinado; manifest e2e cobre `Activity`/schema/access/mapping.

View file

@ -0,0 +1,68 @@
# C165-F1 — Activity: update access fecha município nulo/fora da carteira (bypass REST)
Status: rascunho
Atualizado em: 2026-09-15
Issue: (registrada junto do PR do C165)
Pai: #1010 (C165) — `depends: [1010]`
Priority: P2
Kind: defect
Appetite: ~0,25 dia eng (sem UI, sem migration)
Responsável: —
> Débito registrado na triage do `/simplify` do C165
> ([c165-importar-eventos-google-impl.md](c165-importar-eventos-google-impl.md)).
## Contexto
O C165 tornou `municipality` opcional na `Activity` e decidiu (Decisão G do impl plan) que o
guard de **valor** do município vive na server action: advisor não cria sem município, não
limpa (`null`) e não reaponta para fora da carteira — cada caso com mensagem nomeada
(`ACTIVITY_UNSCOPED_ADVISOR_MESSAGE` / `ACTIVITY_OUT_OF_SCOPE_MESSAGE`).
O access cobre a **linha** (`canUpdateActivity` devolve um `Where` que alcança a atividade),
não o **valor**: uma escrita que passe direto pelo Local API/REST com credencial de campanha
(ex.: `PATCH /api/activity/:id` com o JWT no header `Authorization`, caminho já declarado no
threat model de `src/utilities/access/shared.ts`) consegue gravar `municipality: null` ou um
id fora da carteira. Na prática o comportamento é simétrico ao lado `create` (que já valida
`data`), e o cookie `campaign-token` é path-scoped `/campanha` — o caminho exigiria JWT
copiado à mão; ainda assim é o eixo de access e fecha por construção.
## Evidência
- `src/utilities/access/activities.ts` — `canCreateActivity` é `data`-aware (C165);
`canUpdateActivity` devolve `Where`/`boolean` sem inspecionar `data.municipality`.
- `src/app/(campaign)/campanha/actions/activity.ts` — guard equivalente no `updateActivityRecord`
(mensagens nomeadas) cobre o caminho da UI.
- `src/utilities/access/shared.ts:100-104` — caminho REST/GraphQL com JWT de campanha no threat model.
## Objetivo e aceite
- `canUpdateActivity`, para advisor (staff não-unrestricted), inspeciona `data.municipality`
quando presente: `null` → `false`; número fora do write scope → `false`; ausente → o `Where`
de linha atual (nada muda para quem só edita outros campos).
- Coordenador/candidato/admin intocados; `editing: 'tudo'` continua alcançando qualquer
atividade **com** município, mas limpar para `null` segue coordenação/candidato-only.
- A action mantém o guard/UX (Decisão G do C165) — defesa em profundidade, sem mensagem nova.
- Pin int com `overrideAccess: false` e um advisor (`campaignActivity.int.spec.ts`): limpar
município e reapontar para fora da carteira são recusados pelo access; editar outro campo
da mesma atividade continua funcionando.
## Fora de escopo
- Field access por **valor** para `municipality` (Payload field access recebe o valor, não o
documento; a assimetria é do próprio modelo — o guard na collection access é o dono).
- Hardening equivalente nas demais collections (decisão sistêmica separada; cada domínio tem
seu dono e seu precedente).
- Mudança de UX/copy/mensagens da action.
## Riscos
- Advisor com `editing: 'tudo'` que hoje limpa município pela action: já era recusado
(C165) — o access só antecipa a mesma recusa em outros caminhos.
- Leitura do perfil fresco no access update (mesmo `getFreshCampaignUser` já usado) mantém
o custo por request dentro do padrão do módulo.
## Aceite de engenharia
- [ ] `pnpm gate:fast` verde; int pinado em `tests/int/campaignActivity.int.spec.ts`.
- [ ] Sem migration, sem Consent, sem UI; URL/slug intactos.

View file

@ -0,0 +1,238 @@
# Impl: Agenda: importar automaticamente eventos do Google como atividades (sem município, a partir de 16/08/2026)
Status: aprovado
Atualizado em: 2026-09-15
Issue: #1010
Intenção: docs/plans/c165-importar-eventos-google.md
Appetite restante: herdado — ~3–4 dias eng
## Leitura da intenção
- **Outcome:** evento criado no Google Calendar (conta da imprensa já conectada) com início ≥ 16/08/2026 vira atividade no Teqo sozinho, com a imediatez Google→Teqo do C164 (mesmo webhook/passada, sem trigger novo). A atividade nasce `confirmado`, sem município, com título original do Google (sem prefixo `[Município] `), `location → locality`, e a coordenação tria no overlay existente quando quiser. Sem município, só `coordinator`/`candidate` veem; ao atribuir município, entram as regras normais (advisor por carteira/responsável). Vínculo estável e idempotente: re-sync não duplica; editar no Teqo atualiza o MESMO evento do Google — nunca nasce um segundo evento `teqo…`. Editar/cancelar no Google reflete (relógio C115); excluir no Google cancela a atividade. Nada antes de 16/08/2026 entra (sem backfill); evento estrangeiro nunca é apagado; leader lockdown intocado; sem `Consent` novo; Teqo inteiro se o Google cair.
- **O que NÃO negociar:** corte em `CALENDAR_PHASE_ANCHORS.consolidationStart` (`src/lib/visitPlannerAnchors.ts:22-29` — não duplicar o literal); horizonte = janela do espelho (90d/365d) com piso do corte; `singleEvents=true` (cada ocorrência vira atividade própria); descrição ignorada na v1; cancelar no Teqo cancela o MESMO evento; importado que ganha município mantém o título sem prefixo; Vínculo system-write (invisível aos user paths); sem Consent; leader lockdown e feed iCal públicos intocados; evento estrangeiro nunca `DELETE`ado pelo Teqo.
- **O que reavaliar (o reconhecimento confirmou as armadilhas):**
- Access: advisor `visibility === 'tudo'` retorna `true` em `canReadActivity` (`src/utilities/access/activities.ts:74-77`) e `editing === 'tudo'` em `canUpdateActivity` (`:99`) — fura "sem município só coordenação/candidato" sem um guard de `municipality != null`; `canCreateActivity` (`:31-39`) é booleano staff e não olha `data`.
- Ação: `src/app/(campaign)/campanha/actions/activity.ts:293-294` dereferencia `activity.municipality.id` incondicionalmente → TypeError ao salvar importado com demandas; `CampaignDemand.ts:242-279` exige município (`:253`) e compara com o da atividade (`:274-278`).
- Forward: o insert sempre nasce com id determinístico `teqo…` (`src/lib/googleCalendarEventMapping.ts:142`) e o update usa o id remoto decodificado (`googleCalendarSync.ts:675`) — sem branch por `googleEventId`, editar um importado criaria um 2º evento.
- Descrição: `googleEventContentEquals` compara `description` (`mapping:196-199`) e `buildGoogleEventPayload` sempre monta a descrição (`:104-107`) — o forward sobrescreveria a descrição do usuário mesmo "ignorando" a importação.
- Recorrência: instâncias têm id `master_2026…` (underscore) e `GoogleRemoteEvent` (`mapping:152-161`) não carrega `recurringEventId`; títulos idênticos colidem no `slug` único (`Activity.ts:71-98` + `ACTIVITY_DUPLICATE_TITLE_MESSAGE`) — a 2ª ocorrência pausaria a passada.
- Namespace: `teqo3` manual decodifica como atividade 3 (`mapping:54-67`) — o corte de importação reusa `decodeGoogleEventActivityId(...) !== null`, nunca prefixo.
- Título: summary vazio/emoji-only falha `setCanonicalActivitySlug` (`Activity.ts:82-84`) sem fallback.
- Snapshot: `lastSeenEventIds` só guarda ids `teqo…` (`googleCalendarSync.ts:442-458,602-603`); sem ids estrangeiros linkados não há como detectar "excluído no Google" (hard delete).
- e2e manifest: `src/collections/Activity.ts`, `src/lib/schemas/activity.ts` e `src/utilities/access/activities.ts` **não** estão nos prefixes de atividade (`scripts/lib/e2e-affected-manifest.mjs:100-115`) → diff cairia no smoke/`frontend+campaignNewsletter`; `src/lib/googleCalendarEventMapping.ts`/`googleCalendarReverseEdit.ts` também faltam no entry do motor (`:114-135`).
- Migration: `activity.municipality_id` é `NOT NULL`; required→nullable idempotente tem precedente em `20260824_010000_make_supporter_import_batch_actor_nullable.ts:23-45`.
## Abordagem recomendada
```mermaid
flowchart LR
G["Google Calendar<br/>(conta da imprensa)"] -->|listEvents<br/>singleEvents + showDeleted| L["runSyncPass<br/>(motor C114/C115)"]
L -->|"id decodifica (teqo…)"| M["loops 1–2 existentes<br/>espelho + reverse C115"]
L -->|"id estrangeiro + início ≥ corte"| I{"linkado?<br/>googleEventId"}
I -->|não| C["create activity<br/>confirmado, sem município<br/>título verbatim, locality = location<br/>slug determinístico"]
I -->|sim| R{"relógio C115"}
R -->|"Google mais novo"| RE["reverse patch Teqo<br/>(título/horário/cancelamento + registro)"]
R -->|"Teqo mais novo/diferente"| P["patchEvent (PATCH)<br/>MESMO id remoto; sem description"]
C --> S["snapshot lastSeenEventIds<br/>(inclui id estrangeiro linkado)"]
RE --> S
P --> S
S --> CAS["recordLastSeenSnapshot<br/>(CAS inalterado)"]
P -.->|"cancelado no Teqo"| CC["PATCH status=cancelled<br/>(nunca DELETE)"]
```
**Opções consideradas (macro):** **A)** vínculo em 2 campos system-write na `Activity` (`googleEventId` + `googleCalendarId`) + passo de importação dentro do `runSyncPass` (mesma janela/snapshot/CAS) + `PATCH` para eventos linkados; **B)** coleção de vínculo dedicada (`googleCalendarLink`) com passada própria; **C)** "espelhar" o importado criando um evento `teqo…` e deixar o estrangeiro intocado (vínculo pelo título/horário).
**Recomendação: A.**
**Justificativa:** é o menor modelo que satisfaz o aceite inteiro (idempotência por vínculo único, edição no MESMO evento, cancelamento sem apagar estrangeiro) sem twin de coleção e sem duplicar a máquina de snapshot/CAS. B rejeitada: 1:1 sem ciclo de vida próprio, exigiria join/coleção nova e dois escritores de estado. C rejeitada: viola "editar no Teqo atualiza o MESMO evento" (o Teqo passaria a ter duas fontes: o evento estrangeiro e a cópia `teqo…`) e "nunca nasce um segundo evento".
### Decisões de engenharia
#### A. Modelo do vínculo (schema + migration)
Opções: **A)** dois campos system-write na `Activity` — `googleEventId` (`text`, `unique`, `index` implícito, `admin.readOnly`, `access.create/update: canSetActivitySystemField`) + `googleCalendarId` (`text`, idem, sem unique); presença de `googleEventId` = importado (o espelho usa id determinístico e nunca persiste link); **B)** só `googleEventId`, assumindo calendário único; **C)** coleção `googleCalendarLink` (atividade↔evento) separada.
Recomendação: **A** — o par id+calendário é o vínculo estável: com a troca de calendário do C150, só o par responde "este link é deste calendário?" sem ambiguidade (skip seguro, sem 404/duplicata). O `unique` no `googleEventId` é a trava de idempotência no banco (Postgres aceita múltiplos NULL). Migration `20260915_HHMMSS_activity_google_import_link`: gera as duas colunas + unique index e `ALTER TABLE activity ALTER COLUMN municipality_id DROP NOT NULL`.
Alternativas rejeitadas: **B** porque a troca de calendário torna o id ambíguo (update em evento de outro calendário → 404 pausa a passada ou cria cópia); **C** porque é twin de um 1:1 (join por atividade, sem consulta independente) e dobra os pontos de escrita.
#### B. Semântica de importação (corte, horizonte, forma e idempotência)
Opções: **A)** predicado puro `isImportableGoogleEvent(event, { rangeStart, timeMax })` no módulo de mapping + import por `googleEventId` com fallback de título/slug determinístico; **B)** filtro inline no motor com literais de corte; **C)** importar tudo que o `listEvents` devolver (sem corte) e triar depois.
Recomendação: **A** — o corte é `formatBahiaCivilDate(startAt) >= CALENDAR_PHASE_ANCHORS.consolidationStart` (all-day: `date` já é civil; timed: `Date.parse(dateTime)` → data civil Bahia), o piso é `max(rangeStart, cutInstant)` e o teto é o `timeMax` do `buildListWindow` (mesma janela do espelho). Candidato = `decodeGoogleEventActivityId(event.id) === null` (corte existente `googleCalendarSync.ts:605-610`; um `teqo3` manual decodifica e fica no namespace do espelho — nunca importado, documentado). Forma do importado: `status: 'confirmado'`, `startAt/endAt/allDay` de `googleScheduleToActivityFields` (null → skip), `locality = event.location?.trim().slice(0,160)`, `description` ausente, `tags: []`, `googleEventId`/`googleCalendarId`, create com `overrideAccess: true` + `context: { mutationKind: 'googleCalendarSync' }` + `req.user` removido (mesmo padrão do reverse patch, `googleCalendarSync.ts:471-499`). Título = `googleTitleFromSummary(summary, undefined)`, exigindo `length ≥ 2` e `slugify(title) !== ''` (senão skip — não inventar título num campo imutável). Slug determinístico `${slugify(title)}-${sufixo civil}` (timed: `formatIsoAsBahiaDateTimeInput(startAt).replace(/[-:]/g,'')`; all-day: `allDayCivilDateOf`), com branch no hook `setCanonicalActivitySlug`: em `create` com `mutationKind === 'googleCalendarSync'` e slug fornecido, sanitiza e preserva (em vez de recomputar). Idempotência: mapa `googleEventId → activity` carregado numa query do passo; `unique` no banco como backstop; violação de unicidade do create só é engolida quando um link para o mesmo evento já existe (senão re-throw — ver decisão C).
Alternativas rejeitadas: **B** porque duplicaria o literal do corte (proibido pela intenção) e espalharia a política no motor; **C** porque backfill de eventos pessoais antigos é rabbit hole nomeado e viola o aceite "nada antes de 16/08".
Nota de produto respeitada: o importado **não** leva `[Município] ` no título nem depois de ganhar município (branch de importado no forward; ver D/E).
#### C. Encaixe no motor, snapshot e concorrência
Opções: **A)** função `runImportedEventsPass(...)` chamada dentro do `runSyncPass` depois dos loops 1–2 e **antes** do `recordLastSeenSnapshot`, compartilhando `remoteEvents`, `lastSeenIds`, CAS e `activityWhere`, devolvendo contadores + ids vistos para o snapshot; **B)** branches de importação dentro dos dois loops existentes; **C)** passada de importação de topo, com `listEvents` próprio, chamada pelo `runCampaignCalendarSync`.
Recomendação: **A** — os loops 1–2 são a máquina C114/C115 testada e assumem id decodificado; separar o passo isola a semântica nova (criar/cancelar linkado) e reusa o que já existe: a lista estrangeira vem do mesmo `listEvents` (sem RTT extra), o `lastSeenIds` alimenta a regra de exclusão e o snapshot ganha os ids estrangeiros **linkados** (não todos os estrangeiros — mantém o JSONB enxuto e o significado do conjunto). Contadores: estender `SyncCounts` só com `imported` (criações); patches Teqo→Google contam em `updated`, cancels do Google em `reverseEdits` (mesma classe do nativo), cancelamento no Teqo conta em `deleted` (PATCH `status: 'cancelled'`, nunca `DELETE`) — `toMatchObject` dos testes atuais não quebra. Query do passo: `payload.find` em `activity` com `and: [{ googleEventId: { exists: true } }, { googleCalendarId: { equals: calendarId } }, ...(activityWhere)]`, sem filtro de status (precisa das `cancelado` para re-assertar o cancel no Google). Concorrência de duas passadas no mesmo evento novo: o `unique` barra o segundo create; o catch só é tolerado se um link para aquele `googleEventId` já existir (re-leitura), senão re-throw (o erro real sobe e a passada vira `paused`, comportamento atual).
Alternativas rejeitadas: **B** porque mistura o branch frágil no loop que hoje garante o espelho (e o tratamento de cancelado difere entre nativo e importado); **C** porque paga um segundo `listEvents` por passada e cria um segundo escritor de snapshot/CAS.
#### D. Editar no Teqo → MESMO evento (`patchEvent`, cancel)
Opções: **A)** novo `patchEvent(calendarId, eventId, partial)` no client (HTTP `PATCH`, `events.patch`) usado por importados, com o body só dos campos que o Teqo controla (`summary`, `start`, `end`, `location`) e `sendUpdates: 'none'`; **B)** continuar com `updateEvent` (PUT) e copiar `description` do evento listado; **C)** PUT e aceitar a perda.
Recomendação: **A** — `PUT` (update) substitui o recurso inteiro: além da `description`, apagaria convidados/lembretes/Meet (fora de escopo, mas não podem ser destruídos). O `PATCH` preserva tudo que não enviamos. No branch de importado do `runSyncPass`, o payload usa o id remoto (`googleEventId`) e **nunca** `googleEventIdForActivity`; a igualdade de conteúdo do importado compara `summary` + `start/end` + `location` (descrição fora). Cancelar no Teqo = `patchEvent(..., { status: 'cancelled' })` no MESMO evento (a recomendação A da questão em aberto), nunca `DELETE` — o evento continua existindo (lixeira do Google) e o mandato "evento estrangeiro nunca é apagado" fica literal. Hard delete da atividade (admin) não apaga o evento estrangeiro.
Alternativas rejeitadas: **B** porque ainda é `PUT` (destrói o resto do recurso; copiar a descrição só maquia o problema); **C** porque destrói dado do usuário e é anti-goal explícito ("não alterar evento estrangeiro" além do combinado).
#### E. Descrição no forward de importados
Opções: **A)** o payload parcial do importado omite `description` (o `PATCH` preserva a do Google) e a igualdade de conteúdo ignora descrição; **B)** espelhar a descrição no campo `description` da atividade e reenviá-la; **C)** apagar a descrição do evento na primeira edição.
Recomendação: **A** — é a leitura literal de "descrição ignorada na v1 (texto vive no Google)": o Teqo não lê, não guarda e não escreve descrição de importado. Teste int pina que editar o título no Teqo mantém a descrição que existia no stub.
Alternativas rejeitadas: **B** porque a intenção v1 manda ignorar (e reenviar uma descrição Teqo-soberana contradiz "texto vive no Google"); **C** porque perde dado do usuário.
#### F. Recorrência
Opções: **A)** cada instância expandida vira atividade própria, linkada ao id da instância (`master_2026…T…`), sem campo novo de série; **B)** importar só a primeira ocorrência da série; **C)** guardar `recurringEventId`/`originalStartTime` e gerar/atualizar atividades por série.
Recomendação: **A** — é o literal da intenção (`singleEvents=true` já expande). O id de instância é estável para a ocorrência (contém o início original; mover a ocorrência no Google mantém o id), então remarcar uma ocorrência no Teqo faz `PATCH` naquela instância (vira exceção no Google) e remarcar a série atualiza as instâncias (reverse C115 por instância). O título repetido NÃO colide o slug graças ao sufixo determinístico (decisão B). Mudar a regra da série pode regenerar ids: as atividades órfãs "somem" do Google → canceladas pela regra de exclusão, e as novas entram (aceito e documentado na v1). Nenhum campo de série é criado (sem consumidor).
Alternativas rejeitadas: **B** porque perde compromissos reais e contraria o aceite; **C** porque modela série sem produto que a use (YAGNI) e multiplica estados.
#### G. Access sem município (a armadilha do `visibility === 'tudo'`)
Opções: **A)** guard de existência nas scopes de advisor + `canCreateActivity` ciente de `data`; **B)** reusar `resolveProfileScopedRead` e deixar o advisor "tudo" ver tudo (inclui sem município); **C)** trocar o `true` do perfil "tudo" por um where que exclui apenas `responsável` sem município.
Recomendação: **A** — `canReadActivity`: admin/unrestricted antes; `visibility === 'tudo'` passa a devolver `{ municipality: { exists: true } }`; a carteira devolve `{ and: [{ municipality: { exists: true } }, { or: [ responsible…, advisorMunicipalityScopeWhere ] }] }` (o `and` fecha o vazamento do branch `responsible`, que casaria atividade sem município). `canUpdateActivity`: `editing === 'tudo'` idem, carteira idem. `canCreateActivity`: sem `relationshipId(data?.municipality)` só `isCampaignUnrestricted`. Na action, advisor criando sem município, limpando município (`null`) ou repontando para fora da carteira → mensagem nomeada nova em `src/lib/schemas/activity.ts` (precedente `SUPPORTER_UNSCOPED_COORDINATOR_MESSAGE`), mapeada em `mapActivityOverlayError` (safeMessages) para não colapsar em genérico. Com município, nada muda (advisor por carteira/responsável). Leader continua `false`. Campos novos seguem `canSetActivitySystemField` (só admin no admin panel; o motor escreve com bypass).
Alternativas rejeitadas: **B** porque "perfil de visão" é eixo de leitura do catálogo com município, não licença para atividade órfã — furaria o aceite; **C** porque não fecha o branch `responsible` do `advisorActivityScopeWhere` (advisor responsável veria a atividade sem município).
#### H. Demandas/rascunho sem município
Opções: **A)** demandas continuam exigindo município: dereferência null-safe + rejeição nomeada quando há rascunhos sem município efetivo; **B)** permitir demanda sem município mudando `CampaignDemand.ts:242-279`; **C)** ignorar silenciosamente os rascunhos sem município.
Recomendação: **A** — `CampaignDemand` é dona da regra "demanda e atividade no mesmo município" (`:253,274-278`) e demandas são entidade municipal por design; o escopo deste item é atividade. Em `createActivityRecord` e `updateActivityRecord`: usar `relationshipId(activity.municipality)` (remove o TypeError de `:293-294`) e, se `demands.length > 0` com município efetivo nulo, lançar mensagem nomeada ("Atribua um município para criar demandas desta atividade."). Sem demandas, salvar sem município funciona. Giro (`createTourDraftActivitiesRecord`) intocado (sempre tem município).
Alternativas rejeitadas: **B** porque reabre o modelo de demandas (blast radius grande, fora do appetite); **C** porque perde dado em silêncio.
#### I. Overlay (município opcional)
Opções: **A)** `StrictCombobox` com opção sintética `{ value: '', label: 'Sem município' }` no topo, label "Município (opcional)", aviso âmbar quando vazio, hidden input enviando `''`, parser `nullableRelationshipFormValue`; **B)** input vazio + placeholder sem opção explícita; **C)** checkbox "sem município" separado.
Recomendação: **A** — é o rascunho UI aprovado (Impeccable B): default "Sem município", opção/chip exclusivo, aviso "Sem município, só coordenação e candidato veem. Ao atribuir, o assessor da carteira passa a ver." O `StrictCombobox` já suporta `value ''` (selected nulo, `showClear` some); a opção sintética dá o texto default e a volta depois de escolher um município. Remover a validação `if (!municipalityId)` (`ActivityOverlay.tsx:205`); o hidden `municipality` (`:459`) passa a valer `''`; o edit prefila o município existente (`:175-181`) e "Sem município" quando nulo. Sem mudança nos hosts (create/edit já passam `municipalityOptions`).
Alternativas rejeitadas: **B** porque "Sem município" seria só placeholder (não dá para reverter a escolha) e o draft pede opção explícita; **C** porque cria um estado a mais só para o mesmo campo.
#### J. Lista e agenda (view models + badges)
Opções: **A)** `importedFromGoogle` derivado de `Boolean(activity.googleEventId)` nos view models existentes, badges no `ActivityCard` (âmbar "Do Google", tracejado "Sem município", rodapé "Importado automaticamente") e marcador âmbar no evento da agenda (`ActivityAgenda`, `extendedProps`); **B)** campo booleano próprio no schema; **C)** componente de card novo só para importados.
Recomendação: **A** — derivar evita estado redundante (o link é a fonte); adicionar `googleEventId: true` a `activityAgendaSelect` cobre lista (`ActivityList`) e agenda (`ActivityAgendaEvent`) com um select só; `ActivityCard`/`Badge`/CSS da agenda são os shells existentes (Impeccable B: encaixe, não twin). Agenda renderiza o marcador "Do Google" e "Sem município" na linha de local (hoje a linha já filtra `municipalityName` nulo, `ActivityAgenda.tsx:136` — null-safe). Dossiê (`municipalityDossierData.ts:73-90`) continua filtrando por município: importado não aparece lá (aceito).
Alternativas rejeitadas: **B** porque duplica o que a presença do link já diz (estado redundante que pode divergir); **C** porque twin do `ActivityCard`.
#### K. Omnibox/filtro "Sem município"
Opções: **A)** estado booleano `unscoped?: boolean` + param `unscoped=1`, chip e sugestão "Sem município" (grupo Município), exclusivo com `municipality`; **B)** sobrecarregar `municipality` com o literal `sem`; **C)** só filtro visual sem URL.
Recomendação: **A** — `buildActivityListWhere` adiciona `{ municipality: { exists: false } }`; `parseActivityListParams`/`buildActivityListSearchParams` fazem round-trip de `unscoped`; aplicar `unscoped` limpa `municipality` e vice-versa; remover o chip limpa só o campo. Contagens por sugestão do rascunho são ilustrativas (o omnibox não tem plumbing de contagem hoje; a lista mantém o "N resultados" existente). `ActivityFilters` não muda de estrutura (só seeds/chips).
Alternativas rejeitadas: **B** porque quebra o parse numérico do param e mistura dois significados; **C** porque filtro sem URL quebra o padrão de navegação compartilhado (`useCampaignListFilterNavigation`).
#### L. e2e manifest
Opções: **A)** adicionar os prefixes faltantes: no entry de atividade (`:100-115`) `src/collections/Activity.ts`, `src/lib/schemas/activity`, `src/utilities/access/activities.ts`; no entry do motor (`:114-135`) `src/lib/googleCalendarEventMapping.ts`, `src/lib/googleCalendarReverseEdit.ts`; **B)** confiar na run `frontend`+`campaignNewsletter` que `src/lib/schemas` já acorda; **C)** ampliar para `src/utilities/access` inteiro no entry de atividade.
Recomendação: **A** — `src/lib/schemas` e `src/utilities/access` são risk prefixes e têm mappings genéricos próprios (`:238-251`); sem o par específico, um diff de activity acorda specs erradas (e o classifier pode falhar fechado `unmapped-risk`). O match é por `startsWith` e une os specs dos entries que casam, então o par específico não remove a cobertura de risco existente. E2E de importação real com stub de Google não existe na suíte (fora).
Alternativas rejeitadas: **B** porque a cobertura pinada é do formulário público/newsletter, não da agenda; **C** porque `src/utilities/access` completo acordaria as specs de activity em qualquer diff de RBAC de outro domínio (ruído).
#### M. Testes
Opções: **A)** unit para mapping/client/overlay/omnibox + int no motor (novo describe de importação) e nas regras de access/demandas + e2e de formulário/badge; **B)** só int no motor; **C)** e2e novo com stub de Google.
Recomendação: **A** — unit barato pina a matriz de corte/forma/slug/PATCH; int pina o comportamento do motor (idempotência, mesmo id, descrição preservada, snapshot, cancel) e as regras de access; e2e cobre o overlay sem município e o badge. **C** rejeitado: a suíte e2e não tem stub do Calendar (C164 já documentou a discricionariedade); int com stub é a camada do motor.
Detalhe por arquivo: `tests/unit/googleCalendarEventMapping.unit.spec.ts` (predicado de corte nas bordas 15/16-08 all-day e timed, forma do importado, igualdade sem descrição, slug determinístico, `teqo3` fora do import); `tests/unit/googleCalendarClient.unit.spec.ts` (`patchEvent` → `PATCH` no path certo); `tests/unit/googleCalendarReverseEdit.unit.spec.ts` (título `[X] ` verbatim com `municipalityName` undefined); `tests/unit/activityOverlay.unit.spec.tsx` (default "Sem município", salvar sem município envia `municipality=''`, aviso de visibilidade); `tests/unit/activityUi.unit.spec.ts`/`activityAgendaOmnibox.unit.spec.ts` (round-trip `unscoped`, chip, exclusividade); `tests/int/googleCalendarSync.int.spec.ts` (stub ganha `patchEvent` com merge: **1** importa timed ≥ corte sem município, título verbatim, sem evento `teqo…`; **2** 2ª passada converge; **3** edição no Google reflete com registro; **4** edição no Teqo PATCHa o mesmo id e preserva descrição; **5** atribuir município mantém título sem prefixo; **6** `cancelled` no Google cancela; **7** hard delete pós-visto cancela; **8** pré-corte fica fora e o dia 16/08 entra; **9** cancelar no Teqo vira `cancelled` no MESMO evento (sem DELETE); **10** summary vazio não importa e não pausa; **11** duas ocorrências de mesmo título geram slugs distintos); `tests/int/campaignActivity.int.spec.ts` (schema sem município; advisor não cria/limpa; advisor `visibility=tudo` não lê sem município via `payload.find`/where; coordenador lê; demandas sem município rejeitadas; dereferência null-safe); `tests/e2e/campaignActivity.e2e.spec.ts` (labels `Município (opcional)` nas 4 linhas pinadas e um create sem município com badge "Sem município").
Alternativas rejeitadas: **B** porque a matriz de corte/forma é pura e determinística (não deve depender de banco); **C** idem acima.
### Componentes / mudanças
- **`src/collections/Activity.ts`:** `municipality` `required: false`; campos `googleEventId` (unique, readOnly, access system) e `googleCalendarId` (readOnly, access system) perto de `lastMirroredChangeAt`; branch no `setCanonicalActivitySlug` para preservar slug fornecido em create com `mutationKind === 'googleCalendarSync'`.
- **`src/lib/schemas/activity.ts`:** `municipality` opcional no create e `nullable().optional()` no update; `tourStopDraftSchema` mantém município obrigatório (extend explícito — o pick herdaria a opcionalidade); mensagem nomeada do sem-município e a de demandas.
- **`src/utilities/activityFormData.ts`:** `municipality: nullableRelationshipFormValue(formData, 'municipality')`.
- **`src/utilities/access/activities.ts`:** guards de município existente (read/update) e `canCreateActivity` por `data` (decisão G).
- **`src/app/(campaign)/campanha/actions/activity.ts`:** guard de advisor sem município/limpar/repontar; dereferência null-safe e rejeição de demandas (decisão H); exportar `updateActivityRecord` se o teste int precisar (precedente `createActivityRecord`).
- **`src/utilities/activityOverlayErrors.ts`:** safeMessages com a mensagem nova.
- **`src/lib/googleCalendarEventMapping.ts`:** `isImportableGoogleEvent`, `buildImportedGoogleEventPayload`, `googleImportedEventContentEquals`, `importedActivitySlug` (puros, reusando `googleScheduleToActivityFields`/`googleTitleFromSummary`/`allDayStartInstant`/`slugify`/`CALENDAR_PHASE_ANCHORS`).
- **`src/utilities/googleCalendarClient.ts`:** `patchEvent` (`PATCH`, `sendUpdates: 'none'`).
- **`src/utilities/googleCalendarSync.ts`:** `runImportedEventsPass` + integração no `runSyncPass` (merge de snapshot e contadores), branch de importado no forward (`patchEvent` pelo id remoto), cancel no Teqo via `status: 'cancelled'`, `SyncCounts.imported`.
- **`src/utilities/activityViewModels.ts`:** `googleEventId: true` no `activityAgendaSelect`; `importedFromGoogle` em `ActivityAgendaEvent`/`ActivityListViewModel`.
- **UI:** `ActivityOverlay.tsx` (decisão I), `ActivityCard.tsx` (badges/rodapé), `ActivityAgenda.tsx` + `ActivityAgenda.css` (marcador), `ActivityFilters.tsx` (seeds/chips) — shells existentes do rascunho aprovado.
- **Filtros:** `src/utilities/activityUi.ts` (`unscoped`), `src/utilities/activityOmnibox.ts` (chip/sugestão/exclusividade).
- **`scripts/lib/e2e-affected-manifest.mjs`:** prefixes da decisão L.
- **Migration:** `src/migrations/20260915_HHMMSS_activity_google_import_link.ts` (+ `index.ts`, `payload-types.ts` via `pnpm migrate:create`/`pnpm migrate`/`pnpm generate:types`).
- **Access/Consent:** nada de `Consent` novo (dado interno de staff); vínculo system-write; leader lockdown intocado.
- **Dados → forma:** N/A — a intenção resolve "não apresentar dados"; a apresentação é a UI de badges/overlay acima.
## Fases verificáveis
1. **Tracer schema + access (quota ~25% / ~1 dia).** Migration aplica; coordenador cria atividade sem município pelo Local API; advisor (inclusive `visibility=tudo`) não lê; demandas sem município rejeitadas com mensagem.
- `pnpm migrate:create activity_google_import_link` → auditar `up`/`down` → `pnpm migrate` → `pnpm generate:types`.
- Unit/int: `pnpm test:unit -- tests/unit/activityOverlayErrors.unit.spec.ts`; `pnpm test:int -- tests/int/campaignActivity.int.spec.ts`.
- Checkpoint: os testes pinados `campaignActivity.int.spec.ts:99-106` mudam de "requires a municipality" para "coordinator cria sem / advisor não cria".
2. **Motor import/link (quota ~40% / ~1,5 dia).** Evento estrangeiro elegível vira atividade linkada; 2ª passada converge; edição nos dois sentidos usa o mesmo evento; descrição preservada; cancel/exclusão; corte/horizonte.
- Unit: `pnpm test:unit -- tests/unit/googleCalendarEventMapping.unit.spec.ts tests/unit/googleCalendarClient.unit.spec.ts tests/unit/googleCalendarReverseEdit.unit.spec.ts`.
- Int: `pnpm test:int -- tests/int/googleCalendarSync.int.spec.ts`.
3. **UI (quota ~20% / ~0,75 dia).** Overlay com "Município (opcional)" + aviso; cards com "Do Google"/"Sem município"/rodapé; agenda com marcador; omnibox com chip/sugestão "Sem município".
- Unit: `pnpm test:unit -- tests/unit/activityOverlay.unit.spec.tsx tests/unit/activityUi.unit.spec.ts tests/unit/activityAgendaOmnibox.unit.spec.ts`.
- E2E: `pnpm test:e2e -- tests/e2e/campaignActivity.e2e.spec.ts` (labels novos + create sem município com badge; import real não é observável e2e — registrar discricionariedade).
4. **Gates + fechamento (quota ~15% / ~0,5 dia).** Manifest, suíte, changelog, PR.
- `pnpm gate:fast`; `pnpm test:int -- tests/int/googleCalendarSync.int.spec.ts tests/int/campaignActivity.int.spec.ts`; `pnpm test:e2e -- tests/e2e/campaignActivity.e2e.spec.ts`.
- Changelog `docs/changelog/2026-09-15-c165.md` (parágrafo único, abrindo `**C165 — … (2026-09-15):**`).
- `pnpm push` → PR com `Closes #1010`.
## Rabbit holes / Não escopo (engenharia)
- **Modelar série/recorrência** (`recurringEventId`, exceções, RRULE): fora — instância é atividade (decisão F).
- **Sincronizar convidados/RSVP/lembretes/Meet/cor**: fora; `PATCH` existe justamente para não destruí-los (decisão D).
- **Backfill pré-corte**: fora para sempre (predicado com piso; sem job).
- **Tela de triagem/notificação de importado**: a triagem é inline (lista + filtro + overlay).
- **Coleção de vínculo/estado de sync por evento**: twin rejeitado (decisão A/C).
- **Reconciliar calendários secundários**: fora; só o principal conectado.
- **Tocar leader lockdown, feed iCal público, consentimento**: fora.
- **Renomear/limpar links em troca de calendário**: v1 só ignora links de outro `googleCalendarId` (sem 404, sem duplicata); relink é trabalho futuro, se pedido.
- **Advisory lock global do sync**: fora — o CAS + unique já fecham a corrida sem segurar lock em I/O (C114-LOCK).
## Riscos e mitigação
- **Descrição/dados do usuário destruídos no forward:** `PATCH` parcial para importados + igualdade sem descrição (decisões D/E); int afirma que a descrição sobrevive a uma edição de título/local no Teqo.
- **Segundo evento `teqo…` criado:** branch por `googleEventId` nunca chama `insertEvent` com id determinístico; evento de link de outro calendário é ignorado; int afirma ausência de id `teqo…` no stub.
- **Colisão de slug (recorrência/título repetido):** sufixo civil determinístico + branch do hook; int com duas ocorrências de mesmo título.
- **Corrida de duas passadas importando o mesmo evento:** `unique` + catch que só tolera colisão quando o link já existe; senão re-throw (passada `paused`, sem duplicata — a trava é o banco).
- **Vazamento de visão para advisor (`visibility=tudo`/responsável):** guard `municipality exists` nos dois branches; int com `payload.find` sob o advisor; curated e2e de RBAC roda pelo risk prefix `src/utilities/access` (e o par específico acorda a suíte de atividade).
- **TypeError em demandas / demanda sem município:** dereferência null-safe + rejeição nomeada; `CampaignDemand` intocado; int cobre.
- **Título vazio/emoji-only quebrando a passada:** predicado de título/slug skip (não cria, não lança, passada segue `synced`); int cobre.
- **Borda do corte em fuso:** comparação por data civil Bahia (all-day direto; timed via `Date`); unit pina 15/08 (fora) e 16/08 (dentro).
- **Passada mais longa no hook (5s):** imports só adicionam uma query + creates de eventos novos; se o orçamento estourar, a passada vira `paused` e a próxima (webhook/manual, sem budget) converge — comportamento existente, registrar no PR.
- **Google fora do ar:** catch existente → `paused`; nenhum write path do Teqo depende do Google (guardrail preservado).
- **Snapshot maior:** só ids estrangeiros linkados entram (não todos), limitado pela janela/corte.
- **Migration em produção:** drop NOT NULL + unique index em coluna nova (todas as linhas NULL) — sem conflito; `down` reverte colunas e (best-effort) o NOT NULL.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: importação automática ≥ 16/08/2026, sem município visível só coordenação/candidato, triagem no overlay, vínculo idempotente editando o MESMO evento, Google→Teqo e excluir→cancelar, nada pré-corte, estrangeiro nunca apagado, leader lockdown intocado, sem Consent, Teqo inteiro se o Google cair.
- [ ] Invariantes AGENTS: migration commitada (`push: false`), Consent fail-closed preservado (nenhum novo), edição no dono (mapping/motor/access/overlay existentes, sem twin), sem literal duplicado do corte, URLs públicas intactas, PR com `Closes #1010`.
- [ ] Testes: unit (mapping/client/overlay/omnibox/reverse-edit), int (motor de importação + access/demandas), e2e pinado de formulário/badge; manifest e2e atualizado; `pnpm gate:fast` verde.
- [ ] Changelog `docs/changelog/2026-09-15-c165.md` commitado.
## Débitos da revisão (triage 2026-09-15)
**Registrado:** C165-F1 ([#1030](https://github.com/fsolla/teqo/issues/1030), kind defect, P2,
`depends #1010`, appetite ~0,25 dia) — o guard de valor do município vive só na action;
`PATCH /api/activity` com JWT de campanha escapa. Plano:
[c165-f1-update-access-municipality.md](c165-f1-update-access-municipality.md).
**Já resolvido no simplify (não reabrir):** snapshot não re-importa id dismissado (+ teste de
2ª passada); create do import fora da transação do caller + pre-check; seen-and-gone para
atividade sem município; `down` da migration com `IF EXISTS`; params objeto em
`runImportedEventsPass`; `patchEvent(importedPatch)`; casts redundantes removidos; helpers de
teste hoistados; assert de slug por regex; `isImportableGoogleEvent` rejeita ids `teqo…`;
labels `ACTIVITY_IMPORTED_LABEL`/`ACTIVITY_UNSCOPED_LABEL`; `importedActivitySlug`
desexportado (knip); card sem span vazio.
### Adiado com gatilho
- **S2 — sleeps reais de 3,5s nos int do relógio** (margem ~0,5s contra a tolerância de 2s; o
irmão nativo é pré-C165). **Gatilho:** primeiro flake do relógio que consumir o `retry: 1`
em CI, ou qualquer mudança na tolerância/janela — trocar o sleep por timestamps/baseline
controlados, nos dois testes juntos.
- **S4 — "sumiu do Google" também cancela quem só saiu da janela bufferizada** (regra
deliberada do espelho nativo C115, agora herdada; remarcar para além de
`rangeEnd + SYNC_LIST_WINDOW_BUFFER_DAYS` vira cancelamento). **Gatilho:** primeiro relato
de cancelamento falso por remarcação fora da janela, ou mudança das janelas `FEED_*` —
revisar nativo + importado juntos.
### Explicitamente fora
- **S3 —** `googleScheduleToActivityFields` tipa `startAt`/`endAt` como `string | null` mas
nunca devolve null, e `importedActivitySlug` mascara com `?? ''`: pureza de tipo, sem call
site prejudicado; refazer só se um consumidor novo precisar do tipo estreito.
- **S5 —** `runImportedEventsPass` com ~150 linhas: extrair o ramo linkado em helper quando
uma feature tocar esse ramo; legibilidade não paga refatoração sem comportamento.
- **S6 —** `slugify(title) || 'evento'` inalcançável (o builder rejeita título sem slug em 2
pontos): fallback defensivo morto; remover no próximo toque no mapping.
### Self-score (decision-quality)
**5/5** — (1) todas as decisões não triviais (A–M) trazem Opções A|B|C, Recomendação e rejeitadas com o porquê; (2) o diff cabe no appetite (~3–4 dias; fases somam 100% com quotas 25/40/20/15 e nenhuma frente inflada); (3) rabbit holes nomeados com corte explícito (série, convidados, backfill, tela de triagem, coleção twin); (4) depth check feito — helpers puros no dono (`googleCalendarEventMapping`), orquestração no motor que já é dona do snapshot/CAS, access no módulo de access, UI nos shells existentes; nenhum módulo/helper novo além da migration; (5) o outcome da intenção permanece intacto — as reavaliações confirmaram as armadilhas e as decisões as fecham, sem reescrever produto (descrição segue ignorada, líder segue trancado, evento estrangeiro não é apagado).

View file

@ -109,6 +109,13 @@ export const E2E_AFFECTED_MANIFEST = [
'src/utilities/activity',
'src/utilities/activityOmnibox',
'src/lib/activityQuickActions',
// C165 — the activity schema/access/collection moved the município to
// optional and added the Google link; those diffs must wake the
// activity e2e family (the generic `src/lib/schemas` entry alone only
// wakes the public-site/newsletter specs).
'src/collections/Activity.ts',
'src/lib/schemas/activity',
'src/utilities/access/activities.ts',
],
// C148 — the activity components host the agenda's three modals: the
// create/edit overlay (campaignActivity), the Google sync dialog and the
@ -122,6 +129,7 @@ export const E2E_AFFECTED_MANIFEST = [
// would only wake the home smoke, leaving the mirror spec unrun.
// C150 — the one-click link builder and the campaign actions (picker +
// choose) join the same domain entry.
// C165 — the pure mapping/reverse-edit modules joined the engine surface.
prefixes: [
'src/utilities/googleCalendarSync.ts',
'src/utilities/googleCalendarSyncHooks.ts',
@ -129,6 +137,8 @@ export const E2E_AFFECTED_MANIFEST = [
'src/utilities/googleCalendarOAuth.ts',
'src/lib/googleCalendarOAuth.ts',
'src/lib/googleCalendarLink.ts',
'src/lib/googleCalendarEventMapping.ts',
'src/lib/googleCalendarReverseEdit.ts',
'src/components/campaign/activity/AgendaGoogleSyncChrome',
'src/components/campaign/activity/GoogleCalendarSyncDialog',
'src/components/campaign/activity/GoogleCalendarPickerDialog',

View file

@ -2,7 +2,9 @@
import type { Payload } from 'payload'
import { relationshipId } from '@/lib/relationship'
import {
ACTIVITY_DEMANDS_MUNICIPALITY_MESSAGE,
ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE,
ACTIVITY_OUT_OF_SCOPE_MESSAGE,
ACTIVITY_RESCHEDULE_FAILED_MESSAGE,
@ -10,6 +12,7 @@ import {
ACTIVITY_RESULT_STAFF_MESSAGE,
ACTIVITY_RESULT_TOO_LONG_MESSAGE,
ACTIVITY_TASK_NOT_FOUND_MESSAGE,
ACTIVITY_UNSCOPED_ADVISOR_MESSAGE,
ACTIVITY_UPDATE_BODY_REQUIRED_MESSAGE,
ACTIVITY_UPDATE_BODY_TOO_LONG_MESSAGE,
activityAgendaRequestSchema,
@ -36,6 +39,7 @@ import {
canCampaignUserRescheduleActivity,
getWritableMunicipalityIds,
isCampaignStaff,
isCampaignUnrestricted,
} from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
@ -125,10 +129,21 @@ export const createActivityRecord = async (
// create must check the municipality against the WRITE scope itself —
// with Visão "Tudo" + Edição "Carteira" the read widens while the
// carteira stays the edit boundary (same rule as the giro batch).
// C165 — without a município, only coordination/candidate may create;
// advisors cannot reach this branch (their `canCreateActivity` requires
// a município), but the guard keeps the rule local to the action too.
const municipalityID = raw.municipality ?? null
const writableIDs = await getWritableMunicipalityIds(payload, currentActor, req)
if (writableIDs !== null && !writableIDs.includes(raw.municipality)) {
if (municipalityID === null) {
if (!isCampaignUnrestricted(currentActor)) {
throw new Error(ACTIVITY_UNSCOPED_ADVISOR_MESSAGE)
}
} else if (writableIDs !== null && !writableIDs.includes(municipalityID)) {
throw new Error(ACTIVITY_OUT_OF_SCOPE_MESSAGE)
}
if (municipalityID === null && demands.length > 0) {
throw new Error(ACTIVITY_DEMANDS_MUNICIPALITY_MESSAGE)
}
const activity = await payload.create({
collection: 'activity',
@ -139,19 +154,21 @@ export const createActivityRecord = async (
req,
})
for (const demand of demands) {
await payload.create({
collection: 'campaignDemand',
data: hookFilledCreateData<'campaignDemand'>({
...demand,
municipality: raw.municipality,
activity: activity.id,
}),
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
if (demands.length > 0 && municipalityID !== null) {
for (const demand of demands) {
await payload.create({
collection: 'campaignDemand',
data: hookFilledCreateData<'campaignDemand'>({
...demand,
municipality: municipalityID,
activity: activity.id,
}),
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
}
}
return activity
@ -254,7 +271,7 @@ export const createTourDraftActivitiesRecord = async (
)
}
const updateActivityRecord = async (
export const updateActivityRecord = async (
payload: Payload,
actor: CampaignUser,
input: ActivityUpdateInput,
@ -280,6 +297,24 @@ const updateActivityRecord = async (
const currentActor = await reloadCampaignActor(payload, actor, req)
await acquireTextAdvisoryLocks(payload, req, [`activity:${id}`])
// C165 — the Edição axis grants the ROW, not any município: an advisor
// cannot clear the município (coordination/candidate-only) nor point the
// activity at one outside their write scope. Coordination/candidate are
// unrestricted by definition.
if (!isCampaignUnrestricted(currentActor)) {
const writableIDs = await getWritableMunicipalityIds(payload, currentActor, req)
if (data.municipality === null) {
throw new Error(ACTIVITY_UNSCOPED_ADVISOR_MESSAGE)
}
if (
typeof data.municipality === 'number' &&
writableIDs !== null &&
!writableIDs.includes(data.municipality)
) {
throw new Error(ACTIVITY_OUT_OF_SCOPE_MESSAGE)
}
}
const activity = (await payload.update({
collection: 'activity',
id,
@ -290,21 +325,25 @@ const updateActivityRecord = async (
req,
})) as unknown as Activity
const municipality =
typeof activity.municipality === 'number' ? activity.municipality : activity.municipality.id
for (const demand of demands) {
await payload.create({
collection: 'campaignDemand',
data: hookFilledCreateData<'campaignDemand'>({
...demand,
municipality,
activity: activity.id,
}),
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
const municipality = relationshipId(activity.municipality)
if (demands.length > 0 && municipality === null) {
throw new Error(ACTIVITY_DEMANDS_MUNICIPALITY_MESSAGE)
}
if (municipality !== null) {
for (const demand of demands) {
await payload.create({
collection: 'campaignDemand',
data: hookFilledCreateData<'campaignDemand'>({
...demand,
municipality,
activity: activity.id,
}),
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
}
}
return activity

View file

@ -93,7 +93,16 @@ const setCanonicalActivitySlug: CollectionBeforeValidateHook = ({
throw new APIError('O título da atividade não pode ser alterado após a criação.', 409)
}
data.title = title
data.slug = operation === 'create' ? slug : originalDoc?.slug
if (operation === 'create') {
// C165 — an imported Google event carries its own deterministic slug
// (title + occurrence suffix) so two occurrences of a recurring event
// never collide on the unique index. Sanitize and preserve it; fall back
// to the title's own slug when absent.
const providedSlug = isGoogleCalendarWrite ? slugify(String(data.slug ?? '')) : ''
data.slug = providedSlug || slug
} else {
data.slug = originalDoc?.slug
}
return data
}
@ -556,7 +565,10 @@ export const Activity: CollectionConfig = {
type: 'relationship',
relationTo: 'municipality',
label: 'Município',
required: true,
// C165 — optional: imported Google events are born without a município
// and the coordination triages later. Without one, only the
// coordination/candidate see the activity (see access/activities).
required: false,
index: true,
},
{
@ -768,6 +780,37 @@ export const Activity: CollectionConfig = {
update: canSetActivitySystemField,
},
},
{
// C165 — link to the FOREIGN Google event this activity was imported
// from. Its presence is what makes an activity "imported": the mirror
// writes `teqo…` events and never fills this field. `unique` is the
// idempotency lock (Postgres allows many NULLs); the calendar id pins
// WHICH calendar, so a calendar switch never patches the wrong event.
name: 'googleEventId',
type: 'text',
label: 'Evento do Google',
unique: true,
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetActivitySystemField,
update: canSetActivitySystemField,
},
},
{
name: 'googleCalendarId',
type: 'text',
label: 'Calendário do Google',
admin: {
readOnly: true,
},
access: {
create: canSetActivitySystemField,
update: canSetActivitySystemField,
},
},
systemStampedActorField({ label: 'Criada por', setAccess: canSetActivitySystemField }),
],
}

View file

@ -283,6 +283,16 @@
white-space: nowrap;
}
.activity-agenda-imported {
overflow: hidden;
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.04em;
opacity: 0.85;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-agenda-event-status {
font-size: 0.625rem;
font-weight: 600;

View file

@ -48,7 +48,9 @@ import {
} from '@/lib/activityAllDay'
import { formatBahiaCivilDate } from '@/lib/campaignTime'
import {
ACTIVITY_IMPORTED_LABEL,
ACTIVITY_RESCHEDULE_FAILED_MESSAGE,
ACTIVITY_UNSCOPED_LABEL,
activityStatusLabels,
type ActivityStatus,
} from '@/lib/schemas/activity'
@ -72,6 +74,8 @@ type AgendaEventProps = {
municipalityName: string | null
locality: string | null
tags: string[]
/** C165 — imported from the Google Calendar (link present). */
importedFromGoogle: boolean
}
const eventColors: Record<ActivityStatus, { color: string; contrastColor: string }> = {
@ -115,6 +119,7 @@ const toEventInput = (event: ActivityAgendaEvent): EventInput => ({
municipalityName: event.municipality?.name ?? null,
locality: event.locality,
tags: event.tags,
importedFromGoogle: event.importedFromGoogle,
} satisfies AgendaEventProps,
})
@ -150,6 +155,13 @@ const renderEventContent = ({ event, timeText, view }: EventDisplayInfo) => {
</div>
<span className="activity-agenda-event-title">{event.title}</span>
{location ? <span className="activity-agenda-event-location">{location}</span> : null}
{props.importedFromGoogle ? (
<span className="activity-agenda-imported">
{props.municipalityName
? ACTIVITY_IMPORTED_LABEL
: `${ACTIVITY_IMPORTED_LABEL} · ${ACTIVITY_UNSCOPED_LABEL}`}
</span>
) : null}
{tags ? <span className="activity-agenda-event-tags">{tags}</span> : null}
<span className="activity-agenda-event-status">{activityStatusLabels[props.status]}</span>
</div>

View file

@ -4,6 +4,7 @@ import Link from 'next/link'
import { ActivityStatusBadge } from '@/components/campaign/activity/ActivityStatusBadge'
import { Badge } from '@/components/ui/Badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { ACTIVITY_IMPORTED_LABEL, ACTIVITY_UNSCOPED_LABEL } from '@/lib/schemas/activity'
import { formatActivityWhenLabel, type ActivityListViewModel } from '@/utilities/activityViewModels'
export const ActivityCard = ({ activity }: { activity: ActivityListViewModel }) => (
@ -16,6 +17,16 @@ export const ActivityCard = ({ activity }: { activity: ActivityListViewModel })
</Badge>
))}
<ActivityStatusBadge status={activity.status} />
{activity.importedFromGoogle ? (
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700">
{ACTIVITY_IMPORTED_LABEL}
</Badge>
) : null}
{!activity.municipalityName ? (
<Badge variant="outline" className="border-dashed text-muted-foreground">
{ACTIVITY_UNSCOPED_LABEL}
</Badge>
) : null}
{activity.deputyPresent ? <Badge>Deputado presente</Badge> : null}
</div>
<CardTitle>
@ -66,9 +77,16 @@ export const ActivityCard = ({ activity }: { activity: ActivityListViewModel })
'Resp: Não definido'
)}
</span>
{activity.taskProgress.total > 0 ? (
<span className="font-medium tabular-nums text-muted-foreground">
{activity.taskProgress.done}/{activity.taskProgress.total} tarefas
{activity.importedFromGoogle || activity.taskProgress.total > 0 ? (
<span className="flex items-center gap-2">
{activity.importedFromGoogle ? (
<span className="text-xs text-muted-foreground">Importado automaticamente</span>
) : null}
{activity.taskProgress.total > 0 ? (
<span className="font-medium tabular-nums text-muted-foreground">
{activity.taskProgress.done}/{activity.taskProgress.total} tarefas
</span>
) : null}
</span>
) : null}
</div>

View file

@ -52,6 +52,7 @@ import {
formatIsoAsBahiaDateTimeInput,
parseBahiaDateTimeInput,
} from '@/lib/campaignTime'
import { ACTIVITY_UNSCOPED_LABEL } from '@/lib/schemas/activity'
import type { ActivityAgendaState } from '@/utilities/activityUi'
import type { ActivityFormViewModel } from '@/utilities/activityViewModels'
import type { StrictComboboxOption } from '@/utilities/territory/territoryComboboxOptions'
@ -84,8 +85,12 @@ type ActivityOverlayProps = {
onSaved: () => void
}
const inlineMunicipalityOptions = (options: RelationOption[]): StrictComboboxOption[] =>
options.map((option) => ({ value: String(option.id), label: option.name }))
const inlineMunicipalityOptions = (options: RelationOption[]): StrictComboboxOption[] => [
// C165 — the explicit "no município" state; imported Google events are born
// here and only coordination/candidate can keep them (server-enforced).
{ value: '', label: ACTIVITY_UNSCOPED_LABEL },
...options.map((option) => ({ value: String(option.id), label: option.name })),
]
/** List-row styling for the label-less mobile sheet (C103). */
const sheetFieldInputClass =
@ -192,7 +197,6 @@ const ActivityOverlayForm = ({
event.preventDefault()
const formData = new FormData(event.currentTarget)
const trimmedTitle = title.trim()
const municipalityId = Number(municipalityValue)
const startIso = parseBahiaDateTimeInput(start)
const endIso = end ? parseBahiaDateTimeInput(end) : undefined
@ -202,7 +206,8 @@ const ActivityOverlayForm = ({
} else if (!isEdit && trimmedTitle.length < 2) {
nextFieldErrors.title = ['Informe um título com ao menos 2 caracteres.']
}
if (!municipalityId) nextFieldErrors.municipality = ['Informe o município.']
// C165 — município is optional: "Sem município" is a valid state for an
// imported event awaiting triage (the server gates who may keep it).
if (allDay) {
if (!startIso) nextFieldErrors.startAt = ['Informe a data de início do compromisso.']
if (startIso && endIso && !allDayRangeValid(startIso, endIso)) {
@ -374,7 +379,7 @@ const ActivityOverlayForm = ({
<>
<Field data-invalid={Boolean(errorFor('municipality'))}>
<FieldLabel htmlFor="overlay-municipality" className={sheet ? 'sr-only' : undefined}>
Município *
Município (opcional)
</FieldLabel>
<StrictCombobox
id="overlay-municipality"
@ -382,12 +387,18 @@ const ActivityOverlayForm = ({
value={municipalityValue}
onValueChange={setMunicipalityValue}
error={errorFor('municipality')}
placeholder={sheet ? 'Município *' : undefined}
placeholder={sheet ? 'Município (opcional)' : undefined}
className={sheet ? 'min-h-11 rounded-none border-0' : undefined}
/>
{errorFor('municipality') ? (
<FieldError id="overlay-municipality-error">{errorFor('municipality')}</FieldError>
) : null}
{!municipalityValue ? (
<p className="text-xs text-amber-700">
Sem município, só coordenação e candidato veem. Ao atribuir, o assessor da carteira
passa a ver.
</p>
) : null}
</Field>
<Field data-invalid={Boolean(errorFor('locality'))}>

View file

@ -13,8 +13,17 @@
*/
import type { Activity } from '@/payload-types'
import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay'
import {
allDayCivilDateOf,
allDayEndInstantFromExclusive,
allDayExclusiveEndDate,
allDayStartInstant,
isCivilDate,
} from '@/lib/activityAllDay'
import { buildActivityDescriptionParts } from '@/lib/activityDescription'
import { formatBahiaCivilDate, formatIsoAsBahiaDateTimeInput } from '@/lib/campaignTime'
import { slugify } from '@/lib/slug'
import { CALENDAR_PHASE_ANCHORS } from '@/lib/visitPlannerAnchors'
const GOOGLE_EVENT_ID_PREFIX = 'teqo'
@ -106,13 +115,9 @@ export const buildGoogleCalendarDescription = (
municipalityName?: string,
): string => buildActivityDescriptionParts(activity, municipalityName).join('\n')
export const buildGoogleEventPayload = (
activity: Pick<
Activity,
'id' | 'title' | 'startAt' | 'endAt' | 'allDay' | 'locality' | 'tags' | 'deputyPresent'
>,
municipalityName?: string,
): GoogleCalendarEventPayload => {
const buildGoogleEventSchedule = (
activity: Pick<Activity, 'startAt' | 'endAt' | 'allDay'>,
): { start: GoogleCalendarEventPayload['start']; end: GoogleCalendarEventPayload['end'] } => {
// C14 guarantees startAt beyond draft; a null start has no Google shape —
// fail loudly in the mapping (the engine never feeds one: the window query
// only returns dated activities).
@ -120,9 +125,6 @@ export const buildGoogleEventPayload = (
throw new Error('Atividade sem data de início não pode virar evento do Google.')
}
const summary = municipalityName ? `[${municipalityName}] ${activity.title}` : activity.title
const description = buildGoogleCalendarDescription(activity, municipalityName)
const allDay = Boolean(activity.allDay)
const start = allDay
? { date: allDayCivilDateOf(activity.startAt) }
@ -134,6 +136,20 @@ export const buildGoogleEventPayload = (
activity.endAt ?? addMinutes(activity.startAt, DEFAULT_TIMED_EVENT_DURATION_MINUTES),
),
}
return { start, end }
}
export const buildGoogleEventPayload = (
activity: Pick<
Activity,
'id' | 'title' | 'startAt' | 'endAt' | 'allDay' | 'locality' | 'tags' | 'deputyPresent'
>,
municipalityName?: string,
): GoogleCalendarEventPayload => {
const summary = municipalityName ? `[${municipalityName}] ${activity.title}` : activity.title
const description = buildGoogleCalendarDescription(activity, municipalityName)
const { start, end } = buildGoogleEventSchedule(activity)
const locality = activity.locality?.trim()
const location = locality || municipalityName || undefined
@ -198,3 +214,227 @@ export const googleEventContentEquals = (
(remote.location ?? '') === (payload.location ?? '') &&
startEndInstantEquals(remote.start, payload.start) &&
startEndInstantEquals(remote.end, payload.end)
/** Matches the `title` field cap in the Activity collection — the same bound the Teqo form enforces. */
const ACTIVITY_TITLE_MAX_LENGTH = 160
/**
* The summary carries a `[Município] ` prefix we wrote; the user edits what
* follows it. Only OUR OWN prefix is stripped (matched against the activity's
* municipality name); anything else is the user's title verbatim — and the
* forward direction re-prefixes it consistently. A summary that is ONLY the
* prefix is structural tampering → null → the Teqo re-asserts.
*/
export const googleTitleFromSummary = (
summary: string | undefined,
municipalityName?: string,
): string | null => {
const raw = summary?.trim()
if (!raw) return null
const stripped =
municipalityName && raw.startsWith(`[${municipalityName}]`)
? raw.slice(municipalityName.length + 2).trim()
: raw
if (!stripped) return null
return stripped.length > ACTIVITY_TITLE_MAX_LENGTH
? stripped.slice(0, ACTIVITY_TITLE_MAX_LENGTH)
: stripped
}
const parseInstant = (value: string | undefined): string | null => {
if (!value) return null
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString()
}
/**
* Event start/end → activity schedule. `date` (all-day) maps through the
* `activityAllDay` conventions; `dateTime` (timed) maps by absolute instant.
* Malformed values (missing end, exclusive end not after start, unparseable)
* → null: the Teqo keeps its state and the forward direction re-asserts
* (fail-safe — a bad Google value never corrupts the activity).
*/
export const googleScheduleToActivityFields = (
event: Pick<GoogleRemoteEvent, 'start' | 'end'>,
): { startAt: string | null; endAt: string | null; allDay: boolean } | null => {
const start = event.start
if (!start) return null
if (start.date) {
if (!isCivilDate(start.date)) return null
const startAt = allDayStartInstant(start.date)
let endAt = startAt
if (event.end?.date) {
if (!isCivilDate(event.end.date) || event.end.date <= start.date) return null
endAt = allDayEndInstantFromExclusive(event.end.date)
}
return { startAt, endAt, allDay: true }
}
if (start.dateTime) {
const startAt = parseInstant(start.dateTime)
if (!startAt || !event.end?.dateTime) return null
const endAt = parseInstant(event.end.dateTime)
if (!endAt) return null
return { startAt, endAt, allDay: false }
}
return null
}
/**
* C165 — fire only for events the mirror does NOT own: the full deterministic
* id decode is null. A manual `teqo3`-looking id decodes (non-null) and stays
* in the mirror namespace — never imported.
*/
export const isForeignGoogleEvent = (event: GoogleRemoteEvent): boolean => {
const id = event.id
return typeof id === 'string' && id.length > 0 && decodeGoogleEventActivityId(id) === null
}
/** C165 — the import window mirrors the push window (90d/365d) with the cut as its floor. */
export type GoogleEventImportWindow = { rangeStart: string; rangeEnd: string }
/**
* C165 — the product cut: only events that START on/after the legal campaign
* start (`CALENDAR_PHASE_ANCHORS.consolidationStart`) are imported — no
* backfill, ever. The comparison is a Bahia civil date (the day turns at
* midnight in Bahia, not in UTC), the same rule the phase anchors use.
*/
export const isImportableGoogleEvent = (
event: GoogleRemoteEvent,
window: GoogleEventImportWindow,
): boolean => {
if (!event.id || event.status === GOOGLE_EVENT_STATUS_CANCELLED) return false
if (!isForeignGoogleEvent(event)) return false
const schedule = googleScheduleToActivityFields(event)
if (!schedule?.startAt) return false
const startMs = Date.parse(schedule.startAt)
const rangeStartMs = Date.parse(window.rangeStart)
const rangeEndMs = Date.parse(window.rangeEnd)
if (Number.isNaN(startMs) || !(startMs >= rangeStartMs && startMs < rangeEndMs)) return false
return (
formatBahiaCivilDate(new Date(schedule.startAt)) >= CALENDAR_PHASE_ANCHORS.consolidationStart
)
}
/**
* Short deterministic digest of the Google event id (FNV-1a, base36). The
* slug suffix must be unique even for two distinct events with the same title
* and start (duplicated invites are real): the event id is the only unique
* input, and hashing keeps the slug reproducible across passes.
*/
const shortGoogleEventHash = (eventId: string): string => {
let hash = 2166136261
for (let index = 0; index < eventId.length; index += 1) {
hash ^= eventId.charCodeAt(index)
hash = Math.imul(hash, 16777619)
}
return (hash >>> 0).toString(36)
}
/**
* C165 — deterministic slug for an imported event: title + occurrence (civil
* date/date-time in Bahia) + event digest. The occurrence suffix keeps two
* instances of a recurring series apart; the digest keeps equal title+start
* events apart.
*/
const importedActivitySlug = (
title: string,
schedule: { startAt: string | null; allDay: boolean },
eventId: string,
): string => {
const occurrence = schedule.allDay
? allDayCivilDateOf(schedule.startAt ?? '')
: formatIsoAsBahiaDateTimeInput(schedule.startAt ?? '')
const suffix = occurrence.replace(/[-:]/g, '')
const base = slugify(title) || 'evento'
return `${base}-${suffix}-${shortGoogleEventHash(eventId)}`
}
/**
* C165 — the activity an imported Google event is born as: `confirmado`,
* title verbatim (no `[Município] ` prefix — that prefix belongs to mirrored
* events), `location → locality`, description deliberately ignored (the text
* lives in Google), and the system-write link that makes the import
* idempotent. Null when the event has no usable title/schedule (skip — never
* invent a title in an immutable field, never pause the pass).
*/
export const buildImportedGoogleEventActivity = (
event: GoogleRemoteEvent,
calendarId: string,
): {
title: string
slug: string
status: 'confirmado'
startAt: string
endAt?: string
allDay: boolean
locality?: string
googleEventId: string
googleCalendarId: string
tags: string[]
} | null => {
if (!event.id) return null
const title = googleTitleFromSummary(event.summary, undefined)
if (!title || title.length < 2 || slugify(title) === '') return null
const schedule = googleScheduleToActivityFields(event)
if (!schedule?.startAt) return null
const locality = event.location?.trim().slice(0, 160)
return {
title,
slug: importedActivitySlug(title, schedule, event.id),
status: 'confirmado',
startAt: schedule.startAt,
...(schedule.endAt ? { endAt: schedule.endAt } : {}),
allDay: schedule.allDay,
...(locality ? { locality } : {}),
googleEventId: event.id,
googleCalendarId: calendarId,
tags: [],
}
}
/**
* C165 — the partial event the engine PATCHes for an imported activity: only
* the fields the Teqo owns from then on (title, schedule, location). No
* `description` (the Google text is preserved by the partial PATCH), no
* `id` (the linked remote id is the target), no prefix on the summary.
*/
export type GoogleImportedEventPatch = {
summary: string
location: string
start: GoogleCalendarEventPayload['start']
end: GoogleCalendarEventPayload['end']
}
export const buildImportedGoogleEventPatch = (
activity: Pick<Activity, 'title' | 'startAt' | 'endAt' | 'allDay' | 'locality'>,
): GoogleImportedEventPatch => {
const { start, end } = buildGoogleEventSchedule(activity)
return {
summary: activity.title,
location: activity.locality?.trim() ?? '',
start,
end,
}
}
/**
* C165 — content equality for imported activities: summary + schedule +
* location. Description is out by design (the Teqo never reads, stores or
* writes the Google description of an imported event).
*/
export const googleImportedEventContentEquals = (
remote: GoogleRemoteEvent,
patch: GoogleImportedEventPatch,
): boolean =>
(remote.summary ?? '') === patch.summary &&
(remote.location ?? '') === patch.location &&
startEndInstantEquals(remote.start, patch.start) &&
startEndInstantEquals(remote.end, patch.end)

View file

@ -7,14 +7,8 @@
*
* This module is pure and client-safe; only the engine touches the network.
*/
import {
allDayEndInstantFromExclusive,
allDayStartInstant,
formatAllDayRangeLabel,
isCivilDate,
} from '@/lib/activityAllDay'
import { formatAllDayRangeLabel } from '@/lib/activityAllDay'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import type { GoogleRemoteEvent } from '@/lib/googleCalendarEventMapping'
/** Body prefix that marks an activity-update entry as written by the reverse direction. */
export const GOOGLE_REVERSE_EDIT_BODY_PREFIX = 'Google Calendar:'
@ -27,9 +21,6 @@ export type GoogleReverseActivityEdit = {
allDay?: boolean | null
}
/** Matches the `title` field cap in the Activity collection — the same bound the Teqo form enforces. */
const ACTIVITY_TITLE_MAX_LENGTH = 160
/**
* D3 — the conflict clock rule: the Google edit wins only when its `updated`
* instant is strictly newer than the activity's last Teqo write, with a small
@ -49,70 +40,6 @@ export const googleEditIsNewer = (
return remoteMs > localMs + toleranceMs
}
/**
* The summary carries a `[Município] ` prefix we wrote; the user edits what
* follows it. Only OUR OWN prefix is stripped (matched against the activity's
* municipality name); anything else is the user's title verbatim — and the
* forward direction re-prefixes it consistently. A summary that is ONLY the
* prefix is structural tampering → null → the Teqo re-asserts.
*/
export const googleTitleFromSummary = (
summary: string | undefined,
municipalityName?: string,
): string | null => {
const raw = summary?.trim()
if (!raw) return null
const stripped =
municipalityName && raw.startsWith(`[${municipalityName}]`)
? raw.slice(municipalityName.length + 2).trim()
: raw
if (!stripped) return null
return stripped.length > ACTIVITY_TITLE_MAX_LENGTH
? stripped.slice(0, ACTIVITY_TITLE_MAX_LENGTH)
: stripped
}
const parseInstant = (value: string | undefined): string | null => {
if (!value) return null
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString()
}
/**
* Event start/end → activity schedule. `date` (all-day) maps through the
* `activityAllDay` conventions; `dateTime` (timed) maps by absolute instant.
* Malformed values (missing end, exclusive end not after start, unparseable)
* → null: the Teqo keeps its state and the forward direction re-asserts
* (fail-safe — a bad Google value never corrupts the activity).
*/
export const googleScheduleToActivityFields = (
event: Pick<GoogleRemoteEvent, 'start' | 'end'>,
): Pick<GoogleReverseActivityEdit, 'startAt' | 'endAt' | 'allDay'> | null => {
const start = event.start
if (!start) return null
if (start.date) {
if (!isCivilDate(start.date)) return null
const startAt = allDayStartInstant(start.date)
let endAt = startAt
if (event.end?.date) {
if (!isCivilDate(event.end.date) || event.end.date <= start.date) return null
endAt = allDayEndInstantFromExclusive(event.end.date)
}
return { startAt, endAt, allDay: true }
}
if (start.dateTime) {
const startAt = parseInstant(start.dateTime)
if (!startAt || !event.end?.dateTime) return null
const endAt = parseInstant(event.end.dateTime)
if (!endAt) return null
return { startAt, endAt, allDay: false }
}
return null
}
const scheduleLabelOf = (
schedule: Pick<GoogleReverseActivityEdit, 'startAt' | 'endAt' | 'allDay'>,
): string | null => {

View file

@ -29,6 +29,17 @@ export const ACTIVITY_RESCHEDULE_FAILED_MESSAGE =
'Não foi possível remarcar a atividade. O horário anterior foi mantido.'
export const ACTIVITY_OUT_OF_SCOPE_MESSAGE =
'O município escolhido está fora do seu escopo de edição. Atualize a página e tente novamente.'
export const ACTIVITY_UNSCOPED_ADVISOR_MESSAGE =
'Só a coordenação e o candidato podem criar ou manter uma atividade sem município.'
export const ACTIVITY_DEMANDS_MUNICIPALITY_MESSAGE =
'Atribua um município para criar demandas desta atividade.'
/**
* C165 — the shared labels of the imported/triage states (overlay option,
* card badges, agenda marker and omnibox chip/seed): one spelling, no drift.
*/
export const ACTIVITY_IMPORTED_LABEL = 'Do Google'
export const ACTIVITY_UNSCOPED_LABEL = 'Sem município'
/**
* C14 — tags are free-form labels the mesa invents (comício, imprensa, etc.).
@ -257,6 +268,10 @@ export const activityRescheduleSchema = z
export const activityCreateSchema = activityFieldsSchema
.extend({
status: z.enum(['confirmado']).default('confirmado'),
// C165 — an activity may be born without a municipality (an imported
// Google event awaiting triage). The base field stays required so the giro
// stop pick keeps its município obligation.
municipality: positiveRelationshipId.nullable().optional(),
})
.superRefine((data, context) => {
validateSchedule(data, context, true)
@ -271,6 +286,8 @@ export const activityUpdateSchema = activityFieldsSchema
.partial()
.extend({
id: positiveRelationshipId,
// C165 — `null` clears the município; only coordination/candidate can.
municipality: positiveRelationshipId.nullable().optional(),
locality: trimmedNullableText(160),
description: trimmedNullableText(4000),
startAt: z.string().datetime().nullable().optional(),

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
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" ALTER COLUMN "municipality_id" DROP NOT NULL;
ALTER TABLE "activity" ADD COLUMN "google_event_id" varchar;
ALTER TABLE "activity" ADD COLUMN "google_calendar_id" varchar;
CREATE UNIQUE INDEX "activity_google_event_id_idx" ON "activity" USING btree ("google_event_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
// NOT NULL first: down cannot succeed while imported (municipality-less)
// activities exist, and failing before dropping anything leaves the schema
// intact for a retry. `IF EXISTS` keeps the retry idempotent.
await db.execute(sql`
ALTER TABLE "activity" ALTER COLUMN "municipality_id" SET NOT NULL;
DROP INDEX IF EXISTS "activity_google_event_id_idx";
ALTER TABLE "activity" DROP COLUMN IF EXISTS "google_event_id";
ALTER TABLE "activity" DROP COLUMN IF EXISTS "google_calendar_id";`)
}

View file

@ -71,6 +71,7 @@ import * as migration_20260913_001200_add_speech_segment_trgm_index from './2026
import * as migration_20260913_145820_add_speech_search_text from './20260913_145820_add_speech_search_text';
import * as migration_20260913_150000_backfill_speech_search_text_trgm_index from './20260913_150000_backfill_speech_search_text_trgm_index';
import * as migration_20260915_122356_add_speech_cut from './20260915_122356_add_speech_cut';
import * as migration_20260915_142058_activity_google_import_link from './20260915_142058_activity_google_import_link';
export const migrations = [
{
@ -436,6 +437,11 @@ export const migrations = [
{
up: migration_20260915_122356_add_speech_cut.up,
down: migration_20260915_122356_add_speech_cut.down,
name: '20260915_122356_add_speech_cut'
name: '20260915_122356_add_speech_cut',
},
{
up: migration_20260915_142058_activity_google_import_link.up,
down: migration_20260915_142058_activity_google_import_link.down,
name: '20260915_142058_activity_google_import_link'
},
];

View file

@ -788,7 +788,7 @@ export interface Activity {
publicEvent?: boolean | null;
startAt?: string | null;
endAt?: string | null;
municipality: number | Municipality;
municipality?: (number | null) | Municipality;
locality?: string | null;
organizations?: (number | Organization)[] | null;
responsible?:
@ -834,6 +834,8 @@ export interface Activity {
resultRecordedBy?: (number | null) | CampaignUser;
resultRecordedAt?: string | null;
lastMirroredChangeAt?: string | null;
googleEventId?: string | null;
googleCalendarId?: string | null;
createdBy?: (number | null) | CampaignUser;
updatedAt: string;
createdAt: string;
@ -2112,6 +2114,8 @@ export interface ActivitySelect<T extends boolean = true> {
resultRecordedBy?: T;
resultRecordedAt?: T;
lastMirroredChangeAt?: T;
googleEventId?: T;
googleCalendarId?: T;
createdBy?: T;
updatedAt?: T;
createdAt?: T;

View file

@ -4,6 +4,7 @@
import type { Access, FieldAccess, PayloadRequest, Where } from 'payload'
import { relationshipId } from '@/lib/relationship'
import type { CampaignUser } from '@/payload-types'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import {
@ -27,15 +28,20 @@ const canStaffCreateActivity: FieldAccess = async ({ req }) => {
* a per-município constraint on create), so the Edição axis narrows it for
* advisors: a `somente_leitura` advisor creates nothing. The giro batch checks
* the stops against the WRITE scope on top (`getWritableMunicipalityIds`).
* C165 — an activity without município is coordination/candidate-only (the
* `supporter` precedent), so an advisor's create requires one; the carteira
* membership of that município is still enforced by the action.
*/
export const canCreateActivity: Access = async ({ req }) => {
export const canCreateActivity: Access = async ({ data, req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser || !isCampaignStaff(currentUser)) return false
if (currentUser.role === 'advisor') return advisorEditingAccess(currentUser) !== 'none'
if (isCampaignUnrestricted(currentUser)) return true
if (currentUser.role !== 'advisor') return false
if (advisorEditingAccess(currentUser) === 'none') return false
return true
return relationshipId(data?.municipality) !== null
}
export const canCampaignUserRescheduleActivity = (
@ -49,16 +55,24 @@ const advisorActivityScopeWhere = async (
): Promise<Where> => {
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return {
or: [
// C90 — polymorphic `responsible`: the only scalar query the adapter
// supports on a multi-relation relationship is the object notation
// with `equals` (see @payloadcms/drizzle sanitizeQueryValue).
// C165 — the `and` closes the responsible branch for an activity WITHOUT
// município: it is coordination/candidate-only (precedente `supporter`),
// so an advisor listed as responsible must not see/update it.
and: [
{ municipality: { exists: true } },
{
responsible: {
equals: { relationTo: 'campaignUser', value: currentUser.id },
},
or: [
// C90 — polymorphic `responsible`: the only scalar query the adapter
// supports on a multi-relation relationship is the object notation
// with `equals` (see @payloadcms/drizzle sanitizeQueryValue).
{
responsible: {
equals: { relationTo: 'campaignUser', value: currentUser.id },
},
},
advisorMunicipalityScopeWhere('municipality', municipalityIDs),
],
},
advisorMunicipalityScopeWhere('municipality', municipalityIDs),
],
}
}
@ -72,8 +86,9 @@ export const canReadActivity: Access = async ({ req }): Promise<boolean | Where>
if (isCampaignUnrestricted(currentUser)) return true
if (currentUser.role === 'advisor') {
// C141 — Visão "Tudo": the advisor sees the whole activity agenda.
if (currentUser.visibility === 'tudo') return true
// C141 — Visão "Tudo": the advisor sees the whole activity agenda, except
// C165 activities without município (coordination/candidate-only).
if (currentUser.visibility === 'tudo') return { municipality: { exists: true } }
return advisorActivityScopeWhere(req, currentUser)
}
@ -84,6 +99,7 @@ export const canReadActivity: Access = async ({ req }): Promise<boolean | Where>
* C141 — the Edição axis rules activity updates. `tudo` widens to every
* activity; `somente_leitura` closes updates entirely; the carteira branch
* keeps the same row scope as read (responsible OR municipality).
* C165 — `tudo` never widens into activities without município.
*/
export const canUpdateActivity: Access = async ({ req }): Promise<boolean | Where> => {
if (isPayloadAdmin(req.user)) return true
@ -96,7 +112,7 @@ export const canUpdateActivity: Access = async ({ req }): Promise<boolean | Wher
const editingAccess = advisorEditingAccess(currentUser)
if (editingAccess === 'none') return false
if (editingAccess === 'tudo') return true
if (editingAccess === 'tudo') return { municipality: { exists: true } }
return advisorActivityScopeWhere(req, currentUser)
}

View file

@ -6,6 +6,7 @@ import {
boundedJsonFormValue,
checkboxFormValue,
FormDataBoundaryError,
nullableRelationshipFormValue,
optionalFormText,
repeatedRelationshipFormValues,
requiredFormText,
@ -166,7 +167,9 @@ const parseSharedActivityFormData = (formData: FormData) => {
publicEvent: checkboxFormValue(formData, 'publicEvent'),
startAt: parseScheduleFormField(formData, 'startAt', allDay),
endAt: parseScheduleFormField(formData, 'endAt', allDay),
municipality: requiredRelationshipFormValue(formData, 'municipality'),
// C165 — empty input means "Sem município" (`null`), not a missing field:
// the overlay always renders the hidden input.
municipality: nullableRelationshipFormValue(formData, 'municipality'),
locality: optionalFormText(formData, 'locality'),
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
responsible: parseResponsiblesFormData(formData),

View file

@ -9,7 +9,7 @@ import {
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
import { activityStatusLabels } from '@/lib/schemas/activity'
import { ACTIVITY_UNSCOPED_LABEL, activityStatusLabels } from '@/lib/schemas/activity'
import {
activityTabLabels,
activityTabs,
@ -38,7 +38,7 @@ const parseStateFromParams = (
const setExclusiveField = (
state: ActivityListState,
field: 'tag' | 'status' | 'municipality',
field: 'tag' | 'status' | 'municipality' | 'unscoped',
value: string | undefined,
): ActivityListState => {
const params = buildActivityListSearchParams(withPageReset(state))
@ -48,10 +48,14 @@ const setExclusiveField = (
raw.tag = value
} else if (field === 'status') {
raw.status = value
} else {
} else if (field === 'municipality') {
// C165 — picking a município leaves the "Sem município" triage filter.
raw.municipality = value
delete raw.unscoped
} else if (field === 'unscoped') {
raw.unscoped = value
delete raw.municipality
}
return parseStateFromParams(raw)
}
@ -104,6 +108,11 @@ export const buildActivityOmniboxChips = ({
})
}
if (state.unscoped) {
// C165 — the triage filter: activities without a município.
chips.push({ id: 'unscoped', label: ACTIVITY_UNSCOPED_LABEL })
}
if (state.municipality) {
chips.push({
id: `municipality:${state.municipality}`,
@ -183,6 +192,16 @@ export const buildActivityOmniboxSuggestionSeeds = ({
)
}
// C165 — the triage entry: imported Google activities awaiting a município.
seeds.push(
createOmniboxSuggestionSeed({
id: 'unscoped',
group: 'Município',
label: ACTIVITY_UNSCOPED_LABEL,
keywords: ['municipio', 'sem', 'importado', 'google'],
}),
)
return seeds
}
@ -229,6 +248,13 @@ export const applyActivityOmniboxSuggestion = ({
return { kind: 'url', state: next }
}
if (suggestionId === 'unscoped') {
const next = state.unscoped
? setExclusiveField(state, 'unscoped', undefined)
: setExclusiveField(state, 'unscoped', '1')
return { kind: 'url', state: next }
}
if (suggestionId.startsWith('municipality:')) {
const value = suggestionId.slice(13)
const next =
@ -262,6 +288,10 @@ export const removeActivityOmniboxChip = ({
return { kind: 'url', state: setExclusiveField(state, 'status', undefined) }
}
if (chipId === 'unscoped') {
return { kind: 'url', state: setExclusiveField(state, 'unscoped', undefined) }
}
if (chipId.startsWith('municipality:')) {
return { kind: 'url', state: setExclusiveField(state, 'municipality', undefined) }
}

View file

@ -3,6 +3,11 @@ import {
ACTIVITY_DUPLICATE_TITLE_MESSAGE,
ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE,
} from '@/lib/activityOverlayMessages'
import {
ACTIVITY_DEMANDS_MUNICIPALITY_MESSAGE,
ACTIVITY_OUT_OF_SCOPE_MESSAGE,
ACTIVITY_UNSCOPED_ADVISOR_MESSAGE,
} from '@/lib/schemas/activity'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
/**
@ -34,6 +39,11 @@ export const mapActivityOverlayError = (error: unknown): ActivityOverlayErrorRes
}
const mapped = mapCampaignFormActionError({
error,
safeMessages: [
ACTIVITY_OUT_OF_SCOPE_MESSAGE,
ACTIVITY_UNSCOPED_ADVISOR_MESSAGE,
ACTIVITY_DEMANDS_MUNICIPALITY_MESSAGE,
],
genericMessage: ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE,
})
return {

View file

@ -21,7 +21,9 @@ const isActivityTitleQueryReady = (query: string) =>
const toActivityRelationOption = (activity: {
id: number
title: string
municipality: unknown
// C165 — optional in the generated type (the município is nullable) and in
// partial `select`s; a municipality-less activity is simply not an option.
municipality?: unknown
}): ActivityRelationOption | null => {
const municipalityId = relationshipId(activity.municipality)
if (!municipalityId) return null

View file

@ -43,6 +43,8 @@ export type ActivityListState = {
tag?: string
status?: ActivityStatus
municipality?: number
/** C165 — the triage filter: activities without a município (imported). */
unscoped?: boolean
}
type RawSearchParams = CampaignListRawSearchParams
@ -386,7 +388,15 @@ export const activityDefaultCreatePrefill = (
): { startAt: string; endAt: string } | null =>
activitySlotPrefill({ allDay: true, dateStr: formatBahiaCivilDate(now) })
const activityListParamNames = ['q', 'tab', 'tag', 'status', 'municipality', 'page'] as const
const activityListParamNames = [
'q',
'tab',
'tag',
'status',
'municipality',
'unscoped',
'page',
] as const
const activityListParamNameSet = new Set<string>(activityListParamNames)
@ -411,7 +421,10 @@ export const parseActivityListParams = (params: RawSearchParams): ActivityListSt
const tag = rawTag?.trim() ? rawTag.trim() : undefined
const rawStatus = tab === 'todos' ? firstValue(params.status) : undefined
const status = isActivityStatus(rawStatus) ? rawStatus : undefined
const municipality = strictDecimalInteger(firstValue(params.municipality))
// C165 — `unscoped` and `municipality` are mutually exclusive; `unscoped`
// wins a hand-crafted URL with both (the canonical form keeps one).
const unscoped = firstValue(params.unscoped) === '1'
const municipality = unscoped ? undefined : strictDecimalInteger(firstValue(params.municipality))
return {
page: rawPage ?? 1,
@ -419,6 +432,7 @@ export const parseActivityListParams = (params: RawSearchParams): ActivityListSt
...(q ? { q } : {}),
...(tag ? { tag } : {}),
...(status ? { status } : {}),
...(unscoped ? { unscoped: true } : {}),
...(municipality ? { municipality } : {}),
}
}
@ -437,7 +451,11 @@ export const buildActivityListWhere = (state: ActivityListState, now: Date): Whe
}
if (state.tag) filters.push({ tags: { contains: state.tag } })
if (state.municipality) filters.push({ municipality: { equals: state.municipality } })
if (state.unscoped) {
filters.push({ municipality: { exists: false } })
} else if (state.municipality) {
filters.push({ municipality: { equals: state.municipality } })
}
if (state.tab === 'proximos') {
filters.push({ status: { equals: 'confirmado' } })
@ -462,6 +480,7 @@ export const buildActivityListSearchParams = (
tag: state.tag,
status: state.status,
municipality: state.municipality === undefined ? undefined : String(state.municipality),
unscoped: state.unscoped ? '1' : undefined,
})
const params = new URLSearchParams()
@ -469,6 +488,7 @@ export const buildActivityListSearchParams = (
if (canonicalState.tab !== 'proximos') params.set('tab', canonicalState.tab)
if (canonicalState.tag) params.set('tag', canonicalState.tag)
if (canonicalState.status) params.set('status', canonicalState.status)
if (canonicalState.unscoped) params.set('unscoped', '1')
if (canonicalState.municipality) params.set('municipality', String(canonicalState.municipality))
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))

View file

@ -126,6 +126,8 @@ export const activityAgendaSelect = {
endAt: true,
municipality: true,
locality: true,
// C165 — the link is what makes an activity "Do Google".
googleEventId: true,
} as const
export const activityListSelect = {
@ -148,6 +150,8 @@ export type ActivityAgendaEvent = {
municipality: ActivityMunicipalitySummary | null
locality: string | null
canReschedule: boolean
/** C165 — imported from the Google Calendar (link present). */
importedFromGoogle: boolean
}
export const toActivityAgendaEvent = (
@ -172,6 +176,7 @@ export const toActivityAgendaEvent = (
municipality: activityMunicipalitySummary(activity.municipality),
locality: activity.locality ?? null,
canReschedule: canCampaignUserRescheduleActivity(user, deputyPresent),
importedFromGoogle: Boolean(activity.googleEventId),
}
}
@ -190,6 +195,8 @@ export type ActivityListViewModel = {
locationLabel: string
responsibles: ActivityResponsibleDisplay[]
taskProgress: { done: number; total: number }
/** C165 — imported from the Google Calendar (link present). */
importedFromGoogle: boolean
}
const relationshipName = (
@ -217,6 +224,7 @@ export const toActivityListViewModel = (activity: Activity): ActivityListViewMod
done: activity.taskDoneCount ?? 0,
total: activity.taskTotal ?? 0,
},
importedFromGoogle: Boolean(activity.googleEventId),
}
}

View file

@ -158,8 +158,15 @@ export const buildActivityWindowRange = (): { rangeStart: string; rangeEnd: stri
* clauses — shared by the iCal feed and the C114 Google mirror so both
* surfaces keep the SAME horizon by definition. The feed adds its per-feed
* filters (municipality/deputy/tag + advisor scope) on top.
* C165 — `includeCancelled` drops only the `status != cancelado` clause: the
* imported-events pass needs the cancelled activities to re-assert their
* cancellation on the same remote event.
*/
export const buildActivityWindowWhereClauses = (rangeStart: string, rangeEnd: string): Where[] => [
export const buildActivityWindowWhereClauses = (
rangeStart: string,
rangeEnd: string,
options: { includeCancelled?: boolean } = {},
): Where[] => [
{ startAt: { less_than: rangeEnd } },
{
or: [
@ -169,7 +176,7 @@ export const buildActivityWindowWhereClauses = (rangeStart: string, rangeEnd: st
},
],
},
{ status: { not_equals: 'cancelado' } },
...(options.includeCancelled ? [] : [{ status: { not_equals: 'cancelado' as const } }]),
]
const buildFeedWhere = (

View file

@ -101,6 +101,17 @@ export type GoogleCalendarClient = {
) => Promise<GoogleRemoteEvent[]>
insertEvent: (calendarId: string, event: GoogleRemoteEvent) => Promise<void>
updateEvent: (calendarId: string, eventId: string, event: GoogleRemoteEvent) => Promise<void>
/**
* C165 — partial update (`events.patch`) for events the Teqo did NOT create:
* only the fields the campaign owns go in the body, so the user's
* description, guests, reminders and Meet link survive an edit made here.
* `sendUpdates: 'none'` — the campaign's edit is not a broadcast.
*/
patchEvent: (
calendarId: string,
eventId: string,
patch: Partial<GoogleRemoteEvent>,
) => Promise<void>
deleteEvent: (calendarId: string, eventId: string) => Promise<void>
/** C115 — push channel for the Google→Teqo direction (events.watch). */
watchEvents: (
@ -388,6 +399,14 @@ export const createGoogleCalendarClient = (
})
}
const patchEvent: GoogleCalendarClient['patchEvent'] = async (calendarId, eventId, patch) => {
await apiFetch(`${eventsUrl(calendarId, eventId)}?sendUpdates=none`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
}
const deleteEvent: GoogleCalendarClient['deleteEvent'] = async (calendarId, eventId) => {
await apiFetch(eventsUrl(calendarId, eventId), { method: 'DELETE' })
}
@ -437,6 +456,7 @@ export const createGoogleCalendarClient = (
listEvents,
insertEvent,
updateEvent,
patchEvent,
deleteEvent,
watchEvents,
stopChannel,

View file

@ -8,19 +8,24 @@ import type { Payload, PayloadRequest, Where } from 'payload'
import {
activityMunicipalityIdOf,
buildGoogleEventPayload,
buildImportedGoogleEventActivity,
buildImportedGoogleEventPatch,
decodeGoogleEventActivityId,
GOOGLE_EVENT_STATUS_CANCELLED,
googleEventContentEquals,
googleEventIdForActivity,
googleImportedEventContentEquals,
googleScheduleToActivityFields,
googleStartEndInstantEquals,
googleTitleFromSummary,
isForeignGoogleEvent,
isImportableGoogleEvent,
type GoogleRemoteEvent,
} from '@/lib/googleCalendarEventMapping'
import {
buildGoogleReverseCancelBody,
buildGoogleReverseUpdateBody,
googleEditIsNewer,
googleScheduleToActivityFields,
googleTitleFromSummary,
type GoogleReverseActivityEdit,
} from '@/lib/googleCalendarReverseEdit'
import {
@ -436,7 +441,14 @@ const deleteRemoteEvent = async (
}
}
type SyncCounts = { created: number; updated: number; deleted: number; reverseEdits: number }
type SyncCounts = {
created: number
updated: number
deleted: number
reverseEdits: number
/** C165 — foreign events that became activities in this pass. */
imported: number
}
/**
* C115 — the reconciliation's last-seen snapshot (JSONB on the sync row).
@ -541,6 +553,224 @@ const googleEditableContentEquals = (
googleStartEndInstantEquals(remote.start, payload.start) &&
googleStartEndInstantEquals(remote.end, payload.end)
type ImportedEventsCounts = Omit<SyncCounts, 'created'>
type ImportedEventsPassInput = {
payload: Payload
req: PayloadRequest | undefined
client: GoogleCalendarClient
calendarId: string
remoteEvents: GoogleRemoteEvent[]
lastSeenIds: Set<string>
remoteEventIds: Set<string>
window: { rangeStart: string; rangeEnd: string }
activityWhere: Where | undefined
}
/**
* C165 — the import/link pass: events on the campaign calendar that the Teqo
* did NOT create (full id decode is null), start on/after the campaign's
* legal start and sit in the mirror window become activities of their own.
*
* The link (`googleEventId` + `googleCalendarId`, system-write) is what keeps
* the pass idempotent and what makes the forward direction patch the SAME
* remote event instead of inserting a second `teqo…` one. Foreign events are
* never deleted: a Teqo cancellation patches `status: cancelled`, a Teqo
* "reopen" patches it back. A previously-seen linked event that vanished from
* Google cancels its activity (same rule as the native mirror); a linked
* activity hard-deleted in the Teqo leaves the event alone (its id stays in
* the last-seen snapshot, so it is never re-imported).
*
* Runs inside `runSyncPass`, sharing its list window, last-seen snapshot and
* CAS write — no second listing, no second snapshot writer.
*/
const runImportedEventsPass = async ({
payload,
req,
client,
calendarId,
remoteEvents,
lastSeenIds,
remoteEventIds,
window,
activityWhere,
}: ImportedEventsPassInput): Promise<ImportedEventsCounts> => {
const linked = await payload.find({
collection: 'activity',
depth: 0,
limit: 0,
pagination: false,
where: {
and: [
{ googleEventId: { exists: true } },
{ googleCalendarId: { equals: calendarId } },
...buildActivityWindowWhereClauses(window.rangeStart, window.rangeEnd, {
includeCancelled: true,
}),
...(activityWhere ? [activityWhere] : []),
],
},
// Intentional admin bypass: same espelho-cheio rationale as loadSyncActivities.
overrideAccess: true,
req,
})
const linkedByEventId = new Map<string, Activity>()
for (const activity of linked.docs) {
if (activity.googleEventId) linkedByEventId.set(activity.googleEventId, activity)
}
let imported = 0
let updated = 0
let deleted = 0
let reverseEdits = 0
for (const event of remoteEvents) {
const eventId = event.id
if (!eventId || !isForeignGoogleEvent(event)) continue
const activity = linkedByEventId.get(eventId)
if (!activity) {
// Previously seen without a link: its imported activity was hard-deleted
// in the Teqo. The id STAYS in the snapshot — dropping it here would
// re-import the same event on the very next pass.
if (lastSeenIds.has(eventId)) {
remoteEventIds.add(eventId)
continue
}
if (!isImportableGoogleEvent(event, window)) continue
const data = buildImportedGoogleEventActivity(event, calendarId)
if (!data) continue
// Pre-check right before the insert: the unique `googleEventId` is the
// lock of last resort (create outside the caller's transaction keeps a
// failed insert from poisoning it), and this read narrows the race —
// the common concurrent case converges here, without a failed write.
const existingLink = await payload.find({
collection: 'activity',
depth: 0,
limit: 1,
pagination: false,
where: { googleEventId: { equals: eventId } },
// Intentional admin bypass: the link is system state.
overrideAccess: true,
req,
})
const winner = existingLink.docs[0]
if (winner) {
linkedByEventId.set(eventId, winner)
remoteEventIds.add(eventId)
continue
}
try {
const created = await payload.create({
collection: 'activity',
data,
depth: 0,
// Intentional admin bypass: the Google editor acts through the
// calendar, and the link fields are system-write by design.
overrideAccess: true,
context: { mutationKind: 'googleCalendarSync' },
// Deliberately OUTSIDE the caller's transaction (no `req`): the
// import is Google-sourced and independent from the save that
// triggered this pass. Sharing its transaction would let a unique
// violation in a concurrent import poison the user's save; here the
// recovery read below runs in its own session.
})
linkedByEventId.set(eventId, created)
imported += 1
} catch (error) {
// A concurrent pass may still have won between the pre-check and the
// insert: converge on the winner; any other failure is real and
// pauses the pass.
const existing = await payload.find({
collection: 'activity',
depth: 0,
limit: 1,
pagination: false,
where: { googleEventId: { equals: eventId } },
// Intentional admin bypass: same rationale as the pre-check above.
overrideAccess: true,
req,
})
if (!existing.docs[0]) throw error
linkedByEventId.set(eventId, existing.docs[0])
}
remoteEventIds.add(eventId)
continue
}
// The linked event is ours to reconcile; it stays in the snapshot even if
// this pass only re-asserts it.
remoteEventIds.add(eventId)
if (activity.status === 'cancelado') {
// Teqo cancelled it: the foreign event is never deleted, only trashed —
// the event continues to exist in the user's Google (and its history).
if (event.status !== GOOGLE_EVENT_STATUS_CANCELLED) {
await client.patchEvent(calendarId, eventId, {
status: GOOGLE_EVENT_STATUS_CANCELLED,
})
deleted += 1
}
continue
}
if (event.status === GOOGLE_EVENT_STATUS_CANCELLED) {
// Same clock rule as the native mirror: a NEWER Google cancellation
// cancels the activity; an older one is re-asserted (reopen).
if (activity.status === 'confirmado') {
if (googleEditIsNewer(event.updated, activity.lastMirroredChangeAt ?? activity.updatedAt)) {
await cancelActivityFromGoogle(payload, req, activity)
reverseEdits += 1
} else {
await client.patchEvent(calendarId, eventId, { status: 'confirmed' })
updated += 1
}
}
continue
}
const importedPatch = buildImportedGoogleEventPatch(activity)
if (googleImportedEventContentEquals(event, importedPatch)) continue
if (
activity.status === 'confirmado' &&
googleEditIsNewer(event.updated, activity.lastMirroredChangeAt ?? activity.updatedAt)
) {
// No `[Município] ` prefix to strip here: imported titles are verbatim.
const reverseEdit = googleReverseEditOf(event, undefined)
if (reverseEdit) {
const body = buildGoogleReverseUpdateBody(activity, reverseEdit)
await applyGoogleReverseActivityPatch(payload, req, activity, reverseEdit, body)
reverseEdits += 1
continue
}
}
// Teqo newer/different: PATCH only the fields the campaign owns, on the
// SAME remote event (never `insertEvent`, never the deterministic id).
await client.patchEvent(calendarId, eventId, importedPatch)
updated += 1
}
for (const [eventId, activity] of linkedByEventId) {
if (remoteEventIds.has(eventId)) continue
// Seen before and now gone: the user permanently removed the event (trash
// still shows as `cancelled`, handled above). Never-seen ids are simply
// out of this pass's listing.
if (!lastSeenIds.has(eventId)) continue
if (activity.status !== 'confirmado') continue
await cancelActivityFromGoogle(payload, req, activity)
reverseEdits += 1
}
return { imported, updated, deleted, reverseEdits }
}
/**
* C115 — one full pass, now BIDIRECTIONAL (D2): for every event of ours the
* diff decides the direction by the clock rule (D3) — Google wins when its
@ -593,6 +823,7 @@ const runSyncPass = async (
let updated = 0
let deleted = 0
let reverseEdits = 0
let imported = 0
// Ids PRESENT in this pass's remote list (the second loop's "exists" check)
// — never removed mid-pass. Ids the engine DELETES are tracked separately so
@ -676,6 +907,24 @@ const runSyncPass = async (
updated += 1
}
// C165 — foreign events (id does not decode) are imported/linked here,
// sharing this pass's listing, last-seen snapshot and CAS write.
const importedCounts = await runImportedEventsPass({
payload,
req,
client,
calendarId,
remoteEvents,
lastSeenIds,
remoteEventIds,
window: { rangeStart, rangeEnd },
activityWhere,
})
imported += importedCounts.imported
updated += importedCounts.updated
deleted += importedCounts.deleted
reverseEdits += importedCounts.reverseEdits
for (const [activityId, activity] of wantedById) {
const eventId = googleEventIdForActivity(activityId)
if (remoteEventIds.has(eventId)) continue
@ -689,6 +938,15 @@ const runSyncPass = async (
continue
}
// C165 — imported activities are reconciled by their Google link above;
// a municipality-less activity waits for triage before entering the
// mirror (the original cut: untriaged events are not official agenda).
// The seen-and-gone rule above still applies to a municipality-less
// native activity whose mirrored event was removed in Google.
if (activity.googleEventId || activityMunicipalityIdOf(activity.municipality) === undefined) {
continue
}
const municipalityId = activityMunicipalityIdOf(activity.municipality)
const municipalityName = municipalityId ? municipalityNames.get(municipalityId) : undefined
await client.insertEvent(calendarId, buildGoogleEventPayload(activity, municipalityName))
@ -704,12 +962,12 @@ const runSyncPass = async (
})
// Only log if there were actual changes or errors
if (created > 0 || updated > 0 || deleted > 0 || reverseEdits > 0) {
if (created > 0 || updated > 0 || deleted > 0 || reverseEdits > 0 || imported > 0) {
payload.logger.info(
`[GoogleCalendarSync] Passada concluída com mudanças: created=${created}, updated=${updated}, deleted=${deleted}, reverseEdits=${reverseEdits}, calendarId=${calendarId}`,
`[GoogleCalendarSync] Passada concluída com mudanças: created=${created}, updated=${updated}, deleted=${deleted}, reverseEdits=${reverseEdits}, imported=${imported}, calendarId=${calendarId}`,
)
}
return { created, updated, deleted, reverseEdits }
return { created, updated, deleted, reverseEdits, imported }
}
type SyncStatePatch = Partial<
@ -1183,6 +1441,7 @@ export const runCampaignCalendarSync = async (
updated: 0,
deleted: 0,
reverseEdits: 0,
imported: 0,
at,
}
}
@ -1227,7 +1486,15 @@ export const runCampaignCalendarSync = async (
// derived connection state to `error`, which offers reconnect in one click.
Object.assign(patch, oauthErrorPatchFor(error, at) ?? {})
await recordSyncState(payload, options.req, patch)
return { status: 'paused', created: 0, updated: 0, deleted: 0, reverseEdits: 0, at }
return {
status: 'paused',
created: 0,
updated: 0,
deleted: 0,
reverseEdits: 0,
imported: 0,
at,
}
}
}

View file

@ -23,9 +23,13 @@ const openAgendaSlot = async ({
}: {
campaign: { baseURL: string }
page: import('@playwright/test').Page
municipalityId: number
/** C165 — omit to open the agenda unfiltered (the triage create). */
municipalityId?: number
}) => {
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipalityId}`)
const agendaUrl = municipalityId
? `${campaign.baseURL}/campanha/agenda?municipality=${municipalityId}`
: `${campaign.baseURL}/campanha/agenda`
await page.goto(agendaUrl)
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
await expect(slotLocator).toBeVisible()
@ -85,7 +89,7 @@ test.describe('Atividades — registro-fundação', () => {
expect(pairBoxes.endX).toBeGreaterThan(pairBoxes.startX)
await page.getByLabel('Título *').fill(activityTitle)
// The municipality filter prefills the field; the times come from the slot.
await expect(page.getByLabel('Município *')).toHaveValue(municipality.name)
await expect(page.getByLabel('Município (opcional)')).toHaveValue(municipality.name)
// C90 — the unified polymorphic selector replaces the old Contact
// responsável + assessores/liderança fields.
@ -276,7 +280,7 @@ test.describe('Agenda — calendário operacional', () => {
])
expect(startLabel).toMatch(/\d{2}\/\d{2}\/\d{4}/)
expect(endLabel).toMatch(/\d{2}\/\d{2}\/\d{4}/)
await expect(page.getByLabel('Município *')).toHaveValue(municipality.name)
await expect(page.getByLabel('Município (opcional)')).toHaveValue(municipality.name)
// C123 — the time selects are INLINE, visible without opening anything.
await expect(page.getByLabel('Hora de Início')).toHaveValue('14')
await expect(page.getByLabel('Minuto de Início')).toHaveValue('00')
@ -405,7 +409,7 @@ test.describe('Agenda — calendário operacional', () => {
await expect(editModal).toBeVisible()
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
await expect(editModal.getByLabel('Título *')).toHaveValue(title, { timeout: 30_000 })
await expect(editModal.getByLabel('Município *')).toHaveValue(municipality.name)
await expect(editModal.getByLabel('Município (opcional)')).toHaveValue(municipality.name)
await expect(editModal.getByLabel('Hora de Início')).toHaveValue('14')
await expect(editModal.getByLabel('Minuto de Término')).toHaveValue('30')
// C123 — the edit overlay carries the id the old /editar page lost (C14
@ -517,6 +521,36 @@ test.describe('Agenda — calendário operacional', () => {
await expect(page.getByRole('button', { name: 'Hoje', exact: true })).toHaveCount(0)
await expect(page.getByRole('grid').first()).toBeVisible()
})
test('cria atividade sem município e a triagem mostra o badge "Sem município" (C165)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const title = fixtures.value('Compromisso sem município')
await campaign.login(page, coordinator.email!, coordinator.password)
// Sem filtro de município: o overlay nasce no estado "Sem município".
await openAgendaSlot({ campaign, page })
const modal = page.getByRole('dialog', { name: 'Nova atividade' })
await expect(modal).toBeVisible()
await page.getByLabel('Título *').fill(title)
await expect(page.getByLabel('Município (opcional)')).toHaveValue('Sem município')
await expect(page.getByText(/só coordenação e candidato veem/)).toBeVisible()
await page.getByRole('button', { name: 'Salvar', exact: true }).click()
await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
// A triagem: o filtro "Sem município" da omnibox lista o card com o badge.
await page.goto(`${campaign.baseURL}/campanha/atividades?tab=todos&unscoped=1`)
await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('Sem município', { exact: true }).first()).toBeVisible()
// Nativa (criada no Teqo), não importada: sem badge "Do Google".
await expect(page.getByText('Do Google', { exact: true })).toHaveCount(0)
await expect(page.getByText('Importado automaticamente')).toHaveCount(0)
})
})
test.describe('Atividades — agenda mobile (C103)', () => {
@ -547,7 +581,7 @@ test.describe('Atividades — agenda mobile (C103)', () => {
// Label-less form: the placeholder gives context, the sr-only label keeps
// the field addressable and accessible.
await page.getByLabel('Título *').fill(title)
await expect(page.getByLabel('Município *')).toHaveValue(municipality.name)
await expect(page.getByLabel('Município (opcional)')).toHaveValue(municipality.name)
// The footer is FIXED: "Salvar" is visible without scrolling on a small
// viewport — the acceptance center of C103.

View file

@ -14,11 +14,15 @@ import {
loadActivityAgendaEventsRecord,
loadActivityEditDraftRecord,
rescheduleActivityRecord,
updateActivityRecord,
} from '@/app/(campaign)/campanha/actions/activity'
import { allDayEndInstant, allDayStartInstant } from '@/lib/activityAllDay'
import { relationshipId } from '@/lib/relationship'
import {
ACTIVITY_DEMANDS_MUNICIPALITY_MESSAGE,
ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE,
ACTIVITY_OUT_OF_SCOPE_MESSAGE,
ACTIVITY_UNSCOPED_ADVISOR_MESSAGE,
activityCreateSchema,
activityUpdateSchema,
} from '@/lib/schemas/activity'
@ -96,13 +100,185 @@ describe('activity domain', () => {
).toBe(false)
})
it('requires a municipality on create', () => {
const result = activityCreateSchema.safeParse({
title: campaignFixtures().value('Atividade sem município'),
tags: ['Caminhada'],
status: 'confirmado',
it('allows an activity without a municipality for coordination and blocks advisors', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
// C165 — imported Google events are born without a município: the schema
// accepts it and the create access enforces the role split.
expect(
activityCreateSchema.safeParse({
title: fixtures.value('Atividade sem município'),
tags: ['Caminhada'],
status: 'confirmado',
startAt: new Date(Date.now() + 86_400_000).toISOString(),
municipality: null,
}).success,
).toBe(true)
const coordinatorCreate = await canCreateActivity({
req: stub<PayloadRequest>({ user: coordinator, payload, context: {} }),
data: { title: 'Sem município' },
})
expect(result.success).toBe(false)
expect(coordinatorCreate).toBe(true)
const advisorCreateWithoutMunicipality = await canCreateActivity({
req: stub<PayloadRequest>({ user: advisor, payload, context: {} }),
data: { title: 'Sem município' },
})
expect(advisorCreateWithoutMunicipality).toBe(false)
const advisorCreateWithMunicipality = await canCreateActivity({
req: stub<PayloadRequest>({ user: advisor, payload, context: {} }),
data: { title: 'Com município', municipality: municipality.id },
})
expect(advisorCreateWithMunicipality).toBe(true)
})
it('creates and clears a municipality-less activity for coordination only (C165)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
const outside = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
const activity = await createActivityRecord(payload, coordinator, {
title: fixtures.value('Atividade sem município'),
tags: [],
status: 'confirmado',
startAt: new Date(Date.now() + 86_400_000).toISOString(),
municipality: null,
})
fixtures.own('activity', activity.id)
expect(relationshipId(activity.municipality)).toBeNull()
// Demandas continuam exigindo município (a demanda é entidade municipal).
await expect(
createActivityRecord(
payload,
coordinator,
{
...validActivityInput(municipality.id),
title: fixtures.value('Atividade sem município com demanda'),
municipality: null,
},
[{ title: 'Panfletos', kind: 'material' }],
),
).rejects.toThrow(ACTIVITY_DEMANDS_MUNICIPALITY_MESSAGE)
// O advisor não cria sem município (access + guarda da action)…
await expect(
createActivityRecord(payload, advisor, {
title: fixtures.value('Sem município do assessor'),
tags: [],
status: 'confirmado',
startAt: new Date(Date.now() + 86_400_000).toISOString(),
municipality: null,
}),
).rejects.toThrow(ACTIVITY_UNSCOPED_ADVISOR_MESSAGE)
// …nem aponta para um município fora da carteira.
await expect(
createActivityRecord(payload, advisor, {
...validActivityInput(outside.id),
title: fixtures.value('Fora da carteira'),
}),
).rejects.toThrow(ACTIVITY_OUT_OF_SCOPE_MESSAGE)
// Updates: o coordenador limpa o município; o advisor não pode.
const cleared = await updateActivityRecord(payload, coordinator, {
id: activity.id,
startAt: activity.startAt,
municipality: null,
})
expect(relationshipId(cleared.municipality)).toBeNull()
const advisorActivity = await createActivityRecord(payload, advisor, {
...validActivityInput(municipality.id),
title: fixtures.value('Com município do assessor'),
})
fixtures.own('activity', advisorActivity.id)
await expect(
updateActivityRecord(payload, advisor, {
id: advisorActivity.id,
startAt: advisorActivity.startAt,
municipality: null,
}),
).rejects.toThrow(ACTIVITY_UNSCOPED_ADVISOR_MESSAGE)
await expect(
updateActivityRecord(payload, advisor, {
id: advisorActivity.id,
startAt: advisorActivity.startAt,
municipality: outside.id,
}),
).rejects.toThrow(ACTIVITY_OUT_OF_SCOPE_MESSAGE)
})
it('hides municipality-less activities from advisors even with Visão "Tudo" (C165)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const wideAdvisor = await fixtures.createCampaignUser('advisor', { visibility: 'tudo' })
const responsibleAdvisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [responsibleAdvisor])
const orphan = await createActivityRecord(payload, coordinator, {
title: fixtures.value('Importada sem município'),
tags: [],
status: 'confirmado',
startAt: new Date(Date.now() + 86_400_000).toISOString(),
municipality: null,
})
fixtures.own('activity', orphan.id)
// A coordenação lê; o advisor "tudo" e o advisor listado como responsável
// não — a atividade sem município é coordenação/candidato-only.
const coordinatorRead = await payload.find({
collection: 'activity',
where: { id: { equals: orphan.id } },
user: coordinator,
overrideAccess: false,
depth: 0,
})
expect(coordinatorRead.totalDocs).toBe(1)
const wideRead = await payload.find({
collection: 'activity',
where: { id: { equals: orphan.id } },
user: wideAdvisor,
overrideAccess: false,
depth: 0,
})
expect(wideRead.totalDocs).toBe(0)
await payload.update({
collection: 'activity',
id: orphan.id,
data: {
responsible: [{ relationTo: 'campaignUser', value: responsibleAdvisor.id }],
},
overrideAccess: true,
})
const responsibleRead = await payload.find({
collection: 'activity',
where: { id: { equals: orphan.id } },
user: responsibleAdvisor,
overrideAccess: false,
depth: 0,
})
expect(responsibleRead.totalDocs).toBe(0)
expect(
await canReadActivity({
req: stub<PayloadRequest>({ user: wideAdvisor, payload, context: {} }),
}),
).toEqual({ municipality: { exists: true } })
})
it('parses several demand drafts from the activity form', () => {
@ -237,6 +413,7 @@ describe('activity domain', () => {
})
const createAsAdvisor = await canCreateActivity({
req: stub<PayloadRequest>({ user: advisor, payload, context: {} }),
data: { municipality: municipality.id },
})
const createAsLeader = await canCreateActivity({
req: stub<PayloadRequest>({ user: leaderAccount, payload, context: {} }),
@ -268,9 +445,14 @@ describe('activity domain', () => {
req: stub<PayloadRequest>({ user: advisor, payload, context: {} }),
})
expect(advisorRead).toEqual({
or: [
{ responsible: { equals: { relationTo: 'campaignUser', value: advisor.id } } },
{ municipality: { in: [municipality.id] } },
and: [
{ municipality: { exists: true } },
{
or: [
{ responsible: { equals: { relationTo: 'campaignUser', value: advisor.id } } },
{ municipality: { in: [municipality.id] } },
],
},
],
})
@ -278,9 +460,14 @@ describe('activity domain', () => {
req: stub<PayloadRequest>({ user: otherAdvisor, payload, context: {} }),
})
expect(otherAdvisorRead).toEqual({
or: [
{ responsible: { equals: { relationTo: 'campaignUser', value: otherAdvisor.id } } },
{ municipality: { in: [] } },
and: [
{ municipality: { exists: true } },
{
or: [
{ responsible: { equals: { relationTo: 'campaignUser', value: otherAdvisor.id } } },
{ municipality: { in: [] } },
],
},
],
})
@ -299,9 +486,14 @@ describe('activity domain', () => {
req: stub<PayloadRequest>({ user: advisor, payload, context: {} }),
})
expect(advisorUpdate).toEqual({
or: [
{ responsible: { equals: { relationTo: 'campaignUser', value: advisor.id } } },
{ municipality: { in: [municipality.id] } },
and: [
{ municipality: { exists: true } },
{
or: [
{ responsible: { equals: { relationTo: 'campaignUser', value: advisor.id } } },
{ municipality: { in: [municipality.id] } },
],
},
],
})

View file

@ -97,6 +97,7 @@ const createStubClient = (): StubClient => {
listEvents,
insertEvent: vi.fn(async () => {}),
updateEvent: vi.fn(async () => {}),
patchEvent: vi.fn(async () => {}),
deleteEvent: vi.fn(async () => {}),
watchEvents: vi.fn(async () => ({
id: 'watch-1',

View file

@ -71,6 +71,10 @@ const createStubClient = (store: GoogleRemoteEvent[] = []): GoogleCalendarClient
const index = store.findIndex((entry) => entry.id === eventId)
if (index >= 0) store[index] = event
},
patchEvent: async (_calendarId, eventId, patch) => {
const index = store.findIndex((entry) => entry.id === eventId)
if (index >= 0) store[index] = { ...store[index], ...patch }
},
deleteEvent: async (_calendarId, eventId) => {
const index = store.findIndex((entry) => entry.id === eventId)
if (index >= 0) store.splice(index, 1)
@ -177,6 +181,21 @@ describe('campaign Google calendar sync engine (C114)', () => {
}),
)
/** UTC ISO → the `-03:00` dateTime shape the Calendar API echoes. */
const formatBahiaDateTime = (iso: string): string =>
`${new Date(new Date(iso).getTime() - 3 * 3_600_000).toISOString().slice(0, 19)}-03:00`
/** Replaces one event's fields in the stub store (throws when absent). */
const replaceEvent = (
store: GoogleRemoteEvent[],
eventId: string,
patch: Partial<GoogleRemoteEvent>,
) => {
const index = store.findIndex((entry) => entry.id === eventId)
if (index < 0) throw new Error(`Evento ${eventId} não está no stub store.`)
store[index] = { ...store[index], ...patch }
}
it('creates the full mirror on the first pass (timed + all-day, municipality summary)', async () => {
const municipality = await campaignFixtures().getMunicipality()
const timed = await createActivity({ municipality: municipality.id, locality: 'Centro' })
@ -477,19 +496,6 @@ describe('campaign Google calendar sync engine (C114)', () => {
})
describe('bidirectional reconciliation (C115)', () => {
/** UTC ISO → the `-03:00` dateTime shape the Calendar API echoes. */
const formatBahiaDateTime = (iso: string): string =>
`${new Date(new Date(iso).getTime() - 3 * 3_600_000).toISOString().slice(0, 19)}-03:00`
const replaceEvent = (
store: GoogleRemoteEvent[],
eventId: string,
patch: Partial<GoogleRemoteEvent>,
) => {
const index = store.findIndex((entry) => entry.id === eventId)
store[index] = { ...store[index], ...patch }
}
const reloadActivity = async (id: number) =>
payload.findByID({
collection: 'activity',
@ -986,4 +992,388 @@ describe('campaign Google calendar sync engine (C114)', () => {
expect((await reloadActivity(activity.id)).status).toBe('confirmado')
})
})
describe('imported Google events (C165)', () => {
/** A foreign event inside the window and after the legal-start cut. */
const foreignEvent = (
overrides: Partial<GoogleRemoteEvent> & { id: string },
): GoogleRemoteEvent => {
const start = new Date(Date.now() + 2 * 86_400_000)
return {
summary: `C165 ${crypto.randomUUID().slice(0, 8)}`,
description: 'descrição que vive no Google',
location: 'Gabinete',
start: { dateTime: formatBahiaDateTime(start.toISOString()) },
end: {
dateTime: formatBahiaDateTime(new Date(start.getTime() + 3_600_000).toISOString()),
},
...overrides,
}
}
/**
* The imported activity is born DURING the pass, so it enters the file's
* scope/cleanup only after this read — subsequent scoped passes then see
* it as a linked row.
*/
const trackImported = async (eventId: string) => {
const found = await payload.find({
collection: 'activity',
where: { googleEventId: { equals: eventId } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
const activity = found.docs[0]
if (activity) ownedActivityIds.add(activity.id)
return activity
}
it('imports an eligible foreign event once — confirmado, sem município, título verbatim', async () => {
await createConfig(calendarA)
const event = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [event]
const client = createStubClient(store)
const outcome = await runSync(client)
expect(outcome.status).toBe('synced')
expect(outcome.imported).toBe(1)
expect(outcome.created).toBe(0)
const activity = await trackImported(event.id!)
expect(activity).toBeTruthy()
expect(activity?.status).toBe('confirmado')
expect(activity?.title).toBe(event.summary)
expect(activity?.municipality).toBeNull()
expect(activity?.locality).toBe('Gabinete')
expect(activity?.googleCalendarId).toBe(calendarA)
// Description deliberately ignored: the text lives in Google.
expect(activity?.description ?? null).toBeNull()
// The mirror never materializes an imported activity as a `teqo…` event.
expect(store.filter((entry) => entry.id?.startsWith('teqo'))).toHaveLength(0)
// Idempotent: a second pass converges and never re-imports.
const again = await runSync(client)
expect(again).toMatchObject({ imported: 0, created: 0, updated: 0, reverseEdits: 0 })
expect(store).toHaveLength(1)
})
it('leaves pre-cut events out — no backfill — and imports the cut day', async () => {
await createConfig(calendarA)
const beforeCut = foreignEvent({
id: `foreign-${crypto.randomUUID()}`,
// 2026-08-15 22:00 in Bahia — the civil date before the legal start.
start: { dateTime: '2026-08-15T22:00:00-03:00' },
end: { dateTime: '2026-08-15T23:00:00-03:00' },
})
const onCut = foreignEvent({
id: `foreign-${crypto.randomUUID()}`,
start: { dateTime: '2026-08-16T09:00:00-03:00' },
end: { dateTime: '2026-08-16T10:00:00-03:00' },
})
const store: GoogleRemoteEvent[] = [beforeCut, onCut]
const outcome = await runSync(createStubClient(store))
expect(outcome.imported).toBe(1)
expect(await trackImported(beforeCut.id!)).toBeUndefined()
expect(await trackImported(onCut.id!)).toBeTruthy()
// The pre-cut event stays untouched in Google.
expect(store).toHaveLength(2)
})
it('skips events without a usable title without pausing the pass', async () => {
await createConfig(calendarA)
const emptyTitle = foreignEvent({ id: `foreign-${crypto.randomUUID()}`, summary: '' })
const oneChar = foreignEvent({ id: `foreign-${crypto.randomUUID()}`, summary: 'A' })
const valid = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [emptyTitle, oneChar, valid]
const outcome = await runSync(createStubClient(store))
expect(outcome.status).toBe('synced')
expect(outcome.imported).toBe(1)
expect(await trackImported(valid.id!)).toBeTruthy()
expect(store).toHaveLength(3)
})
it('applies a newer Google edit back with an audit record, then converges', async () => {
await createConfig(calendarA)
const event = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [event]
const client = createStubClient(store)
await runSync(client)
const activity = await trackImported(event.id!)
expect(activity).toBeTruthy()
const renamed = `C165 ${crypto.randomUUID().slice(0, 8)} (renomeada no Google)`
// Whole seconds: the Calendar API echoes second precision, and the
// helper below formats the same way.
const newStart = new Date(Math.floor((Date.now() + 3 * 86_400_000) / 1000) * 1000)
replaceEvent(store, event.id!, {
summary: renamed,
start: { dateTime: formatBahiaDateTime(newStart.toISOString()) },
end: {
dateTime: formatBahiaDateTime(new Date(newStart.getTime() + 3_600_000).toISOString()),
},
updated: new Date(Date.now() + 60_000).toISOString(),
})
const outcome = await runSync(client)
expect(outcome.reverseEdits).toBe(1)
expect(outcome.updated).toBe(0)
const reloaded = await payload.findByID({
collection: 'activity',
id: activity!.id,
depth: 0,
overrideAccess: true,
})
expect(reloaded.title).toBe(renamed)
expect(reloaded.slug).toBe(activity!.slug)
expect(new Date(reloaded.startAt!).getTime()).toBe(newStart.getTime())
expect((reloaded.updates ?? []).at(-1)?.body).toContain('Google Calendar:')
const again = await runSync(client)
expect(again).toMatchObject({ imported: 0, updated: 0, reverseEdits: 0 })
expect(store).toHaveLength(1)
})
it('edits the Teqo into the SAME remote event — never a second `teqo…`, description preserved', async () => {
await createConfig(calendarA)
const event = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [event]
const client = createStubClient(store)
await runSync(client)
const activity = await trackImported(event.id!)
expect(activity).toBeTruthy()
await payload.update({
collection: 'activity',
id: activity!.id,
data: { locality: 'Centro (atualizado no Teqo)' },
depth: 0,
overrideAccess: true,
})
const outcome = await runSync(client)
expect(outcome.updated).toBe(1)
expect(outcome.imported).toBe(0)
expect(store).toHaveLength(1)
expect(store[0].id).toBe(event.id)
expect(store[0].location).toBe('Centro (atualizado no Teqo)')
// The partial PATCH never touches the user's description.
expect(store[0].description).toBe('descrição que vive no Google')
expect(store.filter((entry) => entry.id?.startsWith('teqo'))).toHaveLength(0)
})
it('assigning a município keeps the verbatim title (no `[Município] ` prefix)', async () => {
await createConfig(calendarA)
const event = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [event]
const client = createStubClient(store)
await runSync(client)
const activity = await trackImported(event.id!)
const municipality = await campaignFixtures().getMunicipality()
await payload.update({
collection: 'activity',
id: activity!.id,
data: { municipality: municipality.id },
depth: 0,
overrideAccess: true,
})
const outcome = await runSync(client)
expect(outcome.updated).toBe(0)
expect(store[0].summary).toBe(event.summary)
expect(store[0].summary).not.toContain('[')
})
it('cancelling in the Teqo trashes the SAME remote event — never deletes it', async () => {
await createConfig(calendarA)
const event = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [event]
const client = createStubClient(store)
await runSync(client)
const activity = await trackImported(event.id!)
await payload.update({
collection: 'activity',
id: activity!.id,
data: { status: 'cancelado' },
depth: 0,
overrideAccess: true,
})
const outcome = await runSync(client)
expect(outcome.deleted).toBe(1)
expect(store).toHaveLength(1)
expect(store[0].status).toBe('cancelled')
// Converged: no repeated patch on the next pass.
const again = await runSync(client)
expect(again).toMatchObject({ imported: 0, updated: 0, deleted: 0, reverseEdits: 0 })
})
it('a newer Google cancellation cancels the activity; a staff reopen re-asserts the event', async () => {
await createConfig(calendarA)
const event = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [event]
const client = createStubClient(store)
await runSync(client)
const activity = await trackImported(event.id!)
replaceEvent(store, event.id!, {
status: 'cancelled',
// +5s: newer than the import stamp (the cancel wins), yet older than
// the reopen's stamp after the 3.5s sleep below (the reopen wins).
updated: new Date(Date.now() + 5_000).toISOString(),
})
let outcome = await runSync(client)
expect(outcome.reverseEdits).toBe(1)
let reloaded = await payload.findByID({
collection: 'activity',
id: activity!.id,
depth: 0,
overrideAccess: true,
})
expect(reloaded.status).toBe('cancelado')
expect((reloaded.updates ?? []).at(-1)?.body).toContain('Google Calendar: cancelada')
// Staff reopens after the cancel: the clock rule says Teqo wins — the
// SAME event is patched back to confirmed (never deleted/recreated).
await new Promise((resolve) => setTimeout(resolve, 3_500))
await payload.update({
collection: 'activity',
id: activity!.id,
data: { status: 'confirmado' },
depth: 0,
overrideAccess: true,
})
outcome = await runSync(client)
expect(outcome.updated).toBe(1)
expect(outcome.reverseEdits).toBe(0)
expect(store).toHaveLength(1)
expect(store[0].status).toBe('confirmed')
reloaded = await payload.findByID({
collection: 'activity',
id: activity!.id,
depth: 0,
overrideAccess: true,
})
expect(reloaded.status).toBe('confirmado')
})
it('a permanently removed remote event cancels the linked activity (snapshot rule)', async () => {
await createConfig(calendarA)
const event = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [event]
const client = createStubClient(store)
await runSync(client)
const activity = await trackImported(event.id!)
store.splice(0, 1)
const outcome = await runSync(client)
expect(outcome.reverseEdits).toBe(1)
const reloaded = await payload.findByID({
collection: 'activity',
id: activity!.id,
depth: 0,
overrideAccess: true,
})
expect(reloaded.status).toBe('cancelado')
})
it('a hard-deleted imported activity leaves its foreign event alone — never resurrected', async () => {
await createConfig(calendarA)
const event = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [event]
const client = createStubClient(store)
await runSync(client)
const activity = await trackImported(event.id!)
expect(activity).toBeTruthy()
await payload.delete({ collection: 'activity', id: activity!.id, overrideAccess: true })
ownedActivityIds.delete(activity!.id)
const outcome = await runSync(client)
expect(outcome.imported).toBe(0)
expect(store).toHaveLength(1)
expect(await trackImported(event.id!)).toBeUndefined()
// The snapshot kept the dismissed id: the FIRST pass after the delete
// must not have dropped it, or this second pass would re-import.
const again = await runSync(client)
expect(again.imported).toBe(0)
expect(await trackImported(event.id!)).toBeUndefined()
})
it('a municipality-less native activity whose mirrored event vanished is cancelled (seen-and-gone)', async () => {
const municipality = await campaignFixtures().getMunicipality()
const activity = await createActivity({ municipality: municipality.id })
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client = createStubClient(store)
await runSync(client)
expect(store).toHaveLength(1)
// Triage clears the município; the user then permanently removes the
// event in Google. The seen-and-gone rule must still cancel it (the C165
// mirror guard only skips the CREATE of municipality-less activities).
await payload.update({
collection: 'activity',
id: activity.id,
data: { municipality: null },
depth: 0,
overrideAccess: true,
})
store.splice(0, 1)
const outcome = await runSync(client)
expect(outcome.reverseEdits).toBe(1)
const reloaded = await payload.findByID({
collection: 'activity',
id: activity.id,
depth: 0,
overrideAccess: true,
})
expect(reloaded.status).toBe('cancelado')
})
it('never touches a different foreign event and never imports a mirrored `teqo…` event', async () => {
const activity = await createActivity()
await createConfig(calendarA)
const untouched: GoogleRemoteEvent = { id: 'foreign-sem-data', summary: 'Sem data' }
const store: GoogleRemoteEvent[] = [untouched]
const client = createStubClient(store)
await runSync(client)
expect(store).toHaveLength(2)
// A manual `teqo…`-looking event is NOT a mirror id and is out of the
// import criteria: it is never imported (the mirror's stray-event
// cleanup owns the native namespace, not the import pass).
const manual: GoogleRemoteEvent = {
id: 'teqo999',
summary: 'Evento manual com cara de espelho',
start: { dateTime: '2026-09-20T09:00:00-03:00' },
end: { dateTime: '2026-09-20T10:00:00-03:00' },
}
store.push(manual)
const outcome = await runSync(client)
expect(outcome.imported).toBe(0)
expect(store.find((entry) => entry.id === 'foreign-sem-data')).toBe(untouched)
const importedManual = await payload.find({
collection: 'activity',
where: { googleEventId: { equals: 'teqo999' } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
expect(importedManual.docs).toHaveLength(0)
expect(store.find((entry) => entry.id === googleEventIdForActivity(activity.id))).toBeTruthy()
})
})
})

View file

@ -167,6 +167,7 @@ describe('activity agenda event view model', () => {
municipality: { id: 12, name: 'Ilhéus', slug: 'ilheus' },
locality: 'Centro histórico',
canReschedule: true,
importedFromGoogle: false,
})
})

View file

@ -21,6 +21,7 @@ const event = (overrides: Partial<ActivityAgendaEvent> = {}): ActivityAgendaEven
municipality: null,
locality: null,
canReschedule: true,
importedFromGoogle: false,
...overrides,
})

View file

@ -156,7 +156,9 @@ describe('ActivityOverlay — criação (modal central)', () => {
expect(
(screen.getByRole('combobox', { name: 'Minuto de Término' }) as HTMLSelectElement).value,
).toBe('30')
expect((screen.getByLabelText('Município *') as HTMLInputElement).value).toBe('Camaçari')
expect((screen.getByLabelText('Município (opcional)') as HTMLInputElement).value).toBe(
'Camaçari',
)
// C123 — "Local" aparece uma única vez no overlay.
expect(screen.getAllByLabelText('Local (opcional)')).toHaveLength(1)
@ -242,7 +244,7 @@ describe('ActivityOverlay — criação (modal central)', () => {
expect(onSaved).toHaveBeenCalledTimes(1)
})
it('bloqueia salvar sem município e não invoca a ação', async () => {
it('salva sem município: "Sem município" é estado válido e envia o campo vazio (C165)', async () => {
const onSaved = vi.fn()
mocks.createOverlay.mockResolvedValue({ ok: true })
renderOverlay({ agendaState: {}, onSaved })
@ -250,11 +252,17 @@ describe('ActivityOverlay — criação (modal central)', () => {
fireEvent.change(screen.getByLabelText('Título *'), {
target: { value: 'Café com apoiadores' },
})
// O aviso de visibilidade acompanha o estado sem município.
expect((screen.getByLabelText('Município (opcional)') as HTMLInputElement).value).toBe(
'Sem município',
)
expect(screen.getByText(/só coordenação e candidato veem/)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
await waitFor(() => expect(screen.getByText('Informe o município.')).toBeTruthy())
expect(mocks.createOverlay).not.toHaveBeenCalled()
expect(onSaved).not.toHaveBeenCalled()
await waitFor(() => expect(mocks.createOverlay).toHaveBeenCalledTimes(1))
expect(submittedFormData().get('municipality')).toBe('')
expect(onSaved).toHaveBeenCalledTimes(1)
})
it('mantém o overlay aberto com erro inline quando a criação falha', async () => {
@ -362,7 +370,9 @@ describe('ActivityOverlay — edição (modal central)', () => {
expect(
(screen.getByRole('combobox', { name: 'Hora de Término' }) as HTMLSelectElement).value,
).toBe('14')
expect((screen.getByLabelText('Município *') as HTMLInputElement).value).toBe('Camaçari')
expect((screen.getByLabelText('Município (opcional)') as HTMLInputElement).value).toBe(
'Camaçari',
)
expect((screen.getByLabelText('Local (opcional)') as HTMLInputElement).value).toBe(
'Centro histórico',
)
@ -445,8 +455,8 @@ describe('ActivityOverlay — sheet mobile (C103)', () => {
expect(
(screen.getByRole('combobox', { name: 'Minuto de Início' }) as HTMLSelectElement).value,
).toBe('00')
expect((screen.getByLabelText('Município *') as HTMLInputElement).placeholder).toBe(
'Município *',
expect((screen.getByLabelText('Município (opcional)') as HTMLInputElement).placeholder).toBe(
'Município (opcional)',
)
expect((screen.getByLabelText('Local (opcional)') as HTMLInputElement).placeholder).toBe(
'Local (opcional)',

View file

@ -318,3 +318,37 @@ describe('activity slot prefill (C91)', () => {
})
})
})
describe('activity triage filter — Sem município (C165)', () => {
it('parses and serializes `unscoped` exclusively with `municipality`', () => {
expect(parseActivityListParams({ unscoped: '1' })).toMatchObject({ unscoped: true })
// A hand-crafted URL with both: the triage filter wins and the município
// drops (the canonical form keeps only one).
const both = parseActivityListParams({ unscoped: '1', municipality: '12' })
expect(both.unscoped).toBe(true)
expect(both.municipality).toBeUndefined()
const params = buildActivityListSearchParams(both)
expect(params.get('unscoped')).toBe('1')
expect(params.get('municipality')).toBeNull()
// O caminho inverso: município presente nunca serializa `unscoped`.
const scoped = buildActivityListSearchParams(parseActivityListParams({ municipality: '12' }))
expect(scoped.get('municipality')).toBe('12')
expect(scoped.get('unscoped')).toBeNull()
})
it('builds `municipality exists: false` on any tab', () => {
const where = buildActivityListWhere(
parseActivityListParams({ tab: 'todos', unscoped: '1' }),
NOW,
)
expect(where).toEqual({ and: [{ municipality: { exists: false } }] })
const scoped = buildActivityListWhere(
parseActivityListParams({ tab: 'todos', municipality: '12' }),
NOW,
)
expect(scoped).toEqual({ and: [{ municipality: { equals: 12 } }] })
})
})

View file

@ -153,6 +153,16 @@ describe('createGoogleCalendarClient', () => {
headers: { 'Content-Type': 'application/json' },
})
}
if (url.includes('/events') && method === 'PATCH') {
const patch = JSON.parse(body ?? '{}') as Record<string, unknown>
const eventId = new URL(url).pathname.split('/').pop()
const index = events.findIndex((e) => e.id === eventId)
if (index >= 0) events[index] = { ...events[index], ...patch }
return new Response(JSON.stringify(events[index] ?? patch), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
if (url.includes('/events') && method === 'DELETE') {
const eventId = url.split('/').pop()
const index = events.findIndex((e) => e.id === eventId)
@ -377,6 +387,24 @@ describe('createGoogleCalendarClient', () => {
expect(tokenRequests).toBe(2)
})
it('patches partial fields with sendUpdates=none, preserving what was not sent (C165)', async () => {
const { fetchImpl, calls, events } = stubTransport([
{ id: 'foreign-1', summary: 'Antigo', description: 'descrição do usuário' },
])
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)
await client.patchEvent(calendarId, 'foreign-1', { summary: 'Novo', location: '' })
const patchCall = calls.find((call) => call.method === 'PATCH')
expect(patchCall?.url).toContain(
`/calendars/${encodeURIComponent(calendarId)}/events/foreign-1`,
)
expect(patchCall?.url).toContain('sendUpdates=none')
expect(JSON.parse(patchCall?.body ?? '{}')).toEqual({ summary: 'Novo', location: '' })
// O PATCH preserva o que não foi enviado — a descrição do usuário fica.
expect(events[0]).toMatchObject({ summary: 'Novo', description: 'descrição do usuário' })
})
it('normalizes the watch expiration string into millis (C164)', async () => {
const { fetchImpl, calls } = stubTransport()
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)

View file

@ -3,9 +3,14 @@ import { describe, expect, it } from 'vitest'
import {
buildGoogleCalendarDescription,
buildGoogleEventPayload,
buildImportedGoogleEventActivity,
buildImportedGoogleEventPatch,
decodeGoogleEventActivityId,
googleEventContentEquals,
googleEventIdForActivity,
googleImportedEventContentEquals,
isForeignGoogleEvent,
isImportableGoogleEvent,
type GoogleRemoteEvent,
} from '@/lib/googleCalendarEventMapping'
@ -181,3 +186,244 @@ describe('googleEventContentEquals', () => {
).toBe(true)
})
})
describe('isForeignGoogleEvent (C165)', () => {
it('accepts only ids the mirror does not own (full decode, never a prefix)', () => {
expect(isForeignGoogleEvent({ id: 'abc123' })).toBe(true)
// `teqo` (the bare prefix) does not decode to an activity — foreign.
expect(decodeGoogleEventActivityId('teqo')).toBeNull()
expect(isForeignGoogleEvent({ id: 'teqo' })).toBe(true)
expect(isForeignGoogleEvent({ id: 'teqo3' })).toBe(false)
// A manual `teqo3` decodes to activity 3: it stays in the mirror
// namespace and is never imported.
expect(decodeGoogleEventActivityId('teqo3')).toBe(3)
expect(isForeignGoogleEvent({})).toBe(false)
})
})
describe('isImportableGoogleEvent (C165 cut + window)', () => {
const window = { rangeStart: '2026-07-01T00:00:00.000Z', rangeEnd: '2027-09-01T00:00:00.000Z' }
const timed = (startAt: string): GoogleRemoteEvent => ({
id: 'foreign-1',
summary: 'Reunião',
start: { dateTime: startAt },
end: { dateTime: new Date(Date.parse(startAt) + 3_600_000).toISOString() },
})
it('applies the cut on the Bahia civil date: 15/08 out, 16/08 in', () => {
expect(isImportableGoogleEvent(timed('2026-08-16T02:59:00.000Z'), window)).toBe(false)
expect(isImportableGoogleEvent(timed('2026-08-16T03:00:00.000Z'), window)).toBe(true)
expect(
isImportableGoogleEvent(
{
id: 'foreign-2',
summary: 'Aniversário',
start: { date: '2026-08-15' },
end: { date: '2026-08-16' },
},
window,
),
).toBe(false)
expect(
isImportableGoogleEvent(
{
id: 'foreign-3',
summary: 'Caminhada',
start: { date: '2026-08-16' },
end: { date: '2026-08-17' },
},
window,
),
).toBe(true)
})
it('respects the mirror window on both ends', () => {
expect(isImportableGoogleEvent(timed('2027-09-02T12:00:00.000Z'), window)).toBe(false)
expect(
isImportableGoogleEvent(timed('2026-06-30T12:00:00.000Z'), {
rangeStart: '2026-08-16T03:00:00.000Z',
rangeEnd: '2027-09-01T00:00:00.000Z',
}),
).toBe(false)
})
it('rejects cancelled, mirrored and malformed events', () => {
expect(
isImportableGoogleEvent(
{ ...timed('2026-08-20T12:00:00.000Z'), status: 'cancelled' },
window,
),
).toBe(false)
expect(isImportableGoogleEvent({ id: 'foreign-4', summary: 'Sem data' }, window)).toBe(false)
expect(
isImportableGoogleEvent(
{ id: 'foreign-5', summary: 'Sem fim', start: { dateTime: '2026-08-20T12:00:00.000Z' } },
window,
),
).toBe(false)
// A `teqo…` id belongs to the mirror namespace, never to the import pass.
expect(
isImportableGoogleEvent({ ...timed('2026-08-20T12:00:00.000Z'), id: 'teqo3' }, window),
).toBe(false)
})
})
describe('buildImportedGoogleEventActivity (C165)', () => {
const calendarId = 'c_imprensa@group.calendar.google.com'
it('shapes a timed event: title verbatim, location as locality, description ignored', () => {
const data = buildImportedGoogleEventActivity(
{
id: 'foreign-1',
summary: 'Reunião com o prefeito',
description: 'pauta longa que fica no Google',
location: 'Gabinete',
start: { dateTime: '2026-08-20T12:00:00-03:00' },
end: { dateTime: '2026-08-20T13:00:00-03:00' },
},
calendarId,
)
expect(data).toMatchObject({
title: 'Reunião com o prefeito',
status: 'confirmado',
startAt: '2026-08-20T15:00:00.000Z',
endAt: '2026-08-20T16:00:00.000Z',
allDay: false,
locality: 'Gabinete',
googleEventId: 'foreign-1',
googleCalendarId: calendarId,
tags: [],
})
expect(data && 'description' in data).toBe(false)
})
it('keeps a `[X] ` prefix of a foreign summary verbatim (no municipality to strip)', () => {
const data = buildImportedGoogleEventActivity(
{
id: 'foreign-2',
summary: '[URGENTE] Reunião',
start: { dateTime: '2026-08-20T12:00:00-03:00' },
end: { dateTime: '2026-08-20T13:00:00-03:00' },
},
calendarId,
)
expect(data?.title).toBe('[URGENTE] Reunião')
})
it('maps all-day events through the C104 civil-date convention', () => {
const data = buildImportedGoogleEventActivity(
{
id: 'foreign-3',
summary: 'Feriado',
start: { date: '2026-08-20' },
end: { date: '2026-08-22' },
},
calendarId,
)
expect(data).toMatchObject({
allDay: true,
startAt: '2026-08-20T03:00:00.000Z',
endAt: '2026-08-21T03:00:00.000Z',
})
})
it('skips events without a usable title or schedule (never pauses the pass)', () => {
const schedule = {
start: { dateTime: '2026-08-20T12:00:00-03:00' },
end: { dateTime: '2026-08-20T13:00:00-03:00' },
}
expect(buildImportedGoogleEventActivity({ id: 'f', ...schedule }, calendarId)).toBeNull()
expect(
buildImportedGoogleEventActivity({ id: 'f', summary: 'A', ...schedule }, calendarId),
).toBeNull()
expect(
buildImportedGoogleEventActivity({ id: 'f', summary: '💡', ...schedule }, calendarId),
).toBeNull()
expect(
buildImportedGoogleEventActivity({ summary: 'Sem id', ...schedule }, calendarId),
).toBeNull()
expect(
buildImportedGoogleEventActivity(
{ id: 'f', summary: 'Sem data', description: 'x' },
calendarId,
),
).toBeNull()
})
it('builds a deterministic slug unique per event (recurrence + duplicate titles)', () => {
const first = buildImportedGoogleEventActivity(
{
id: 'master_20260816T120000Z',
summary: 'Reunião',
start: { dateTime: '2026-08-16T12:00:00-03:00' },
end: { dateTime: '2026-08-16T13:00:00-03:00' },
},
calendarId,
)
const second = buildImportedGoogleEventActivity(
{
id: 'master_20260817T120000Z',
summary: 'Reunião',
start: { dateTime: '2026-08-17T12:00:00-03:00' },
end: { dateTime: '2026-08-17T13:00:00-03:00' },
},
calendarId,
)
const duplicate = buildImportedGoogleEventActivity(
{
id: 'outro-evento',
summary: 'Reunião',
start: { dateTime: '2026-08-16T12:00:00-03:00' },
end: { dateTime: '2026-08-16T13:00:00-03:00' },
},
calendarId,
)
expect(first?.slug).toMatch(/^reuniao-20260816T1200-[a-z0-9]+$/)
expect(first?.slug).not.toBe(second?.slug)
expect(first?.slug).not.toBe(duplicate?.slug)
})
})
describe('buildImportedGoogleEventPatch / googleImportedEventContentEquals (C165)', () => {
const activity = {
title: 'Reunião com o prefeito',
startAt: '2026-08-20T15:00:00.000Z',
endAt: '2026-08-20T16:00:00.000Z',
allDay: false,
locality: 'Gabinete',
}
it('patches only the fields the Teqo owns: no description, no id, verbatim summary', () => {
expect(buildImportedGoogleEventPatch(activity)).toEqual({
summary: 'Reunião com o prefeito',
location: 'Gabinete',
start: { dateTime: '2026-08-20T12:00:00-03:00' },
end: { dateTime: '2026-08-20T13:00:00-03:00' },
})
expect(buildImportedGoogleEventPatch({ ...activity, locality: '' }).location).toBe('')
})
it('compares summary + schedule + location and IGNORES the Google description', () => {
const patch = buildImportedGoogleEventPatch(activity)
const remote: GoogleRemoteEvent = {
id: 'foreign-1',
summary: patch.summary,
description: 'texto que só existe no Google',
location: patch.location,
start: patch.start,
end: patch.end,
}
expect(googleImportedEventContentEquals(remote, patch)).toBe(true)
expect(googleImportedEventContentEquals({ ...remote, summary: 'Outro' }, patch)).toBe(false)
expect(googleImportedEventContentEquals({ ...remote, location: 'Outro' }, patch)).toBe(false)
expect(
googleImportedEventContentEquals(
{ ...remote, start: { dateTime: '2026-08-21T12:00:00-03:00' } },
patch,
),
).toBe(false)
})
})

View file

@ -1,12 +1,14 @@
import { describe, expect, it } from 'vitest'
import {
googleScheduleToActivityFields,
googleTitleFromSummary,
} from '@/lib/googleCalendarEventMapping'
import {
buildGoogleReverseCancelBody,
buildGoogleReverseUpdateBody,
GOOGLE_REVERSE_EDIT_BODY_PREFIX,
googleEditIsNewer,
googleScheduleToActivityFields,
googleTitleFromSummary,
} from '@/lib/googleCalendarReverseEdit'
const MUNICIPALITY = 'Salvador'

View file

@ -39,6 +39,7 @@ const settledOutcome = {
updated: 0,
deleted: 0,
reverseEdits: 0,
imported: 0,
at: '',
}

View file

@ -234,6 +234,48 @@ describe('list omnibox adapters (B128)', () => {
if (removedTab.kind === 'url') expect(removedTab.state.tab).toBe('proximos')
})
it('activity triage filter toggles "Sem município" exclusively with a município (C165)', () => {
const base = parseActivityListParams({ tab: 'todos' })
const seeds = buildActivityOmniboxSuggestionSeeds({ tab: base.tab, municipalityOptions: [] })
const suggestions = filterActivityOmniboxSuggestions(seeds, 'sem')
expect(suggestions.some((entry) => entry.id === 'unscoped')).toBe(true)
const applied = applyActivityOmniboxSuggestion({ state: base, suggestionId: 'unscoped' })
expect(applied.kind).toBe('url')
if (applied.kind !== 'url') return
expect(applied.state.unscoped).toBe(true)
const chips = buildActivityOmniboxChips({
state: applied.state,
municipalityLabelsById: new Map(),
})
expect(chips.map((chip) => chip.id)).toEqual(['tab:todos', 'unscoped'])
// Escolher um município tira o filtro de triagem (exclusivos)…
const withMunicipality = applyActivityOmniboxSuggestion({
state: applied.state,
suggestionId: 'municipality:12',
})
expect(withMunicipality.kind).toBe('url')
if (withMunicipality.kind === 'url') {
expect(withMunicipality.state.municipality).toBe(12)
expect(withMunicipality.state.unscoped).toBeUndefined()
}
// …e aplicar "Sem município" de novo limpa o município.
const backToTriage = applyActivityOmniboxSuggestion({
state: parseActivityListParams({ municipality: '12' }),
suggestionId: 'unscoped',
})
if (backToTriage.kind === 'url') {
expect(backToTriage.state.unscoped).toBe(true)
expect(backToTriage.state.municipality).toBeUndefined()
}
const removed = removeActivityOmniboxChip({ state: applied.state, chipId: 'unscoped' })
if (removed.kind === 'url') expect(removed.state.unscoped).toBeUndefined()
})
it('demand toggles exclusive status, kind and search; preserves activity on clear', () => {
const state = parseDemandListParams({
status: 'aberta',