mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
feat(campaign): C165 — importa eventos do Google como atividades sem município (Closes #1010)
This commit is contained in:
parent
352801853f
commit
7750c43201
40 changed files with 17656 additions and 204 deletions
1
docs/changelog/2026-09-15-c165.md
Normal file
1
docs/changelog/2026-09-15-c165.md
Normal 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.
|
||||
68
docs/plans/c165-f1-update-access-municipality.md
Normal file
68
docs/plans/c165-f1-update-access-municipality.md
Normal 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.
|
||||
238
docs/plans/c165-importar-eventos-google-impl.md
Normal file
238
docs/plans/c165-importar-eventos-google-impl.md
Normal 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).
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'))}>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
15423
src/migrations/20260915_142058_activity_google_import_link.json
Normal file
15423
src/migrations/20260915_142058_activity_google_import_link.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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] } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const event = (overrides: Partial<ActivityAgendaEvent> = {}): ActivityAgendaEven
|
|||
municipality: null,
|
||||
locality: null,
|
||||
canReschedule: true,
|
||||
importedFromGoogle: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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 } }] })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const settledOutcome = {
|
|||
updated: 0,
|
||||
deleted: 0,
|
||||
reverseEdits: 0,
|
||||
imported: 0,
|
||||
at: '',
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue