mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
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.
This commit is contained in:
parent
f96863fad1
commit
f15a2cf1c1
4 changed files with 77 additions and 7 deletions
|
|
@ -42,6 +42,23 @@ PROD_DATABASE_URL="<unpooled Neon 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 `<slug>.<ext>` 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://<prod-domain>/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:
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
46
AGENTS.md
46
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 <name>` → 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/<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 ~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 `<slug>.<ext>` 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://<prod-domain>/api/revalidate" \
|
||||
-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 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
|
||||
```
|
||||
|
||||
|
|
|
|||
12
README.md
12
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://<prod-domain>/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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue