teqo-backup/AGENTS-public.md
Francisco Solla 0a8b0774d3
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
feat(S44): home divulga a Plenária da Vitória (agenda pré-live, embed ao vivo e kill switch)
2026-10-01 17:47:30 -03:00

14 KiB

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 canonical publicFigureCatalog name) 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-1313-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" discovery link in CampaignFooter appears while there are approved photos (and always on /fotos itself).

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.

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):

# 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.