teqo-backup/AGENTS-public.md
Francisco Solla 9e9864f420 feat(C234): busca por selfie no álbum público — índice A/C consentido, selfie on-device
- /fotos/encontre + POST /api/fotos/selfie: só o vetor 128-d, sem imagem, sem score e
  sem nomes de terceiros; fail-closed pela flag photoAlbum.selfieSearchEnabled (default
  false) e pelos Consents por chave estável (busca-selfie-fotos/busca-selfie-indice, não
  seedados — a superfície nasce fechada até o aval jurídico/DPIA).
- Índice A/C do gate (PR #1370): só quem aderiu é indexado; descriptors do acervo são
  transitórios. Collection faceSubject (admin-only, sem vínculo com Contact), vínculos
  matchedPhotos e marker faces.checkedKey no archivePhoto (modelo + enrolledAt).
- Engine @vladmandic/face-api nos dois lados: browser lazy com modelos same-origin
  (copy-face-vision-assets no prebuild/predev/Docker) e CLI node-wasm sem tfjs-node.
  CLIs ops-only faces:enroll / faces:index --apply|--verify com guardas C231/C232.
- Opt-out self-service por reconhecimento (leave-index) + canal de remoção do C233;
  rate limit próprio (namespace selfie:<hash>) e bounded body extraído do beacon.
- UI portada classe-a-classe do design estendido (paridade certificada, tier primário);
  e2e frontendFotosSelfie (manifest+curado+projeto, engine stubada) e canário do engine
  real; runbook §C234, contrato em AGENTS-public.md e changelog.
- Migration aditiva add_face_subject (SQL revisado); hard-stops aprovados na sessão.
2026-09-27 20:04:41 -03:00

12 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

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 the query Consent is missing.

The index is A/C by gate decision (PR #1370): only people who adhered and consented are searchable; there is no anonymous face index of the archive (that is option B, gated on DPIA + legal review). Enrollment and indexing are ops-only CLIs (pnpm faces:enroll, pnpm faces:index --apply|--verify), run from the maintenance service with the same write guards as C231/C232 (TEQO_ENV, FACE_*_CONFIRM=1, mirrored S3_*). The batch computes descriptors transiently and persists only the photo↔subject links (faceSubject.matchedPhotos) plus the archivePhoto.faces.checkedKey marker (model + each subject's enrolledAt, so the batch's own writes never invalidate it). The CLI engine (scripts/lib/faceEngine.mjs) loads the same nets from node_modules; a model change invalidates the index (re-enroll + --refresh).

Both sides are fail-closed: selfieSearchEnabled defaults to false, and the two Consent keys (busca-selfie-fotos for the query, busca-selfie-indice for enrollment, in src/lib/campaignConsentKeys.ts) are never seeded — without the admin row with the exact key the page renders the closed state and the API refuses. Editing the enrollment text makes every subject ineligible until re-consent (consentHash snapshot). Opt-out is self-service ("Minha presença" → face match removes the vector and the links, status removed); photo takedown keeps using photoAlbum.removalChannelUrl. 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 §C234.

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.