PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 32s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m9s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m15s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m26s
Review findings on #140. Drafts were reachable. Posts granted unconditional public read and the _status filter lived only in the pages that query the collection — which is a convenience, not a control. Payload's documentation is explicit: "The `draft` argument alone does not restrict documents with _status: 'draft' from being returned by the API." A direct GET /cms-api/posts would have handed every unpublished draft to any visitor. Read access now returns a query constraint for anonymous callers, which is the documented mechanism. The --drop claim was asserted across four files while the spec still listed it as an open question. Now verified rather than assumed: run_full_migration drops exactly ["school_results", "schools"] by name, there is no drop_all() or DROP SCHEMA anywhere in backend/, the only other drop is schema-qualified to marts, and nothing sets search_path. The guarantee is stronger than schema isolation alone — those two table names do not exist in Payload — so the claim stands, but it now rests on cited code. The spec records the evidence and closes the open item. findPost is wrapped in React's cache(): Next calls generateMetadata and the page separately for one request, so every post view ran the same query against Postgres twice. The bare .lede rule was dead — .prose p scores (0,1,1) and outranks it — so only .prose .lede ever applied. Removed, with the specificity noted so the surviving selector is not "simplified" back into a silent regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
100 lines
3.2 KiB
TypeScript
100 lines
3.2 KiB
TypeScript
import type { CollectionConfig } from 'payload';
|
|
import { revalidatePath } from 'next/cache';
|
|
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical';
|
|
import { Callout } from '@/blocks/Callout';
|
|
|
|
/**
|
|
* Drop the cached copy of a post page when it changes.
|
|
*
|
|
* Only the post page needs this. The blog index, the RSS feed and the content
|
|
* sitemap are force-dynamic — they have no dynamic params, so Next would
|
|
* prerender them at build time, where CI has no database — which means they
|
|
* already reflect a change on the next request.
|
|
*
|
|
* /blog/[slug] is ISR: generated on first request and cached, so without this
|
|
* an edit to an already-published post would not appear until the revalidate
|
|
* window expired — up to an hour of a writer concluding that saving is broken.
|
|
*
|
|
* Payload runs in the same process as Next, so this is a direct revalidatePath
|
|
* call: no webhook, no shared secret, no network hop to get wrong.
|
|
*/
|
|
function revalidatePost(slug: string) {
|
|
revalidatePath(`/blog/${slug}`);
|
|
}
|
|
|
|
export const Posts: CollectionConfig = {
|
|
slug: 'posts',
|
|
access: {
|
|
/*
|
|
* Drafts must be hidden here, not in the pages that query this collection.
|
|
*
|
|
* From Payload's own documentation: "The `draft` argument alone does not
|
|
* restrict documents with `_status: 'draft'` from being returned by the
|
|
* API." The blog index and post page both filter on `_status`, but that
|
|
* is a convenience, not a control — a direct GET /cms-api/posts would
|
|
* hand every unpublished draft to any visitor.
|
|
*
|
|
* Returning a query constraint rather than a boolean is the documented
|
|
* mechanism: Payload merges it into every read for an anonymous caller.
|
|
*/
|
|
read: ({ req }) => {
|
|
if (req.user) return true;
|
|
return { _status: { equals: 'published' } };
|
|
},
|
|
},
|
|
admin: {
|
|
useAsTitle: 'title',
|
|
defaultColumns: ['title', 'publishedAt', '_status'],
|
|
},
|
|
versions: {
|
|
// Posts get written across several sittings and previewed before they go
|
|
// live. Without drafts, saving is publishing.
|
|
drafts: true,
|
|
},
|
|
hooks: {
|
|
afterChange: [({ doc }) => { revalidatePost(String(doc.slug)); }],
|
|
afterDelete: [({ doc }) => { revalidatePost(String(doc.slug)); }],
|
|
},
|
|
fields: [
|
|
{ name: 'title', type: 'text', required: true },
|
|
{
|
|
name: 'slug',
|
|
type: 'text',
|
|
required: true,
|
|
unique: true,
|
|
index: true,
|
|
admin: {
|
|
position: 'sidebar',
|
|
description: 'The URL segment. Never change it after publishing.',
|
|
},
|
|
},
|
|
{
|
|
name: 'publishedAt',
|
|
type: 'date',
|
|
required: true,
|
|
admin: { position: 'sidebar', date: { pickerAppearance: 'dayOnly' } },
|
|
},
|
|
{
|
|
name: 'excerpt',
|
|
type: 'textarea',
|
|
required: true,
|
|
maxLength: 200,
|
|
admin: {
|
|
description: 'Shown on the index and used as the meta description.',
|
|
},
|
|
},
|
|
{ name: 'heroImage', type: 'upload', relationTo: 'media' },
|
|
{
|
|
name: 'content',
|
|
type: 'richText',
|
|
required: true,
|
|
editor: lexicalEditor({
|
|
features: ({ defaultFeatures }) => [
|
|
...defaultFeatures,
|
|
BlocksFeature({ blocks: [Callout] }),
|
|
],
|
|
}),
|
|
},
|
|
],
|
|
};
|