diff --git a/docs/superpowers/specs/2026-09-02-about-and-blog-design.md b/docs/superpowers/specs/2026-09-02-about-and-blog-design.md index 4617d1c..e56e13c 100644 --- a/docs/superpowers/specs/2026-09-02-about-and-blog-design.md +++ b/docs/superpowers/specs/2026-09-02-about-and-blog-design.md @@ -73,6 +73,8 @@ Applied to About and every post. Recorded here so the voice does not drift. - State limits before someone else finds them. Every post that presents a metric says what it does not show. - No mission statements, no "passionate about", no invented team. +- No em dashes. One of the clearest tells of machine-written prose, which is + the exact problem this work exists to fix. - Short sentences. The existing code comments in this repo are already written this way; the prose should match. diff --git a/nextjs-app/.gitignore b/nextjs-app/.gitignore index 1aa592e..bb160fa 100644 --- a/nextjs-app/.gitignore +++ b/nextjs-app/.gitignore @@ -40,5 +40,3 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -# Payload generates this from the config on every build -/payload-types.ts diff --git a/nextjs-app/__tests__/payload/migrations.test.ts b/nextjs-app/__tests__/payload/migrations.test.ts new file mode 100644 index 0000000..56182f1 --- /dev/null +++ b/nextjs-app/__tests__/payload/migrations.test.ts @@ -0,0 +1,52 @@ +/** + * The generated migration is schema-qualified to "payload" throughout but does + * not create that schema — `schemaName` says where tables go, it does not + * create anything. On staging and production, which have never run it, the + * whole migration fails with `schema "payload" does not exist`. + * + * The CREATE SCHEMA is therefore hand-added, which makes it exactly the kind + * of edit a regeneration silently discards. This is the guard. + */ +import fs from 'fs'; +import path from 'path'; + +const DIR = path.join(__dirname, '..', '..', 'migrations'); + +function migrationFiles() { + return fs + .readdirSync(DIR) + .filter((f) => f.endsWith('.ts') && f !== 'index.ts'); +} + +describe('payload migrations', () => { + it('ships at least one migration, so a container has tables to find', () => { + expect(migrationFiles().length).toBeGreaterThan(0); + }); + + it('creates the payload schema before creating anything in it', () => { + const initial = migrationFiles().find((f) => f.includes('initial'))!; + const sql = fs.readFileSync(path.join(DIR, initial), 'utf8'); + + expect(sql).toMatch(/CREATE SCHEMA IF NOT EXISTS "payload"/); + + // Ordering matters: the schema must be created before the first object + // that lives in it, or the migration fails on its first statement. + expect(sql.indexOf('CREATE SCHEMA IF NOT EXISTS "payload"')) + .toBeLessThan(sql.indexOf('CREATE TABLE "payload"')); + }); + + it('creates the tables the app queries on boot', () => { + const initial = migrationFiles().find((f) => f.includes('initial'))!; + const sql = fs.readFileSync(path.join(DIR, initial), 'utf8'); + for (const table of ['users', 'posts', '_posts_v', 'media', 'payload_migrations']) { + expect(sql).toContain(`CREATE TABLE "payload"."${table}"`); + } + }); + + it('is wired into the adapter, so it runs on server init', () => { + const config = fs.readFileSync( + path.join(__dirname, '..', '..', 'payload.config.ts'), 'utf8', + ); + expect(config).toMatch(/prodMigrations:\s*migrations/); + }); +}); diff --git a/nextjs-app/app/(frontend)/about/page.tsx b/nextjs-app/app/(frontend)/about/page.tsx index 8dea136..3107e06 100644 --- a/nextjs-app/app/(frontend)/about/page.tsx +++ b/nextjs-app/app/(frontend)/about/page.tsx @@ -42,7 +42,7 @@ export default function AboutPage() {

I'm a parent in south-west London. When we started looking at - primary schools, I found the information I needed was all published — + primary schools, I found the information I needed was all published, and almost impossible to hold in one place.

