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 fromtitlewhen left empty),type(select enumnoticia|campanha|artigo|evento),category(required single relationship →tag),tags(hasManyrelationship →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 fromname), andhidden(checkbox, admin label "Esconder", defaultfalse). Tags are the shared taxonomy for bothpost.categoryandpost.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-secretorAuthorization: Bearer <secret>; it is compared toREVALIDATE_SECRETwith a constant-time check. REVALIDATE_SECRETmust 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-runningpnpm db:seed:tseso the cached elections-tab reads refresh),municipality-catalog(bust after a migration/seed that adds or renames a município so chip indexes andunstable_cacheloaders refresh),social-feed(bust after a direct-DB write to thesocial-feed-settingsglobal — e.g. an admin-less seed of the YouTube/Instagram feed config; admin edits self-revalidate),archivePhotos(bust after a direct-DB write toarchivePhotorows — admin edits self-revalidate via the collection hook; the public album/fotoscaches under it). Unknown tag →400. - Instagram sync status (S11): the global's
instagramSyncStatusjsonb (hidden) is written by the render path, byafterChange(re-sync only when IG credentials changed), and by the admin buttonPOST /api/social-feed/sync(admin session viapayload.auth+ same-origin guard); theInstagramSyncStatusPanelui field renders it. Runbook de credenciais:docs/ops/instagram-feed-token-runbook.md. - Responses:
200 { revalidated: true, tag: '...' }on success;401if the secret is missing/wrong;500ifREVALIDATE_SECRETis not configured on the server. - This POST is the required last step of the post-seed / direct-DB-change runbook.