teqo-backup/AGENTS-public.md
Francisco Solla cc4b344a3b feat(C242/C243): abre a busca por selfie a qualquer visitante (escopo B) + seção na homepage
O escopo A/C do gate (PR #1370) é superado por decisão do dono (2026-10-01, aval jurídico declarado): o índice vira anônimo (archivePhotoFace, foto + descritor 128-d, sem nome/vínculo), o enrollment por pessoa morre (faceSubject/faces:enroll removidos) e qualquer visitante encontra as próprias fotos aprovadas.

- faces:index persiste os descritores por foto (transação substitui as linhas + marker = modelo); desaprovar/remover/deletar foto apaga os descritores (hooks + FK cascade)
- POST /api/fotos/selfie casa o vetor da consulta contra o índice em memória (TTL 60 s) e devolve só a interseção com o read aprovado do C233, sem score/nome de terceiro; leave-index apaga os descritores que casarem
- Consentimentos seguem fail-closed: busca-selfie-fotos (consulta) e busca-selfie-indice (aviso público do índice), textos versionados em scripts/lib/faceConsentTexts.mjs e provisionados por pnpm seed:face-consents
- pnpm archive:publish aprova o acervo em lote (draft -> approved; removed nunca) com guardas e recibo
- C243: seção condicional SelfieSearchHomeSection (port do design hi-fi, cenas 390/1280) entre a Central e a newsletter; sem engine/captura na home e sem CTA morto quando fechada
- docs: planos C242/C243, AGENTS-public, runbook §C242, changelog

Provas: gate:fast 457 arquivos/5.055 testes; int full 109/1.075; e2e frontendFotosSelfie 9/9; knip verde.
2026-10-01 12:29:04 -03:00

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

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.