feat(cms): install Payload and serve the admin panel
Payload 3.88 runs inside the Next app against the existing Postgres, in
its own 'payload' schema so no pipeline operation on public — the app
tables, Airflow's metadata, migrate_csv_to_db.py --drop — can reach blog
content.
Its REST API is mounted at /cms-api. /api is the FastAPI proxy's
catch-all, which would swallow every admin call and forward it to the
backend with no error. The mount points live in lib/payloadRoutes.ts so
there is one definition and a test can assert it without importing
Payload: it is ESM-only, next/jest will not transform it, and appending
transformIgnorePatterns cannot un-ignore a package. Forcing it through
transpilePackages would change how the production build bundles Payload
to serve a test, so the live proof that /api still reaches FastAPI stays
where it belongs — the e2e journeys, which call /api/schools.
The package becomes ESM ("type": "module"), which Payload's CLI requires:
richtext-lexical has top-level await and the config cannot be require()d.
Only two files needed renaming, jest.config.cjs and a build script.
The build is verified to succeed with DATABASE_URL and PAYLOAD_SECRET
both unset, which is how CI builds it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
This commit is contained in:
1 parent
2437ffce42
commit
c5a4d106da
19 files changed
+5441
-226
No files matched your search
@@ -39,3 +39,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Payload generates this from the config on every build
|
||||||
|
/payload-types.ts
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Guards the one thing about Payload's mounting that fails silently.
|
||||||
|
*
|
||||||
|
* payload.config.ts itself cannot be imported here — Payload is ESM-only and
|
||||||
|
* next/jest will not transform it — so this asserts the shared constants and
|
||||||
|
* that the config actually wires them in, by reading its source. The live
|
||||||
|
* proof that /api still reaches FastAPI is the e2e journeys, which call
|
||||||
|
* /api/schools against the running app.
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { PAYLOAD_API_ROUTE, PAYLOAD_ADMIN_ROUTE } from '@/lib/payloadRoutes';
|
||||||
|
|
||||||
|
const CONFIG = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', '..', 'payload.config.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('payload mount points', () => {
|
||||||
|
it('serves the CMS API from /cms-api, never /api', () => {
|
||||||
|
// /api is the FastAPI proxy's catch-all. Payload's default would be
|
||||||
|
// swallowed by it and forwarded to the backend, silently.
|
||||||
|
expect(PAYLOAD_API_ROUTE).toBe('/cms-api');
|
||||||
|
expect(PAYLOAD_API_ROUTE).not.toBe('/api');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves the admin panel from /admin', () => {
|
||||||
|
expect(PAYLOAD_ADMIN_ROUTE).toBe('/admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wires both constants into the Payload config', () => {
|
||||||
|
expect(CONFIG).toContain('PAYLOAD_API_ROUTE');
|
||||||
|
expect(CONFIG).toContain('PAYLOAD_ADMIN_ROUTE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never hardcodes a routes block that could drift from the constants', () => {
|
||||||
|
expect(CONFIG).not.toMatch(/routes:\s*\{[^}]*api:\s*['"]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates CMS tables in their own postgres schema', () => {
|
||||||
|
// Blog content must sit outside `public`, where the app tables, Airflow's
|
||||||
|
// metadata and scripts/migrate_csv_to_db.py --drop all live.
|
||||||
|
expect(CONFIG).toMatch(/schemaName:\s*['"]payload['"]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import config from '@payload-config';
|
||||||
|
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views';
|
||||||
|
import { importMap } from '../importMap.js';
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
params: Promise<{ segments: string[] }>;
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||||
|
generatePageMetadata({ config, params, searchParams });
|
||||||
|
|
||||||
|
export default function NotFound({ params, searchParams }: Args) {
|
||||||
|
return NotFoundPage({ config, importMap, params, searchParams });
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import config from '@payload-config';
|
||||||
|
import { RootPage, generatePageMetadata } from '@payloadcms/next/views';
|
||||||
|
import { importMap } from '../importMap.js';
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
params: Promise<{ segments: string[] }>;
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||||
|
generatePageMetadata({ config, params, searchParams });
|
||||||
|
|
||||||
|
export default function Page({ params, searchParams }: Args) {
|
||||||
|
return RootPage({ config, importMap, params, searchParams });
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||||
|
|
||||||
|
/** @type import('payload').ImportMap */
|
||||||
|
export const importMap = {
|
||||||
|
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* Payload's REST API, mounted at /cms-api rather than /api.
|
||||||
|
* See lib/payloadRoutes.ts — /api is the FastAPI proxy's catch-all.
|
||||||
|
*/
|
||||||
|
import config from '@payload-config';
|
||||||
|
import {
|
||||||
|
REST_DELETE,
|
||||||
|
REST_GET,
|
||||||
|
REST_OPTIONS,
|
||||||
|
REST_PATCH,
|
||||||
|
REST_POST,
|
||||||
|
REST_PUT,
|
||||||
|
} from '@payloadcms/next/routes';
|
||||||
|
|
||||||
|
export const GET = REST_GET(config);
|
||||||
|
export const POST = REST_POST(config);
|
||||||
|
export const DELETE = REST_DELETE(config);
|
||||||
|
export const PATCH = REST_PATCH(config);
|
||||||
|
export const PUT = REST_PUT(config);
|
||||||
|
export const OPTIONS = REST_OPTIONS(config);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import config from '@payload-config';
|
||||||
|
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes';
|
||||||
|
|
||||||
|
export const GET = GRAPHQL_PLAYGROUND_GET(config);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import config from '@payload-config';
|
||||||
|
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes';
|
||||||
|
|
||||||
|
export const POST = GRAPHQL_POST(config);
|
||||||
|
export const OPTIONS = REST_OPTIONS(config);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Root layout for the Payload admin panel.
|
||||||
|
*
|
||||||
|
* This is a SECOND root layout: it renders its own <html>/<body>, as does
|
||||||
|
* app/(frontend)/layout.tsx. Next permits that only while no app/layout.tsx
|
||||||
|
* exists — which is why the site's routes were moved into (frontend). Adding
|
||||||
|
* an app/layout.tsx would nest the admin panel inside the site's nav, footer
|
||||||
|
* and providers and emit nested <html>.
|
||||||
|
*/
|
||||||
|
import type { ServerFunctionClient } from 'payload';
|
||||||
|
import config from '@payload-config';
|
||||||
|
import { RootLayout, handleServerFunctions } from '@payloadcms/next/layouts';
|
||||||
|
import { importMap } from './admin/importMap.js';
|
||||||
|
import '@payloadcms/next/css';
|
||||||
|
|
||||||
|
const serverFunction: ServerFunctionClient = async function (args) {
|
||||||
|
'use server';
|
||||||
|
return handleServerFunctions({ ...args, config, importMap });
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PayloadLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||||
|
{children}
|
||||||
|
</RootLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { CollectionConfig } from 'payload';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The site's only authenticated surface. There is one account and no
|
||||||
|
* registration: `create` is closed to everyone, so the first user is seeded
|
||||||
|
* with `payload create-first-user` and no one can add another through the API.
|
||||||
|
*/
|
||||||
|
export const Users: CollectionConfig = {
|
||||||
|
slug: 'users',
|
||||||
|
auth: {
|
||||||
|
// Slows credential stuffing against a panel that is on the public
|
||||||
|
// internet. Five attempts, then a ten-minute lock.
|
||||||
|
maxLoginAttempts: 5,
|
||||||
|
lockTime: 10 * 60 * 1000,
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
create: () => false,
|
||||||
|
read: ({ req }) => Boolean(req.user),
|
||||||
|
update: ({ req }) => Boolean(req.user),
|
||||||
|
delete: () => false,
|
||||||
|
},
|
||||||
|
admin: { useAsTitle: 'email' },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'displayName',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
// Rendered as the byline on every post. First name only — the site
|
||||||
|
// publishes no surname and no employer.
|
||||||
|
defaultValue: 'Tudor',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
File renamed without changes.
@@ -0,0 +1,15 @@
|
|||||||
|
import { getPayload } from 'payload';
|
||||||
|
import config from '@payload-config';
|
||||||
|
import type { Payload } from 'payload';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Payload instance per process. getPayload() is itself memoised by
|
||||||
|
* Payload, but routing every caller through here keeps the config import in a
|
||||||
|
* single place and gives page code one name to mock in tests.
|
||||||
|
*
|
||||||
|
* Never call this at module scope: CI builds the image with no database
|
||||||
|
* reachable, so a build-time connection attempt fails the build.
|
||||||
|
*/
|
||||||
|
export function getCachedPayload(): Promise<Payload> {
|
||||||
|
return getPayload({ config });
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Where Payload mounts, defined once.
|
||||||
|
*
|
||||||
|
* These are imported by payload.config.ts and asserted by
|
||||||
|
* __tests__/payload/routes.test.ts. They live in their own module because
|
||||||
|
* payload.config.ts cannot be imported from a Jest test: Payload ships
|
||||||
|
* ESM-only, and next/jest's transformIgnorePatterns skips node_modules — you
|
||||||
|
* cannot un-ignore a package by appending patterns, and forcing it through
|
||||||
|
* `transpilePackages` would change how the production build bundles Payload
|
||||||
|
* to serve a test. Keeping the values here makes them testable without
|
||||||
|
* loading Payload at all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload's API base. It must NOT be '/api': that path belongs to
|
||||||
|
* app/(frontend)/api/[...path]/route.ts, a catch-all that proxies to FastAPI.
|
||||||
|
* It would swallow every admin API call and forward it to the backend, and
|
||||||
|
* the failure is silent — no error, just wrong responses.
|
||||||
|
*/
|
||||||
|
export const PAYLOAD_API_ROUTE = '/cms-api';
|
||||||
|
|
||||||
|
/** The admin panel. Kept out of the index by robots.txt and X-Robots-Tag. */
|
||||||
|
export const PAYLOAD_ADMIN_ROUTE = '/admin';
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { withPayload } from '@payloadcms/next/withPayload';
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
// Enable standalone output for Docker
|
// Enable standalone output for Docker
|
||||||
@@ -127,4 +129,4 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withPayload(nextConfig);
|
||||||
Generated
+5184
-223
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,7 @@
|
|||||||
"name": "nextjs-app",
|
"name": "nextjs-app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"description": "SchoolCompare Next.js Application",
|
"description": "SchoolCompare Next.js Application",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -14,18 +15,24 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/react": "^0.27.20",
|
"@floating-ui/react": "^0.27.20",
|
||||||
|
"@payloadcms/db-postgres": "^3.88.0",
|
||||||
|
"@payloadcms/next": "^3.88.0",
|
||||||
|
"@payloadcms/richtext-lexical": "^3.88.0",
|
||||||
"@types/node": "^25.2.0",
|
"@types/node": "^25.2.0",
|
||||||
"@types/react": "^19.2.10",
|
"@types/react": "^19.2.10",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-next": "^16.1.6",
|
"eslint-config-next": "^16.1.6",
|
||||||
|
"graphql": "^16.14.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
|
"payload": "^3.88.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
"sharp": "^0.35.4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
@@ -36,7 +43,6 @@
|
|||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0"
|
||||||
"sharp": "^0.34.5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { buildConfig } from 'payload';
|
||||||
|
import { postgresAdapter } from '@payloadcms/db-postgres';
|
||||||
|
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { Users } from '@/collections/Users';
|
||||||
|
import { PAYLOAD_API_ROUTE, PAYLOAD_ADMIN_ROUTE } from '@/lib/payloadRoutes';
|
||||||
|
|
||||||
|
const filename = fileURLToPath(import.meta.url);
|
||||||
|
const dirname = path.dirname(filename);
|
||||||
|
|
||||||
|
export default buildConfig({
|
||||||
|
admin: { user: Users.slug },
|
||||||
|
// Defined in lib/payloadRoutes.ts, which carries the reasoning and is what
|
||||||
|
// the test asserts. Never inline these — /api belongs to the FastAPI proxy.
|
||||||
|
routes: { api: PAYLOAD_API_ROUTE, admin: PAYLOAD_ADMIN_ROUTE },
|
||||||
|
collections: [Users],
|
||||||
|
editor: lexicalEditor(),
|
||||||
|
secret: process.env.PAYLOAD_SECRET || '',
|
||||||
|
typescript: { outputFile: path.resolve(dirname, 'payload-types.ts') },
|
||||||
|
db: postgresAdapter({
|
||||||
|
pool: { connectionString: process.env.DATABASE_URL },
|
||||||
|
// Its own schema, so no pipeline operation on `public` can reach blog
|
||||||
|
// content. scripts/migrate_csv_to_db.py --drop lives in that blast radius,
|
||||||
|
// as does Airflow's metadata.
|
||||||
|
schemaName: 'payload',
|
||||||
|
}),
|
||||||
|
sharp,
|
||||||
|
});
|
||||||
File renamed without changes.
@@ -25,6 +25,9 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./*"
|
"./*"
|
||||||
|
],
|
||||||
|
"@payload-config": [
|
||||||
|
"./payload.config.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user