@@ -68,11 +68,11 @@ export default function AboutPage() {

- What I do have is the problem itself — I'm going through primary - admissions right now — and a working knowledge of data, which is what - I do for a living. That combination is enough to take published - figures and present them honestly. It is not enough to tell you which - school is right for your child, and this site never tries to. + What I do have is the problem itself. I'm going through primary + admissions right now, and I work with data for a living. That + combination is enough to take published figures and present them + honestly. It is not enough to tell you which school is right for your + child, and this site never tries to.

Where the numbers come from

@@ -96,9 +96,10 @@ export default function AboutPage() {

A school is not its results. The figures here describe one year group, on a handful of days, measured in a way that suits national statistics - rather than your child. A small cohort makes percentages swing wildly - — in a class of thirty, one pupil is more than three points. Results - say nothing at all about whether a child will be happy somewhere. + rather than your child. A small cohort makes percentages swing wildly. + In a class of thirty, one pupil is worth more than three points. + Results say nothing at all about whether a child will be happy + somewhere.

@@ -113,8 +114,8 @@ export default function AboutPage() {

If something's wrong

- Tell me and I'll fix it. Genuinely — if a figure looks wrong, or - a page gives a misleading impression of a school, I want to know. + Tell me and I'll fix it. If a figure looks wrong, or a page gives + a misleading impression of a school, I genuinely want to know. It's the fastest way this gets better.

diff --git a/nextjs-app/app/(frontend)/blog/[slug]/page.tsx b/nextjs-app/app/(frontend)/blog/[slug]/page.tsx index 1fe3048..d717e91 100644 --- a/nextjs-app/app/(frontend)/blog/[slug]/page.tsx +++ b/nextjs-app/app/(frontend)/blog/[slug]/page.tsx @@ -5,6 +5,7 @@ import { notFound } from 'next/navigation'; import { RichText } from '@payloadcms/richtext-lexical/react'; import type { JSXConvertersFunction } from '@payloadcms/richtext-lexical/react'; import { getCachedPayload } from '@/lib/payload'; +import type { Post, Media } from '@/payload-types'; import { absoluteUrl } from '@/lib/site'; import { blogPostingJsonLd, @@ -24,11 +25,15 @@ import styles from './Post.module.css'; */ export const revalidate = 3600; -interface HeroImage { - url?: string | null; - alt?: string | null; - width?: number | null; - height?: number | null; +/** + * heroImage is `number | Media | null`: an id when the query is shallow, the + * populated document at depth 1. Both pages query at depth 1, but narrowing + * rather than asserting keeps it correct if that ever changes. + */ +function heroOf(post: Post): Media | null { + return typeof post.heroImage === 'object' && post.heroImage !== null + ? post.heroImage + : null; } /** @@ -68,12 +73,12 @@ const findPost = cache(async (slug: string) => { return docs[0] ?? null; }); -function summarise(post: Record) { +function summarise(post: Post) { return { - title: String(post.title), - slug: String(post.slug), - excerpt: String(post.excerpt), - publishedAt: String(post.publishedAt), + title: post.title, + slug: post.slug, + excerpt: post.excerpt, + publishedAt: post.publishedAt, }; } @@ -84,18 +89,18 @@ export async function generateMetadata( const post = await findPost(slug); if (!post) return { title: 'Not found' }; - const hero = post.heroImage as HeroImage | null | undefined; + const hero = heroOf(post); return { - title: String(post.title), - description: String(post.excerpt), + title: post.title, + description: post.excerpt, alternates: { canonical: absoluteUrl(`/blog/${post.slug}`) }, openGraph: { type: 'article', - title: String(post.title), - description: String(post.excerpt), + title: post.title, + description: post.excerpt, url: absoluteUrl(`/blog/${post.slug}`), - publishedTime: String(post.publishedAt), + publishedTime: post.publishedAt, // A post with a hero image shares that; one without falls through to the // generated share card at app/opengraph-image.tsx. ...(hero?.url ? { images: [{ url: hero.url }] } : {}), @@ -110,8 +115,8 @@ export default async function PostPage( const post = await findPost(slug); if (!post) notFound(); - const summary = summarise(post as Record); - const hero = post.heroImage as HeroImage | null | undefined; + const summary = summarise(post); + const hero = heroOf(post); const jsonLd = { '@context': 'https://schema.org', @@ -164,7 +169,7 @@ export default async function PostPage( )}
- +
); diff --git a/nextjs-app/blocks/Callout.ts b/nextjs-app/blocks/Callout.ts index fd41654..f71f6a2 100644 --- a/nextjs-app/blocks/Callout.ts +++ b/nextjs-app/blocks/Callout.ts @@ -16,8 +16,8 @@ export const Callout: Block = { type: 'select', defaultValue: 'caveat', options: [ - { label: 'Caveat — what this does not show', value: 'caveat' }, - { label: 'Note — useful aside', value: 'note' }, + { label: 'Caveat: what this does not show', value: 'caveat' }, + { label: 'Note: useful aside', value: 'note' }, ], }, { name: 'body', type: 'textarea', required: true }, diff --git a/nextjs-app/docs/PUBLISHING.md b/nextjs-app/docs/PUBLISHING.md index 75a6eb6..f50254b 100644 --- a/nextjs-app/docs/PUBLISHING.md +++ b/nextjs-app/docs/PUBLISHING.md @@ -1,12 +1,12 @@ # Publishing to the blog The blog is Payload CMS, running inside the Next.js app. There is no separate -service and no second deploy — writing a post is done in the browser and takes +service and no second deploy. Writing a post is done in the browser and takes effect on the live site within seconds. ## Signing in -`https://www.schoolcompare.co.uk/admin` — one account, no registration. If you +`https://www.schoolcompare.co.uk/admin`, one account, no registration. If you need the account seeded on a fresh environment, run against the container: ```bash @@ -24,29 +24,29 @@ password there. | Field | Notes | |---|---| | **Title** | The `

` and the browser tab. | -| **Slug** | The URL segment, in the sidebar. **Never change it after publishing** — it is the canonical URL, and changing it breaks every existing link and discards the page's accumulated search signal. | +| **Slug** | The URL segment, in the sidebar. **Never change it after publishing.** It is the canonical URL, and changing it breaks every existing link and discards the page's accumulated search signal. | | **Published at** | The date shown on the post and in the feed. | | **Excerpt** | Max 200 characters. Shown on the index *and* used as the meta description, so write it as a standalone sentence rather than a teaser. | | **Hero image** | Optional. Becomes the social share image; without one, the site's generated card is used. | | **Content** | Rich text. `/` inserts a block. | -**Save as draft** while you're working — drafts are not public. **Publish** when +**Save as draft** while you're working; drafts are not public. **Publish** when it's ready. ### The callout block One custom block, `Callout`, with two tones: -- **Caveat** — what a number does *not* show. This is the one that matters: it +- **Caveat**: what a number does *not* show. This is the one that matters. It is how a post states a limitation in context rather than burying it in a closing paragraph. -- **Note** — a useful aside. +- **Note**: a useful aside. ### Images Every image requires alt text; the editor will not let you save without it. Uploads go to a Docker volume on the host, which is backed up separately from -Postgres — an image is not reproducible from the pipeline the way school data +Postgres. An image is not reproducible from the pipeline the way school data is. ## How publishing reaches the live site @@ -55,7 +55,7 @@ is. so a new post appears immediately. - `/blog/[slug]` is cached after its first request. Publishing or editing fires a `revalidatePath` from the collection's `afterChange` hook, which drops that - cached copy — so edits appear immediately too. + cached copy, so edits appear immediately too. If a change doesn't show, it is far more likely the post is still a draft than that the cache is stale. @@ -65,7 +65,7 @@ that the cache is stale. These rules are why the blog exists. A post that ignores them makes the site read more machine-generated, not less. -- **First person singular.** "I built", "I found" — never "we provide". +- **First person singular.** "I built", "I found", never "we provide". - **Concrete over general.** "When we were looking at schools in Wandsworth" beats any amount of stated warmth. - **State limits before someone else finds them.** Every post that presents a @@ -73,10 +73,14 @@ read more machine-generated, not less. human wrote it: generated content does not volunteer its own weaknesses. - **No mission statements, no "passionate about", no invented team.** There is one person here. +- **No em dashes.** They are one of the clearest tells of machine-written + prose, which is the whole problem this blog exists to fix. A full stop, a + colon, a semicolon or a pair of commas does the job and reads as though a + person chose it. - **Short sentences.** - **Never publish a surname, an employer, or a child's name.** The site's author is "Tudor". See `/about`. - **Never invent a figure**, even illustratively. On a site whose whole proposition is official data, a made-up number attached to a real school is - the one thing it cannot do — and no illustrative intent survives being + the one thing it cannot do, and no illustrative intent survives being screenshotted. diff --git a/nextjs-app/migrations/20260902_172826_initial.json b/nextjs-app/migrations/20260902_172826_initial.json new file mode 100644 index 0000000..5a4d160 --- /dev/null +++ b/nextjs-app/migrations/20260902_172826_initial.json @@ -0,0 +1,1566 @@ +{ + "version": "7", + "dialect": "postgresql", + "tables": { + "payload.users_sessions": { + "name": "users_sessions", + "schema": "payload", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "users_sessions_order_idx": { + "name": "users_sessions_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_sessions_parent_id_idx": { + "name": "users_sessions_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_sessions_parent_id_fk": { + "name": "users_sessions_parent_id_fk", + "tableFrom": "users_sessions", + "tableTo": "users", + "schemaTo": "payload", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.users": { + "name": "users", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'Tudor'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "reset_password_token": { + "name": "reset_password_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "reset_password_expiration": { + "name": "reset_password_expiration", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "salt": { + "name": "salt", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "hash": { + "name": "hash", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "login_attempts": { + "name": "login_attempts", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "lock_until": { + "name": "lock_until", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_updated_at_idx": { + "name": "users_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_idx": { + "name": "users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.posts": { + "name": "posts", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "excerpt": { + "name": "excerpt", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "hero_image_id": { + "name": "hero_image_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "_status": { + "name": "_status", + "type": "enum_posts_status", + "typeSchema": "payload", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + } + }, + "indexes": { + "posts_slug_idx": { + "name": "posts_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_hero_image_idx": { + "name": "posts_hero_image_idx", + "columns": [ + { + "expression": "hero_image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_updated_at_idx": { + "name": "posts_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_created_at_idx": { + "name": "posts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts__status_idx": { + "name": "posts__status_idx", + "columns": [ + { + "expression": "_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_hero_image_id_media_id_fk": { + "name": "posts_hero_image_id_media_id_fk", + "tableFrom": "posts", + "tableTo": "media", + "schemaTo": "payload", + "columnsFrom": [ + "hero_image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload._posts_v": { + "name": "_posts_v", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_title": { + "name": "version_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_slug": { + "name": "version_slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_published_at": { + "name": "version_published_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_excerpt": { + "name": "version_excerpt", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_hero_image_id": { + "name": "version_hero_image_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_content": { + "name": "version_content", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version_updated_at": { + "name": "version_updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version__status": { + "name": "version__status", + "type": "enum__posts_v_version_status", + "typeSchema": "payload", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latest": { + "name": "latest", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "_posts_v_parent_idx": { + "name": "_posts_v_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_posts_v_version_version_slug_idx": { + "name": "_posts_v_version_version_slug_idx", + "columns": [ + { + "expression": "version_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_posts_v_version_version_hero_image_idx": { + "name": "_posts_v_version_version_hero_image_idx", + "columns": [ + { + "expression": "version_hero_image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_posts_v_version_version_updated_at_idx": { + "name": "_posts_v_version_version_updated_at_idx", + "columns": [ + { + "expression": "version_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_posts_v_version_version_created_at_idx": { + "name": "_posts_v_version_version_created_at_idx", + "columns": [ + { + "expression": "version_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_posts_v_version_version__status_idx": { + "name": "_posts_v_version_version__status_idx", + "columns": [ + { + "expression": "version__status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_posts_v_created_at_idx": { + "name": "_posts_v_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_posts_v_updated_at_idx": { + "name": "_posts_v_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_posts_v_latest_idx": { + "name": "_posts_v_latest_idx", + "columns": [ + { + "expression": "latest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "_posts_v_parent_id_posts_id_fk": { + "name": "_posts_v_parent_id_posts_id_fk", + "tableFrom": "_posts_v", + "tableTo": "posts", + "schemaTo": "payload", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_posts_v_version_hero_image_id_media_id_fk": { + "name": "_posts_v_version_hero_image_id_media_id_fk", + "tableFrom": "_posts_v", + "tableTo": "media", + "schemaTo": "payload", + "columnsFrom": [ + "version_hero_image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.media": { + "name": "media", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_u_r_l": { + "name": "thumbnail_u_r_l", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filesize": { + "name": "filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_x": { + "name": "focal_x", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_y": { + "name": "focal_y", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "sizes_thumbnail_url": { + "name": "sizes_thumbnail_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "sizes_thumbnail_width": { + "name": "sizes_thumbnail_width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "sizes_thumbnail_height": { + "name": "sizes_thumbnail_height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "sizes_thumbnail_mime_type": { + "name": "sizes_thumbnail_mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "sizes_thumbnail_filesize": { + "name": "sizes_thumbnail_filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "sizes_thumbnail_filename": { + "name": "sizes_thumbnail_filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "sizes_hero_url": { + "name": "sizes_hero_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "sizes_hero_width": { + "name": "sizes_hero_width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "sizes_hero_height": { + "name": "sizes_hero_height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "sizes_hero_mime_type": { + "name": "sizes_hero_mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "sizes_hero_filesize": { + "name": "sizes_hero_filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "sizes_hero_filename": { + "name": "sizes_hero_filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "media_updated_at_idx": { + "name": "media_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_created_at_idx": { + "name": "media_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_filename_idx": { + "name": "media_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_sizes_thumbnail_sizes_thumbnail_filename_idx": { + "name": "media_sizes_thumbnail_sizes_thumbnail_filename_idx", + "columns": [ + { + "expression": "sizes_thumbnail_filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_sizes_hero_sizes_hero_filename_idx": { + "name": "media_sizes_hero_sizes_hero_filename_idx", + "columns": [ + { + "expression": "sizes_hero_filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.payload_kv": { + "name": "payload_kv", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "payload_kv_key_idx": { + "name": "payload_kv_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.payload_locked_documents": { + "name": "payload_locked_documents", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "global_slug": { + "name": "global_slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_locked_documents_global_slug_idx": { + "name": "payload_locked_documents_global_slug_idx", + "columns": [ + { + "expression": "global_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_updated_at_idx": { + "name": "payload_locked_documents_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_created_at_idx": { + "name": "payload_locked_documents_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.payload_locked_documents_rels": { + "name": "payload_locked_documents_rels", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "users_id": { + "name": "users_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "posts_id": { + "name": "posts_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "media_id": { + "name": "media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payload_locked_documents_rels_order_idx": { + "name": "payload_locked_documents_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_parent_idx": { + "name": "payload_locked_documents_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_path_idx": { + "name": "payload_locked_documents_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_users_id_idx": { + "name": "payload_locked_documents_rels_users_id_idx", + "columns": [ + { + "expression": "users_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_posts_id_idx": { + "name": "payload_locked_documents_rels_posts_id_idx", + "columns": [ + { + "expression": "posts_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_media_id_idx": { + "name": "payload_locked_documents_rels_media_id_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payload_locked_documents_rels_parent_fk": { + "name": "payload_locked_documents_rels_parent_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "payload_locked_documents", + "schemaTo": "payload", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_users_fk": { + "name": "payload_locked_documents_rels_users_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "users", + "schemaTo": "payload", + "columnsFrom": [ + "users_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_posts_fk": { + "name": "payload_locked_documents_rels_posts_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "posts", + "schemaTo": "payload", + "columnsFrom": [ + "posts_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_media_fk": { + "name": "payload_locked_documents_rels_media_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "media", + "schemaTo": "payload", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.payload_preferences": { + "name": "payload_preferences", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_preferences_key_idx": { + "name": "payload_preferences_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_updated_at_idx": { + "name": "payload_preferences_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_created_at_idx": { + "name": "payload_preferences_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.payload_preferences_rels": { + "name": "payload_preferences_rels", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "users_id": { + "name": "users_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payload_preferences_rels_order_idx": { + "name": "payload_preferences_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_parent_idx": { + "name": "payload_preferences_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_path_idx": { + "name": "payload_preferences_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_users_id_idx": { + "name": "payload_preferences_rels_users_id_idx", + "columns": [ + { + "expression": "users_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payload_preferences_rels_parent_fk": { + "name": "payload_preferences_rels_parent_fk", + "tableFrom": "payload_preferences_rels", + "tableTo": "payload_preferences", + "schemaTo": "payload", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_preferences_rels_users_fk": { + "name": "payload_preferences_rels_users_fk", + "tableFrom": "payload_preferences_rels", + "tableTo": "users", + "schemaTo": "payload", + "columnsFrom": [ + "users_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.payload_migrations": { + "name": "payload_migrations", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "batch": { + "name": "batch", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_migrations_updated_at_idx": { + "name": "payload_migrations_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_migrations_created_at_idx": { + "name": "payload_migrations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "payload.enum_posts_status": { + "name": "enum_posts_status", + "schema": "payload", + "values": [ + "draft", + "published" + ] + }, + "payload.enum__posts_v_version_status": { + "name": "enum__posts_v_version_status", + "schema": "payload", + "values": [ + "draft", + "published" + ] + } + }, + "schemas": { + "payload": "payload" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "id": "d3290989-1d0a-421f-83f9-da08029563d4", + "prevId": "00000000-0000-0000-0000-000000000000" +} \ No newline at end of file diff --git a/nextjs-app/migrations/20260902_172826_initial.ts b/nextjs-app/migrations/20260902_172826_initial.ts new file mode 100644 index 0000000..aedc847 --- /dev/null +++ b/nextjs-app/migrations/20260902_172826_initial.ts @@ -0,0 +1,216 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +export async function up({ db, payload, req }: MigrateUpArgs): Promise { + /* + * Hand-added, and it must survive any regeneration of this file. + * + * `schemaName: 'payload'` tells Payload where to put its tables; it does not + * create the schema. Every statement below is qualified to "payload", so on + * a database that has never run this (staging and production both), the + * whole migration fails with `schema "payload" does not exist`. The schema + * only existed on the throwaway database used to generate this because it + * was created there by hand. + */ + await db.execute(sql`CREATE SCHEMA IF NOT EXISTS "payload";`) + + await db.execute(sql` + CREATE TYPE "payload"."enum_posts_status" AS ENUM('draft', 'published'); + CREATE TYPE "payload"."enum__posts_v_version_status" AS ENUM('draft', 'published'); + CREATE TABLE "payload"."users_sessions" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL, + "id" varchar PRIMARY KEY NOT NULL, + "created_at" timestamp(3) with time zone, + "expires_at" timestamp(3) with time zone NOT NULL + ); + + CREATE TABLE "payload"."users" ( + "id" serial PRIMARY KEY NOT NULL, + "display_name" varchar DEFAULT 'Tudor' NOT NULL, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "email" varchar NOT NULL, + "reset_password_token" varchar, + "reset_password_expiration" timestamp(3) with time zone, + "salt" varchar, + "hash" varchar, + "login_attempts" numeric DEFAULT 0, + "lock_until" timestamp(3) with time zone + ); + + CREATE TABLE "payload"."posts" ( + "id" serial PRIMARY KEY NOT NULL, + "title" varchar, + "slug" varchar, + "published_at" timestamp(3) with time zone, + "excerpt" varchar, + "hero_image_id" integer, + "content" jsonb, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "_status" "payload"."enum_posts_status" DEFAULT 'draft' + ); + + CREATE TABLE "payload"."_posts_v" ( + "id" serial PRIMARY KEY NOT NULL, + "parent_id" integer, + "version_title" varchar, + "version_slug" varchar, + "version_published_at" timestamp(3) with time zone, + "version_excerpt" varchar, + "version_hero_image_id" integer, + "version_content" jsonb, + "version_updated_at" timestamp(3) with time zone, + "version_created_at" timestamp(3) with time zone, + "version__status" "payload"."enum__posts_v_version_status" DEFAULT 'draft', + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "latest" boolean + ); + + CREATE TABLE "payload"."media" ( + "id" serial PRIMARY KEY NOT NULL, + "alt" varchar NOT NULL, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "url" varchar, + "thumbnail_u_r_l" varchar, + "filename" varchar, + "mime_type" varchar, + "filesize" numeric, + "width" numeric, + "height" numeric, + "focal_x" numeric, + "focal_y" numeric, + "sizes_thumbnail_url" varchar, + "sizes_thumbnail_width" numeric, + "sizes_thumbnail_height" numeric, + "sizes_thumbnail_mime_type" varchar, + "sizes_thumbnail_filesize" numeric, + "sizes_thumbnail_filename" varchar, + "sizes_hero_url" varchar, + "sizes_hero_width" numeric, + "sizes_hero_height" numeric, + "sizes_hero_mime_type" varchar, + "sizes_hero_filesize" numeric, + "sizes_hero_filename" varchar + ); + + CREATE TABLE "payload"."payload_kv" ( + "id" serial PRIMARY KEY NOT NULL, + "key" varchar NOT NULL, + "data" jsonb NOT NULL + ); + + CREATE TABLE "payload"."payload_locked_documents" ( + "id" serial PRIMARY KEY NOT NULL, + "global_slug" varchar, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + CREATE TABLE "payload"."payload_locked_documents_rels" ( + "id" serial PRIMARY KEY NOT NULL, + "order" integer, + "parent_id" integer NOT NULL, + "path" varchar NOT NULL, + "users_id" integer, + "posts_id" integer, + "media_id" integer + ); + + CREATE TABLE "payload"."payload_preferences" ( + "id" serial PRIMARY KEY NOT NULL, + "key" varchar, + "value" jsonb, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + CREATE TABLE "payload"."payload_preferences_rels" ( + "id" serial PRIMARY KEY NOT NULL, + "order" integer, + "parent_id" integer NOT NULL, + "path" varchar NOT NULL, + "users_id" integer + ); + + CREATE TABLE "payload"."payload_migrations" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar, + "batch" numeric, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + ALTER TABLE "payload"."users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "payload"."posts" ADD CONSTRAINT "posts_hero_image_id_media_id_fk" FOREIGN KEY ("hero_image_id") REFERENCES "payload"."media"("id") ON DELETE set null ON UPDATE no action; + ALTER TABLE "payload"."_posts_v" ADD CONSTRAINT "_posts_v_parent_id_posts_id_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."posts"("id") ON DELETE set null ON UPDATE no action; + ALTER TABLE "payload"."_posts_v" ADD CONSTRAINT "_posts_v_version_hero_image_id_media_id_fk" FOREIGN KEY ("version_hero_image_id") REFERENCES "payload"."media"("id") ON DELETE set null ON UPDATE no action; + ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_posts_fk" FOREIGN KEY ("posts_id") REFERENCES "payload"."posts"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "payload"."media"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "payload"."payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "payload"."payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action; + CREATE INDEX "users_sessions_order_idx" ON "payload"."users_sessions" USING btree ("_order"); + CREATE INDEX "users_sessions_parent_id_idx" ON "payload"."users_sessions" USING btree ("_parent_id"); + CREATE INDEX "users_updated_at_idx" ON "payload"."users" USING btree ("updated_at"); + CREATE INDEX "users_created_at_idx" ON "payload"."users" USING btree ("created_at"); + CREATE UNIQUE INDEX "users_email_idx" ON "payload"."users" USING btree ("email"); + CREATE UNIQUE INDEX "posts_slug_idx" ON "payload"."posts" USING btree ("slug"); + CREATE INDEX "posts_hero_image_idx" ON "payload"."posts" USING btree ("hero_image_id"); + CREATE INDEX "posts_updated_at_idx" ON "payload"."posts" USING btree ("updated_at"); + CREATE INDEX "posts_created_at_idx" ON "payload"."posts" USING btree ("created_at"); + CREATE INDEX "posts__status_idx" ON "payload"."posts" USING btree ("_status"); + CREATE INDEX "_posts_v_parent_idx" ON "payload"."_posts_v" USING btree ("parent_id"); + CREATE INDEX "_posts_v_version_version_slug_idx" ON "payload"."_posts_v" USING btree ("version_slug"); + CREATE INDEX "_posts_v_version_version_hero_image_idx" ON "payload"."_posts_v" USING btree ("version_hero_image_id"); + CREATE INDEX "_posts_v_version_version_updated_at_idx" ON "payload"."_posts_v" USING btree ("version_updated_at"); + CREATE INDEX "_posts_v_version_version_created_at_idx" ON "payload"."_posts_v" USING btree ("version_created_at"); + CREATE INDEX "_posts_v_version_version__status_idx" ON "payload"."_posts_v" USING btree ("version__status"); + CREATE INDEX "_posts_v_created_at_idx" ON "payload"."_posts_v" USING btree ("created_at"); + CREATE INDEX "_posts_v_updated_at_idx" ON "payload"."_posts_v" USING btree ("updated_at"); + CREATE INDEX "_posts_v_latest_idx" ON "payload"."_posts_v" USING btree ("latest"); + CREATE INDEX "media_updated_at_idx" ON "payload"."media" USING btree ("updated_at"); + CREATE INDEX "media_created_at_idx" ON "payload"."media" USING btree ("created_at"); + CREATE UNIQUE INDEX "media_filename_idx" ON "payload"."media" USING btree ("filename"); + CREATE INDEX "media_sizes_thumbnail_sizes_thumbnail_filename_idx" ON "payload"."media" USING btree ("sizes_thumbnail_filename"); + CREATE INDEX "media_sizes_hero_sizes_hero_filename_idx" ON "payload"."media" USING btree ("sizes_hero_filename"); + CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload"."payload_kv" USING btree ("key"); + CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload"."payload_locked_documents" USING btree ("global_slug"); + CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload"."payload_locked_documents" USING btree ("updated_at"); + CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload"."payload_locked_documents" USING btree ("created_at"); + CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload"."payload_locked_documents_rels" USING btree ("order"); + CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload"."payload_locked_documents_rels" USING btree ("parent_id"); + CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload"."payload_locked_documents_rels" USING btree ("path"); + CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("users_id"); + CREATE INDEX "payload_locked_documents_rels_posts_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("posts_id"); + CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("media_id"); + CREATE INDEX "payload_preferences_key_idx" ON "payload"."payload_preferences" USING btree ("key"); + CREATE INDEX "payload_preferences_updated_at_idx" ON "payload"."payload_preferences" USING btree ("updated_at"); + CREATE INDEX "payload_preferences_created_at_idx" ON "payload"."payload_preferences" USING btree ("created_at"); + CREATE INDEX "payload_preferences_rels_order_idx" ON "payload"."payload_preferences_rels" USING btree ("order"); + CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload"."payload_preferences_rels" USING btree ("parent_id"); + CREATE INDEX "payload_preferences_rels_path_idx" ON "payload"."payload_preferences_rels" USING btree ("path"); + CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload"."payload_preferences_rels" USING btree ("users_id"); + CREATE INDEX "payload_migrations_updated_at_idx" ON "payload"."payload_migrations" USING btree ("updated_at"); + CREATE INDEX "payload_migrations_created_at_idx" ON "payload"."payload_migrations" USING btree ("created_at");`) +} + +export async function down({ db, payload, req }: MigrateDownArgs): Promise { + await db.execute(sql` + DROP TABLE "payload"."users_sessions" CASCADE; + DROP TABLE "payload"."users" CASCADE; + DROP TABLE "payload"."posts" CASCADE; + DROP TABLE "payload"."_posts_v" CASCADE; + DROP TABLE "payload"."media" CASCADE; + DROP TABLE "payload"."payload_kv" CASCADE; + DROP TABLE "payload"."payload_locked_documents" CASCADE; + DROP TABLE "payload"."payload_locked_documents_rels" CASCADE; + DROP TABLE "payload"."payload_preferences" CASCADE; + DROP TABLE "payload"."payload_preferences_rels" CASCADE; + DROP TABLE "payload"."payload_migrations" CASCADE; + DROP TYPE "payload"."enum_posts_status"; + DROP TYPE "payload"."enum__posts_v_version_status";`) +} diff --git a/nextjs-app/migrations/index.ts b/nextjs-app/migrations/index.ts new file mode 100644 index 0000000..ba271c4 --- /dev/null +++ b/nextjs-app/migrations/index.ts @@ -0,0 +1,9 @@ +import * as migration_20260902_172826_initial from './20260902_172826_initial'; + +export const migrations = [ + { + up: migration_20260902_172826_initial.up, + down: migration_20260902_172826_initial.down, + name: '20260902_172826_initial' + }, +]; diff --git a/nextjs-app/payload-types.ts b/nextjs-app/payload-types.ts new file mode 100644 index 0000000..41b8b7c --- /dev/null +++ b/nextjs-app/payload-types.ts @@ -0,0 +1,443 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * This file was automatically generated by Payload. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +/** + * Supported timezones in IANA format. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "supportedTimezones". + */ +export type SupportedTimezones = + | 'Pacific/Midway' + | 'Pacific/Niue' + | 'Pacific/Honolulu' + | 'Pacific/Rarotonga' + | 'America/Anchorage' + | 'Pacific/Gambier' + | 'America/Los_Angeles' + | 'America/Tijuana' + | 'America/Denver' + | 'America/Phoenix' + | 'America/Chicago' + | 'America/Guatemala' + | 'America/New_York' + | 'America/Bogota' + | 'America/Caracas' + | 'America/Santiago' + | 'America/Buenos_Aires' + | 'America/Sao_Paulo' + | 'Atlantic/South_Georgia' + | 'Atlantic/Azores' + | 'Atlantic/Cape_Verde' + | 'Europe/London' + | 'Europe/Berlin' + | 'Africa/Lagos' + | 'Europe/Athens' + | 'Africa/Cairo' + | 'Europe/Moscow' + | 'Asia/Riyadh' + | 'Asia/Dubai' + | 'Asia/Baku' + | 'Asia/Karachi' + | 'Asia/Tashkent' + | 'Asia/Calcutta' + | 'Asia/Dhaka' + | 'Asia/Almaty' + | 'Asia/Jakarta' + | 'Asia/Bangkok' + | 'Asia/Shanghai' + | 'Asia/Singapore' + | 'Asia/Tokyo' + | 'Asia/Seoul' + | 'Australia/Brisbane' + | 'Australia/Sydney' + | 'Pacific/Guam' + | 'Pacific/Noumea' + | 'Pacific/Auckland' + | 'Pacific/Fiji'; + +export interface Config { + auth: { + users: UserAuthOperations; + }; + blocks: {}; + collections: { + users: User; + posts: Post; + media: Media; + 'payload-kv': PayloadKv; + 'payload-locked-documents': PayloadLockedDocument; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + }; + collectionsJoins: {}; + collectionsSelect: { + users: UsersSelect | UsersSelect; + posts: PostsSelect | PostsSelect; + media: MediaSelect | MediaSelect; + 'payload-kv': PayloadKvSelect | PayloadKvSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + }; + db: { + defaultIDType: number; + }; + fallbackLocale: null; + globals: {}; + globalsSelect: {}; + locale: null; + widgets: { + collections: CollectionsWidget; + }; + user: User; + jobs: { + tasks: unknown; + workflows: unknown; + }; +} +export interface UserAuthOperations { + forgotPassword: { + email: string; + password: string; + }; + login: { + email: string; + password: string; + }; + registerFirstUser: { + email: string; + password: string; + }; + unlock: { + email: string; + password: string; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: number; + displayName: string; + updatedAt: string; + createdAt: string; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + sessions?: + | { + id: string; + createdAt?: string | null; + expiresAt: string; + }[] + | null; + password?: string | null; + collection: 'users'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "posts". + */ +export interface Post { + id: number; + title: string; + /** + * The URL segment. Never change it after publishing. + */ + slug: string; + publishedAt: string; + /** + * Shown on the index and used as the meta description. + */ + excerpt: string; + heroImage?: (number | null) | Media; + content: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + }; + updatedAt: string; + createdAt: string; + _status?: ('draft' | 'published') | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media". + */ +export interface Media { + id: number; + /** + * Describe the image for screen readers. + */ + alt: string; + updatedAt: string; + createdAt: string; + url?: string | null; + thumbnailURL?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + focalX?: number | null; + focalY?: number | null; + sizes?: { + thumbnail?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + hero?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-kv". + */ +export interface PayloadKv { + id: number; + key: string; + data: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents". + */ +export interface PayloadLockedDocument { + id: number; + document?: + | ({ + relationTo: 'users'; + value: number | User; + } | null) + | ({ + relationTo: 'posts'; + value: number | Post; + } | null) + | ({ + relationTo: 'media'; + value: number | Media; + } | null); + globalSlug?: string | null; + user: { + relationTo: 'users'; + value: number | User; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences". + */ +export interface PayloadPreference { + id: number; + user: { + relationTo: 'users'; + value: number | User; + }; + key?: string | null; + value?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations". + */ +export interface PayloadMigration { + id: number; + name?: string | null; + batch?: number | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users_select". + */ +export interface UsersSelect { + displayName?: T; + updatedAt?: T; + createdAt?: T; + email?: T; + resetPasswordToken?: T; + resetPasswordExpiration?: T; + salt?: T; + hash?: T; + loginAttempts?: T; + lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "posts_select". + */ +export interface PostsSelect { + title?: T; + slug?: T; + publishedAt?: T; + excerpt?: T; + heroImage?: T; + content?: T; + updatedAt?: T; + createdAt?: T; + _status?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media_select". + */ +export interface MediaSelect { + alt?: T; + updatedAt?: T; + createdAt?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + focalX?: T; + focalY?: T; + sizes?: + | T + | { + thumbnail?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + hero?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-kv_select". + */ +export interface PayloadKvSelect { + key?: T; + data?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents_select". + */ +export interface PayloadLockedDocumentsSelect { + document?: T; + globalSlug?: T; + user?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences_select". + */ +export interface PayloadPreferencesSelect { + user?: T; + key?: T; + value?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations_select". + */ +export interface PayloadMigrationsSelect { + name?: T; + batch?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collections_widget". + */ +export interface CollectionsWidget { + data?: { + [k: string]: unknown; + }; + width: 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "auth". + */ +export interface Auth { + [k: string]: unknown; +} + + +declare module 'payload' { + export interface GeneratedTypes extends Config {} +} \ No newline at end of file diff --git a/nextjs-app/payload.config.ts b/nextjs-app/payload.config.ts index 6be8850..5d53597 100644 --- a/nextjs-app/payload.config.ts +++ b/nextjs-app/payload.config.ts @@ -8,6 +8,7 @@ import { Users } from '@/collections/Users'; import { Posts } from '@/collections/Posts'; import { Media } from '@/collections/Media'; import { PAYLOAD_API_ROUTE, PAYLOAD_ADMIN_ROUTE } from '@/lib/payloadRoutes'; +import { migrations } from '@/migrations'; const filename = fileURLToPath(import.meta.url); const dirname = path.dirname(filename); @@ -25,8 +26,15 @@ export default buildConfig({ 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. + // as does Airflow's metadata. The schema itself is created by the initial + // migration: schemaName says where tables go, it does not create anything. + // + // prodMigrations runs pending migrations during server init. Without it a + // production container connects to an empty schema and fails its first + // query with 42P01 — the adapter cannot self-create tables, because + // db-postgres/connect.js gates push on NODE_ENV !== 'production'. schemaName: 'payload', + prodMigrations: migrations, }), sharp, });