From f15a2cf1c12feb26ad82c377590868ccd5713b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Wed, 15 Jul 2026 21:31:06 -0300 Subject: [PATCH] Document posts/tags feature in project docs Update AGENTS.md, the local-database and payload-migrations skills, and the README to reflect the shipped Post/Tag collections, the electoral `hidden` visibility control and fail-closed `isPostVisible` rule, the mixed-ISR `posts` cache model with self-revalidating afterChange hooks, the `db:seed:posts` seed, and the required post-seed/direct-DB revalidation via POST /api/revalidate. --- .cursor/skills/local-database/SKILL.md | 17 ++++++++ .cursor/skills/payload-migrations/SKILL.md | 9 ++++- AGENTS.md | 46 +++++++++++++++++++--- README.md | 12 +++++- 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/.cursor/skills/local-database/SKILL.md b/.cursor/skills/local-database/SKILL.md index 4c022e2f..33e0c99a 100644 --- a/.cursor/skills/local-database/SKILL.md +++ b/.cursor/skills/local-database/SKILL.md @@ -42,6 +42,23 @@ PROD_DATABASE_URL="" pnpm db:pull - It **excludes PII**: row data of `contact`, `signature`, `subscription` (and their `_rels`) is not copied; the table structure is kept. - It resets and rebuilds the local `public` schema, so it overwrites local data. +## Seed news content (`pnpm db:seed:posts`) + +```bash +pnpm db:seed:posts +``` + +- `scripts/seed-posts.mjs` fetches ~39 articles **live** from jorgesolla.com.br (WordPress REST API, HTML-crawl fallback) and imports them into `post`/`tag`/`media` as published `noticia` posts, converting the WP HTML body to Payload Lexical and uploading covers to Vercel Blob. +- Same safety guard as `pnpm dev`: it refuses a non-local `DATABASE_URL` unless `ALLOW_REMOTE_DB=true`. +- **Idempotent by slug** — re-running skips existing posts and reuses tags/media, so it never duplicates. Because the Vercel Blob store is shared across environments, it deletes any orphan blob under the deterministic `.` key before uploading, so it stays re-runnable. +- **Post-seed revalidation (required for prod):** the seed writes from a CLI process outside the deployed runtime, so it does **not** bust production's frozen `posts` cache. After seeding prod content — or after ANY direct-DB write — bust it: + + ```bash + curl -X POST "https:///api/revalidate" -H "x-revalidate-secret: $REVALIDATE_SECRET" + ``` + + `REVALIDATE_SECRET` must be set in the Vercel production env. See the "Posts & Tags" section in `AGENTS.md` for the full rationale (returns `401` on a bad secret, `500` if the secret is unset). + ## Prepare / reset the test database The test DB needs the schema applied via migrations: diff --git a/.cursor/skills/payload-migrations/SKILL.md b/.cursor/skills/payload-migrations/SKILL.md index 49cdbc3a..91c98114 100644 --- a/.cursor/skills/payload-migrations/SKILL.md +++ b/.cursor/skills/payload-migrations/SKILL.md @@ -13,6 +13,13 @@ Schema is managed exclusively by committed migrations in `src/migrations/`. `pus Production is already baselined at migration `20260715_163458_initial` (marked applied in prod's `payload_migrations`). **Never regenerate, rename, or replace the initial migration** — doing so would make prod try to recreate existing tables. +Current chain (`src/migrations/index.ts`, order matters): + +1. `20260715_163458_initial` — baseline. +2. `20260715_163500_consent_text_to_jsonb` — hand-written `Consent.text` `varchar` → `jsonb` reconciliation. +3. `20260715_181058_add_post_and_tag` — creates the `post`/`tag` tables (+ versions/`_rels`) and the `enum_post_type` (`noticia|campanha|artigo|evento`) enum. +4. `20260715_215834_rename_tag_visible_to_hidden` — hand-written, data-preserving inversion of the tag visibility flag (`visible` → `hidden`, backfilled as its logical inverse). + ## Making a schema change 1. Make sure the local DB is running and current: `pnpm db:start`, `DATABASE_URL` local, `pnpm migrate`. @@ -31,7 +38,7 @@ Production is already baselined at migration `20260715_163458_initial` (marked a For changes Payload's diff can't generate (data backfills, type reconciliations), add a file manually and register it in `index.ts`. Make it **idempotent** so it's safe on databases already in the target state. -Follow the existing example `src/migrations/20260715_163500_consent_text_to_jsonb.ts`: +Two existing examples: `src/migrations/20260715_163500_consent_text_to_jsonb.ts` (guarded type reconciliation) and `src/migrations/20260715_215834_rename_tag_visible_to_hidden.ts` (a data-preserving column rename that backfills `hidden = NOT visible` instead of letting the generated diff drop + recreate the column and lose which tags were toggled). Follow the consent example's guard pattern: ```ts import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' diff --git a/AGENTS.md b/AGENTS.md index 9572d44a..1aba3915 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si - `Contact` is the normalized "person" record (name, email, phone, state/city via `src/lib/cities.ts`, CEP). Don't create a parallel "person" collection for new features (e.g. líderanças/apoiadores) — add a join collection that relates to `Contact`, the same way `Signature` and `Subscription` do. - Every action that writes to more than one collection (see `src/app/(frontend)/actions/*.ts`) wraps the writes in a Payload transaction (`payload.db.beginTransaction/commitTransaction/rollbackTransaction`) and passes `req: { transactionID }` to every `payload.create`/`payload.update` call. Follow this pattern for any new multi-collection write. - LGPD consent is tracked via a `Consent` collection (richText, versioned) referenced by relationship from `Signature`/`Subscription`. Any new form/opt-in flow should link to a `Consent` document the same way — don't invent a new consent mechanism. -- Cache invalidation: collections/globals call `revalidateDocumentById` (`src/utilities/documents.ts`) / `revalidateGlobal` (`src/utilities/globals.ts`) in an `afterChange` hook. Add the same hook to any new collection/global that backs a public page. +- Cache invalidation: collections/globals call `revalidateDocumentById` (`src/utilities/documents.ts`) / `revalidateGlobal` (`src/utilities/globals.ts`) in an `afterChange` hook. Add the same hook to any new collection/global that backs a public page. Collections whose listing is cached under a shared tag also call the listing helper (`revalidateCollectionListing`, e.g. `revalidatePostsListing()` → `revalidateTag('posts')`) — see "Posts & Tags" below. - Admin UI is organized via `admin.group` (e.g. `'Abaixo-assinados'`, `'Contatos'`, `'Configurações'`, `'Paginas'`) — group new collections consistently instead of leaving them ungrouped. - i18n: Payload admin defaults to `pt` (not `pt-BR` — see `payload.config.ts`, `payload/i18n/pt`). - Language & naming conventions: ALL code identifiers are in English — variable, function, parameter, type/interface names, local constants, and Next.js dynamic route segment folder names used as param keys (e.g. `[type]/[category]/[slug]`, not `[tipo]/[categoria]`). Portuguese is allowed ONLY in: user-visible string literals (JSX text, button/labels), image `alt` text, SEO/metadata values (title/description/keywords), Payload admin config text (`labels`/`singular`/`plural`/`admin.description`/field `label`), and intentional URL slug/enum VALUES kept in Portuguese for SEO (e.g. the `post.type` enum values `noticia|campanha|artigo|evento`, tag/category slugs like `saude`, `eleitoral`). Never translate those data/URL values or admin labels. Renaming a route segment folder changes only the param KEY, never the public URL (segment values come from the data). @@ -28,14 +28,50 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si - Schema changes go through committed Payload migrations — `push: false` everywhere, never flip it on against a remote DB. Edit configs → `pnpm migrate:create ` → commit `src/migrations/*` (both `.ts` and `.json`, plus `index.ts`) → `pnpm migrate` locally. `pnpm build` runs `payload migrate`, so **every Vercel deploy applies pending migrations to prod**. Prod is baselined at migration `20260715_163458_initial` — never regenerate or replace the initial migration. Full workflow (incl. hand-written data/reconciliation migrations) lives in the `payload-migrations` skill (`.cursor/skills/payload-migrations`). - `Consent.text` was reconciled from `varchar` to `jsonb` via `20260715_163500_consent_text_to_jsonb` (matching the `richText` field). Any future field-type change is a migration, not a manual DB edit. - To copy production content locally, use `pnpm db:pull` (`scripts/db-pull.mjs`): it only reads prod, only writes local, and excludes supporter PII (`contact`/`signature`/`subscription` row data). +- To (re)populate news content, `pnpm db:seed:posts` fetches articles live from jorgesolla.com.br into the LOCAL db (same non-local `DATABASE_URL` guard as `pnpm dev`). It writes from a CLI process outside the deployed runtime, so after seeding — or after ANY direct-DB change — you must bust the production `posts` cache via `POST /api/revalidate`. See "Posts & Tags" below. ## Known Gaps (as of 2026-07-15 — resolve before relying on this file for onboarding new devs) 1. **`Users` has no `roles` field yet.** The RBAC examples below are generic Payload patterns, not yet implemented here — every admin user today has full access. Add `roles` (e.g. `admin`/`editor`/`assessor`) before opening the admin to more people or building `/campanha`. 2. **`Consent` document IDs are hardcoded** in code (e.g. `consent: 2` in `submitWhatsapp.ts`). Fragile — breaks silently if that document is ever recreated with a different ID, and blocks having a reproducible local seed. Consider referencing by a stable slug/key instead. (The column-type drift is fixed; the hardcoded-ID fragility remains.) -3. **No `Pages`/`Posts` collections yet** for institutional content (bio, propostas, notícias/imprensa). `HomePage` global today only has a single image field — most homepage content is still hardcoded in `src/app/(frontend)/(home)/page.tsx`. This is the biggest gap for reproducing jorgesolla.com.br content in Payload. +3. **`Post`/`Tag` ship the news system, but there is still no `Pages` collection for institutional content.** Partly resolved: the `post`/`tag` collections back the news/publications system, and the home "Últimas notícias" list plus the `/[type]`, `/[type]/[category]`, and article routes are live (see "Posts & Tags" below). Still hardcoded/pending: the home hero heading + subtitle copy (`HomePage` global still exposes only a single `image` field — the text lives in `src/app/(frontend)/(home)/page.tsx`), and there is no `Pages` collection yet for institutional content such as the bio and propostas. -**Recently resolved (2026-07-15):** local Postgres now runs via `docker-compose.yml` (Postgres 17, matching prod, auto-creating `teqo_test`); Payload migrations are set up with prod baselined; dev/test database guards prevent accidental production access. See the two database skills. +**Recently resolved (2026-07-15):** local Postgres now runs via `docker-compose.yml` (Postgres 17, matching prod, auto-creating `teqo_test`); Payload migrations are set up with prod baselined; dev/test database guards prevent accidental production access (see the two database skills). The `post`/`tag` collections and the public news/article/listing routes shipped and are deployed (see "Posts & Tags" below). + +## 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 above 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/`), 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 ~39 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 to Vercel Blob. 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`). The Vercel Blob store is shared across environments, so before uploading a cover it deletes any orphan blob left under the same deterministic `.` key, keeping the seed re-runnable. + +### 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`), which calls `revalidatePostsListing()`: + +```bash +curl -X POST "https:///api/revalidate" \ + -H "x-revalidate-secret: $REVALIDATE_SECRET" +``` + +- **Auth:** send the secret via `x-revalidate-secret` or `Authorization: Bearer `; it is compared to `REVALIDATE_SECRET` with a constant-time check. +- `REVALIDATE_SECRET` must be set in the Vercel **production** env (and in your local env if you want to test the endpoint). It is documented in `.env.example`. +- **Responses:** `200 { revalidated: true, tag: 'posts' }` 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. ## Core Principles @@ -62,11 +98,11 @@ src/ │ ├── (frontend)/ # Frontend routes + server actions (src/app/(frontend)/actions/*.ts) │ ├── (payload)/ # Payload admin routes │ └── (campanha)/ # NOT YET CREATED — planned internal campaign-management area -├── collections/ # Collection configs (Users, Media, Petition, Signature, Consent, Contact, Subscription) +├── collections/ # Collection configs (Users, Media, Petition, Signature, Consent, Contact, Subscription, Post, Tag) ├── globals/ # Global configs (SiteSettings, HomePage, Metadata) ├── components/ # Custom React components (incl. components/ui from shadcn) ├── lib/ # cities.ts, zod schemas (lib/schemas/*) -├── utilities/ # documents.ts / globals.ts (revalidation helpers) +├── utilities/ # documents.ts / globals.ts / posts.ts (revalidation + posts helpers) └── payload.config.ts # Main config ``` diff --git a/README.md b/README.md index 31a5cfe5..7ba67117 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,17 @@ Teqo then becomes a configurable base product for other politicians in Brazil: 6. Start the dev server: `pnpm dev` 7. Open: `http://localhost:3000` -Useful scripts: `pnpm db:start` / `pnpm db:stop` (local Postgres), `pnpm db:pull` (refresh local content from prod). +Useful scripts: `pnpm db:start` / `pnpm db:stop` (local Postgres), `pnpm db:pull` (refresh local content from prod), `pnpm db:seed:posts` (import news posts/tags fresh from the live jorgesolla.com.br site into the local db; idempotent by slug, refuses a non-local database). + +### Content & cache revalidation + +Public news pages (`/[type]`, `/[type]/[category]`, articles, and the home news list) are cached under a shared `posts` tag. Editing a post or tag in the deployed admin self-revalidates via `afterChange` hooks. Any write that bypasses the deployed runtime — a direct-DB seed (`pnpm db:seed:posts`), SQL, or a restore — does **not** bust production's cache, so afterwards call the secured endpoint: + +``` +curl -X POST "https:///api/revalidate" -H "x-revalidate-secret: $REVALIDATE_SECRET" +``` + +`REVALIDATE_SECRET` must be set in the Vercel production env (see `.env.example`). See the "Posts & Tags" section of `AGENTS.md` for the full model. ### Database migrations