teqo-backup/AGENTS-public.md
Francisco Solla 5635e6522c feat(site): vira o site público para o mandato pós-eleição (S47)
Home abre no agradecimento pelos 152.049 votos com board 1+3, trajetória, bandeiras do mandato, galeria de fotos e rodapé institucional; chrome novo (header home/interno, lockup de nome, menu mobile acessível) e /artigos//fotos com título compacto.

As superfícies de campanha saem do ar (308 de /conteudos, /jingles e /cards), junto com newsletter, estúdio público de cards e UI da Central/jingles; download de foto e .ics perdem o 1313. Sem migration, sem Consent novo e sem apagar dados do CMS.
2026-10-22 02:16:08 -03:00

63 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Public site domain (AGENTS layer — load when touching `(frontend)`)
## Photo album (`/fotos`) — C233
The public album over the private archive (`archivePhoto`, C231/C232): **only `publicationStatus: 'approved'` photos** are read (`getApprovedArchivePhotoItems`, `src/utilities/archivePhotos/archivePhotoReads.ts`), cached under the collection listing tag `archivePhotos` and busted by the collection's `afterChange`/`afterDelete` — an `approved→removed` edit pulls the photo and its media down **without a deploy**. The route canonicalizes to `/fotos`; the single-value facets are `data` (day, `YYYY-MM-DD`), `municipio` (catalog slug), `atividade` (an `ARCHIVE_PHOTO_SCENES` value), `pessoa` (slug of a curated `faceFigure`, recognized by the facial layer — C244; the text `catalog.people` no longer governs it) plus `q`/`pagina`; `?foto=<id>` is the non-canonical overlay and `/fotos/<id>` keeps 404ing (no page per photo). Media goes through the same-origin proxy `/fotos/<id>/midia` (`?tamanho=grade` resizes on the fly, `?download=1` names `jorge-solla-foto-<id>.<ext>`, `X-Robots-Tag: noindex`); the municipality name/slug are snapshotted on the row (`municipalityName`/`municipalitySlug`) because the `municipality` collection is campaign-only.
The album global `photoAlbum` holds the two product knobs: `published` (kill switch — **only `false` closes**; an absent row means the schema default `true`) and `removalChannelUrl` (the visible "é você nesta foto? peça a remoção" channel, never invented in code). Publishing the album or approving a photo **requires a valid channel** (fail-closed guard in the global and in the collection); the album has no `loading.tsx` on purpose — a Suspense boundary would turn the closed album's `notFound()` into a `200`. The "Fotos" link in `CampaignFooter` is permanent (the album renders its own honest empty state), and the home gallery (`CampaignPhotoSection`) reads the same approved list.
## Selfie search (`/fotos/encontre`) — C234/C242
The visitor's photo never leaves their device: the browser runs the face-api nets locally (models served same-origin from `/fotos/modelos`, copied by `scripts/copy-face-vision-assets.mjs` in prebuild/predev/Docker) and posts only the 128-float descriptor to `POST /api/fotos/selfie` (`{ vector, intent: 'search' | 'leave-index' }`, JSON ≤4 KB, same-origin, rate-limited 20/10 min per hashed IP with its own key namespace). The answer is a list of **approved** photos (`{ ok, found, photos }`), never a score, a percentage or a third-party name — the view model (`src/lib/faceSearch.ts` `toFaceSearchPhotoView`) has no such fields by shape. Recusals: 400 body, 403 origin, 429 throttle, 404 when `photoAlbum.published === false` or `selfieSearchEnabled !== true`, 503 when either Consent is missing. Since C243 the public homepage carries a conditional discovery section (`SelfieSearchHomeSection`) that only presents and links — no capture/engine there.
**The index is scope B (owner decision, 2026-10-01, supersedes the A/C gate of PR #1370): an anonymous descriptor index of the approved archive, so ANY visitor can find themselves.** The batch (`pnpm faces:index --apply|--verify`, maintenance service with the C231/C232 write guards) downloads each approved photo, detects faces with the same engine and persists identity-free rows in `archivePhotoFace` (photo + 128-float vector + model); `archivePhoto.faces.checkedKey` stores only the model, so a model change marks everything stale. There is no enrollment and no per-person consent: the `Consent` rows are the visitor's query consent and the public index notice. The rows die with the public status — the collection hooks purge them when a photo stops being `approved` or is deleted (plus the FK `ON DELETE CASCADE`) — and the answer always intersects with the C233 approved read. The CLI engine (`scripts/lib/faceEngine.mjs`) loads the same nets from `node_modules`.
**The curated public-figure layer (C244).** The album's `pessoa` facet and its "Quem aparece" line list ONLY the curated public figures of the mandate catalog, as recognized by the facial index — never third parties, never a score. The catalog is the admin-only `faceFigure` collection (name, `slug` = `?pessoa` value, `active`, 1–8 curated reference descriptors enrolled by `pnpm faces:enroll-figure`; the initial rows by `pnpm seed:face-figures`). `faceFigureReads` matches the references against the SAME anonymous index at read time (no materialized link, no reprocess), cached under the album's `archivePhotos` tag: figure edits/deletes, photo lifecycle purges and the opt-out bust it, and the batch/enrollment CLIs go through the runbook's `POST /api/revalidate?tag=archivePhotos`. The layer **fails closed on the `busca-selfie-indice` notice Consent** (checked outside the cache: absent row → no names), and the C244 notice text declares the curated filter explicitly. `catalog.people` remains ficha/Central content and still feeds the `q` haystack, but it does not create the facet or the "Quem aparece" line.
Both sides are **fail-closed**: `selfieSearchEnabled` defaults to `false`; the two Consent keys (`busca-selfie-fotos` query consent, `busca-selfie-indice` public index notice, in `src/lib/campaignConsentKeys.ts`) are provisioned by `pnpm seed:face-consents` from the texts versioned in `scripts/lib/faceConsentTexts.mjs` — without both admin rows the page renders the closed state and the API refuses. Opt-out is self-service ("Minha presença" → face match deletes the matching descriptors, so the face stops answering); photo takedown keeps using `photoAlbum.removalChannelUrl`. Opening the surface also requires approving the archive (`pnpm archive:publish`, bulk `draft → approved`, never touching `removed`). E2E builds stub the engine with `NEXT_PUBLIC_FACE_SEARCH_STUB=1` (`window.__faceSearchStub`); the real engine is exercised by the CLI canary and manually. Runbook: `docs/ops/teqo-1313-deploy.md` §C242.
## Home Plenária section (S44)
The campaign home carries a section for the Plenária da Vitória right after the hero. It is driven by **one fixed share link** (`SHARE_LINK_HOME_SECTION_SLUG = 'plenaria-vitoria'`, `src/lib/shareLinkHomeSection.ts`) — no CMS field, no migration: the link itself remains the owner of the title, description, image, date and destination pool. `loadShareLinkHomeSection` (`src/utilities/shareLinkReads.ts`) reads it under the same cached `shareLinks` tag as the announcement page (unpublish pulls the section without a deploy) and fails closed: no published link, no valid `startsAt`, or `now >= endsAt` (else `startsAt` + 2h) → the section is absent from the HTML. While the window is open the client island (`ShareLinkHomeSection`) renders the invitation — image + the S29 agenda, **never an entry button**; once a destination is on air the poll (shared hook `useShareLinkLiveTarget`, activation-only, 30s, paused on hidden tabs) swaps the image for the official `youtube-nocookie` player (video id derived from the link's pre-registered YouTube destination) and the entry CTA takes the agenda's place, always pointing to `/plenaria-vitoria` (never the raw Meet/YouTube URL) with the label by host (`Entrar na plenária` on Meet, `Assistir no YouTube` on YouTube). The island also hides itself at the exact window close, covering open tabs over the ISR-stale HTML. No data collection, no Consent, no PII.
## Posts & Tags (news / publications)
The public news/publications system is backed by two collections (both in the `Publicações` admin group) plus the helpers in `src/utilities/posts.ts`.
- **`post`** (`src/collections/Post.ts`) — fields: `title`, `slug` (unique, indexed, auto-slugified from `title` when left empty), `type` (select enum `noticia|campanha|artigo|evento`), `category` (required single relationship → `tag`), `tags` (`hasMany` relationship → `tag`), `subtitle`, `coverImage` (upload → `media`), `publishedDate`, `body` (richText). Has drafts/versions (`schedulePublish`, `maxPerDoc: 5`).
- **`tag`** (`src/collections/Tag.ts`) — fields: `name`, `slug` (unique, indexed, auto-slugified from `name`), and `hidden` (checkbox, admin label "Esconder", default `false`). Tags are the shared taxonomy for both `post.category` and `post.tags`.
**Tags are a taxonomy, not a person record.** The `Contact` convention applies to _people_; `tag` is publication metadata (categories + a visibility control flag), so post/tag relations do not touch `Contact`. Don't fold taxonomy into `Contact` or vice-versa.
**Electoral visibility control (`hidden` + fail-closed `isPostVisible`).** Marking a tag `hidden` hides every post that references it — as `category` or in `tags` — with a single toggle. This exists so campaign / pre-candidacy content (tagged `eleitoral`) can be pulled from the public site during the electoral period. `isPostVisible(post)` (`src/utilities/posts.ts`) returns true only when the post is `published` AND none of its related tags (`category` + `tags`) is `hidden`. It **fails closed**: an unpopulated relation (a bare numeric id) is treated as hiding, so callers must fetch with `depth >= 1`. Every public read filters through `getVisiblePosts()`, which applies this predicate to the cached published list.
**URLs and Portuguese SEO values.** The canonical article path is `/[type]/[category]/[slug]` (e.g. `/noticia/saude/<slug>`), with `/[type]` and `/[type]/[category]` listing routes and the home "Últimas notícias" list. The `type` enum values (`noticia|campanha|artigo|evento`) and tag/category slugs (`saude`, `eleitoral`, …) are deliberately Portuguese for SEO and are **data, not identifiers** — never translate them (see the naming-conventions bullet above). The route folders are `[type]/[category]/[slug]` (English param keys); the article route redirects any stale/mismatched URL to the canonical path derived from the post's _current_ `type` + category slug. Article pages emit JSON-LD (`Article`) and Open Graph metadata.
**Caching (mixed ISR, `posts` tag).** Public reads go through the `unstable_cache` wrappers in `src/utilities/posts.ts` (`getCachedPublishedPosts`, `getCachedPostBySlug`), all tagged `posts`. The routes set `export const dynamicParams = true` and build `generateStaticParams` from `getVisiblePosts()`: known paths are statically generated, unknown ones render on demand, and everything stays cached until the `posts` tag is busted. `revalidatePostsListing()` (`src/utilities/documents.ts`) is `revalidateTag('posts')` (the listing tag is `` `${collection}s` `` → `posts`), so one call busts the home page, every listing route, and every article route at once.
**Self-revalidation on admin edits.** Both collections have an `afterChange` hook. `post` busts its own document tag + the `posts` listing on every publish/update (skipping only the initial draft that the admin create view generates during render). `tag` is broader: on change it re-reads every post referencing that tag (as `category` or in `tags`, passing `req` for transaction safety) and revalidates each one plus the listing — so flipping `hidden` is reflected on the site immediately.
**Seeding news content (`pnpm db:seed:posts`).** `scripts/seed-posts.mjs` (loaded via `scripts/seed-loader.mjs`) fetches ~43 articles live from jorgesolla.com.br (WordPress REST API, with an HTML-crawl fallback), imports them as `type: 'noticia'` published posts, converts the WP HTML body to Payload Lexical, and uploads cover images through the configured media storage (Garage S3 when the `S3_*` envs are set — the adapter overwrites the deterministic `<slug>.<ext>` key natively, no pre-delete needed; local disk otherwise). The taxonomy (categories + the `eleitoral` control tag, and the per-slug classification) is hardcoded in the script; it reads/writes the `hidden` flag. It is **idempotent by slug** (existing posts are skipped; tags/media are reused by slug/filename) and reuses the same non-local `DATABASE_URL` guard as `pnpm dev` (override only with `ALLOW_REMOTE_DB=true`). `pnpm db:seed:posts --dry-run` is the plan-only pre-flight (fetches, resolves covers, reports what WOULD be created — no writes). **Since OPS60, sync with the `S3_*` envs set also requires explicit bucket-write intent: `SEED_MEDIA_CONFIRM=1`** (fail-closed, same `isTruthyEnv` pattern as `MEDIA_RECOVER_CONFIRM`; without it the sync refuses with exit 1 before any write — the `S3_*` envs alone must never arm a bucket write, since a worktree with a prod-credentials copy running sync against a local DB would write covers to the prod bucket). `--dry-run` stays flag-free. The OPS58 runbook documents the production sync (post-merge, homeserver, stack env, dry-run → sync → bust `posts` cache → verify) — copy it for future syncs (see `docs/plans/ops58-sincronizar-posts-prod-impl.md`).
### Revalidating after a manual/direct DB change
Admin edits made through the deployed app self-revalidate via the `afterChange` hooks above. But any write that does **not** go through the deployed Payload runtime — direct SQL, `pnpm db:seed:posts`, a DB restore / `db:pull` — will **not** bust production's frozen `posts` cache. (Even when such a write triggers the hook, its `revalidateTag('posts')` runs in that CLI/local process and cannot touch the deployed server's cache.) The pages keep serving stale content until the tag is busted.
Bust it with the secured endpoint (`src/app/(frontend)/api/revalidate/route.ts`):
```bash
# News (`posts` tag — default)
curl -X POST "https://<prod-domain>/api/revalidate" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
# Privacy policy global after Onda 0 migration/seed (`global_privacy-policy`)
curl -X POST "https://<prod-domain>/api/revalidate?tag=global_privacy-policy" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
```
- **Auth:** send the secret via `x-revalidate-secret` or `Authorization: Bearer <secret>`; it is compared to `REVALIDATE_SECRET` with a constant-time check.
- `REVALIDATE_SECRET` must be set in the **production** env (and in your local env if you want to test the endpoint). It is documented in `.env.example`.
- **Tag:** optional `?tag=` query or JSON body `{ "tag": "..." }` (query wins). Allowlist: `posts` (default), `global_privacy-policy`, `election-tse` (bust after re-running `pnpm db:seed:tse` so the cached elections-tab reads refresh), `municipality-catalog` (bust after a migration/seed that adds or renames a município so chip indexes and `unstable_cache` loaders refresh), `social-feed` (bust after a direct-DB write to the `social-feed-settings` global — e.g. an admin-less seed of the YouTube/Instagram feed config; admin edits self-revalidate), `archivePhotos` (bust after a direct-DB write to `archivePhoto` rows — admin edits self-revalidate via the collection hook; the public album `/fotos` caches under it). Unknown tag → `400`.
- **Instagram sync status (S11):** the global's `instagramSyncStatus` jsonb (hidden) is written by the render path, by `afterChange` (re-sync only when IG credentials changed), and by the admin button `POST /api/social-feed/sync` (admin session via `payload.auth` + same-origin guard); the `InstagramSyncStatusPanel` ui field renders it. Runbook de credenciais: `docs/ops/instagram-feed-token-runbook.md`.
- **Responses:** `200 { revalidated: true, tag: '...' }` on success; `401` if the secret is missing/wrong; `500` if `REVALIDATE_SECRET` is not configured on the server.
- This POST is the required last step of the post-seed / direct-DB-change runbook.