From 3f3c5953f62adaccad68dab8b72a985308526aa8 Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 2 Sep 2026 17:14:20 +0100 Subject: [PATCH 1/2] style(copy): remove em dashes from the site's prose The em dash is one of the clearest tells of machine-written text, which is the exact impression this work exists to remove. Rewritten rather than substituted: where a dash was carrying a real aside the sentence is split or recast, not patched with a comma. Covers the About page, the two Callout labels an editor sees in the admin panel, and PUBLISHING.md, which defines the house style and should follow it. The rule is now recorded in that house style and in the spec's voice rules, so it survives this branch. Code comments are left alone: they are not copy, and the surrounding codebase uses the same punctuation throughout. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM --- .../specs/2026-09-02-about-and-blog-design.md | 2 ++ nextjs-app/app/(frontend)/about/page.tsx | 23 +++++++++--------- nextjs-app/blocks/Callout.ts | 4 ++-- nextjs-app/docs/PUBLISHING.md | 24 +++++++++++-------- 4 files changed, 30 insertions(+), 23 deletions(-) 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/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/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. -- 2.54.0 From e2c63a9905f15a9889223fd4ca0797d983c4c78e Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 2 Sep 2026 18:31:38 +0100 Subject: [PATCH 2/2] fix(cms): ship the initial migration so a container finds its tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staging failed on boot with 42P01, relation "payload.users" does not exist. The schema was empty because no migration existed, and the adapter cannot create tables itself: db-postgres/connect.js gates push on NODE_ENV !== 'production', so it is inert in a deployed container regardless of config. 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. It only worked against the throwaway database used to generate it because the schema was created there by hand, so every real environment would have failed on the first statement. CREATE SCHEMA IF NOT EXISTS is hand-added at the top of up(), which makes it exactly the kind of edit a regeneration discards silently; a test asserts it is present and ordered before the first CREATE TABLE. payload-types.ts is now committed rather than ignored. Ignoring it meant CI typechecked against looser types than a developer with a generated copy, which is how a Record cast passed CI and then failed locally the moment the file appeared. The post page uses the generated Post and Media types instead, and narrows heroImage rather than asserting it, since the field is an id at shallow depth. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM --- nextjs-app/.gitignore | 2 - .../__tests__/payload/migrations.test.ts | 52 + .../app/(frontend)/blog/[slug]/page.tsx | 43 +- .../migrations/20260902_172826_initial.json | 1566 +++++++++++++++++ .../migrations/20260902_172826_initial.ts | 216 +++ nextjs-app/migrations/index.ts | 9 + nextjs-app/payload-types.ts | 443 +++++ nextjs-app/payload.config.ts | 10 +- 8 files changed, 2319 insertions(+), 22 deletions(-) create mode 100644 nextjs-app/__tests__/payload/migrations.test.ts create mode 100644 nextjs-app/migrations/20260902_172826_initial.json create mode 100644 nextjs-app/migrations/20260902_172826_initial.ts create mode 100644 nextjs-app/migrations/index.ts create mode 100644 nextjs-app/payload-types.ts 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)/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/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, }); -- 2.54.0