From 40f17a1becd4e9aee0153d2ba374d7a914f5418c Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Thu, 20 Aug 2026 09:12:17 +0200 Subject: [PATCH 01/13] feat(seed): add seeded-dummy generator flag --- src/fields/GeneratorFlags/index.ts | 5 +++++ src/types/payload.ts | 32 ++++++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/fields/GeneratorFlags/index.ts b/src/fields/GeneratorFlags/index.ts index ffaf2a4..1fdce87 100644 --- a/src/fields/GeneratorFlags/index.ts +++ b/src/fields/GeneratorFlags/index.ts @@ -7,6 +7,10 @@ import { normalizeIncomingFlags } from './hooks/normalizeIncomingFlags' * generates thumbnails and resume documents, and used to filter those out of * the admin list views (see `scripts/seed-query-presets.ts`). * + * `seeded-dummy` is set by seed scripts (see `scripts/seed-pages.ts` and its + * siblings) on fixture documents, so they can be found and removed without + * touching real content. + * * A non-empty list *is* the "this was generated" marker — hand-uploaded assets * carry none — so the flags describe only what kind of artefact it is. * @@ -25,6 +29,7 @@ export const GENERATOR_FLAGS = [ 'audio-thumbnail', 'video-thumbnail', 'document-thumbnail', + 'seeded-dummy', ] as const export type GeneratorFlag = (typeof GENERATOR_FLAGS)[number] diff --git a/src/types/payload.ts b/src/types/payload.ts index 50ec8eb..7898bf3 100644 --- a/src/types/payload.ts +++ b/src/types/payload.ts @@ -369,7 +369,13 @@ export interface MediaImage { } | null; blurDataURL?: string | null; generatorFlags?: ( - 'resume-asset' | 'thumbnail' | 'document' | 'audio-thumbnail' | 'video-thumbnail' | 'document-thumbnail' + | 'resume-asset' + | 'thumbnail' + | 'document' + | 'audio-thumbnail' + | 'video-thumbnail' + | 'document-thumbnail' + | 'seeded-dummy' )[]; prefix?: string | null; updatedAt: string; @@ -423,7 +429,13 @@ export interface MediaVideo { }[] | null; generatorFlags?: ( - 'resume-asset' | 'thumbnail' | 'document' | 'audio-thumbnail' | 'video-thumbnail' | 'document-thumbnail' + | 'resume-asset' + | 'thumbnail' + | 'document' + | 'audio-thumbnail' + | 'video-thumbnail' + | 'document-thumbnail' + | 'seeded-dummy' )[]; prefix?: string | null; updatedAt: string; @@ -872,7 +884,13 @@ export interface MediaDocument { }[] | null; generatorFlags?: ( - 'resume-asset' | 'thumbnail' | 'document' | 'audio-thumbnail' | 'video-thumbnail' | 'document-thumbnail' + | 'resume-asset' + | 'thumbnail' + | 'document' + | 'audio-thumbnail' + | 'video-thumbnail' + | 'document-thumbnail' + | 'seeded-dummy' )[]; prefix?: string | null; updatedAt: string; @@ -910,7 +928,13 @@ export interface MediaAudio { [k: string]: unknown; } | null; generatorFlags?: ( - 'resume-asset' | 'thumbnail' | 'document' | 'audio-thumbnail' | 'video-thumbnail' | 'document-thumbnail' + | 'resume-asset' + | 'thumbnail' + | 'document' + | 'audio-thumbnail' + | 'video-thumbnail' + | 'document-thumbnail' + | 'seeded-dummy' )[]; prefix?: string | null; updatedAt: string; From 190776dff249b4903f265ed638ed77392ac1c8d0 Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Thu, 20 Aug 2026 09:16:04 +0200 Subject: [PATCH 02/13] feat(seed): add GeneratorFlagsField to seedable collections Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT --- src/collections/BlogPosts/index.ts | 3 + src/collections/BlogTopics/index.ts | 3 + src/collections/Pages/index.ts | 3 + src/collections/ResumeCustomers/index.ts | 3 + src/collections/ResumeDocuments/index.ts | 3 + src/collections/ResumeJobs/index.ts | 3 + src/collections/ResumeProjects/index.ts | 3 + src/collections/ResumeSkillTags/index.ts | 3 + src/collections/ResumeSkills/index.ts | 3 + src/types/payload.ts | 90 ++++++++++++++++++++++++ 10 files changed, 117 insertions(+) diff --git a/src/collections/BlogPosts/index.ts b/src/collections/BlogPosts/index.ts index 12afbfd..5767f30 100644 --- a/src/collections/BlogPosts/index.ts +++ b/src/collections/BlogPosts/index.ts @@ -3,6 +3,7 @@ import { CollectionConfig } from 'payload' import { anyone } from '@/access/anyone' import { authenticated } from '@/access/authenticated' import { authenticatedOrPublished } from '@/access/authenticatedOrPublished' +import { GeneratorFlagsField } from '@/fields/GeneratorFlags' import { MediaField } from '@/fields/Media' import { MetaField } from '@/fields/Meta' import { RichTextField } from '@/fields/RichText' @@ -133,6 +134,8 @@ export const BlogPosts: CollectionConfig = { }, ], }, + + GeneratorFlagsField(), ], trash: true, versions: { diff --git a/src/collections/BlogTopics/index.ts b/src/collections/BlogTopics/index.ts index 9d56b04..d8c2695 100644 --- a/src/collections/BlogTopics/index.ts +++ b/src/collections/BlogTopics/index.ts @@ -2,6 +2,7 @@ import type { AccessArgs, CollectionConfig } from 'payload' import { anyone } from '@/access/anyone' import { authenticated } from '@/access/authenticated' +import { GeneratorFlagsField } from '@/fields/GeneratorFlags' import { MetaField } from '@/fields/Meta' import { RichTextField } from '@/fields/RichText' import { SlugField } from '@/fields/Slug' @@ -179,6 +180,8 @@ export const BlogTopics: CollectionConfig = { }, ], }, + + GeneratorFlagsField(), ], orderable: true, trash: true, diff --git a/src/collections/Pages/index.ts b/src/collections/Pages/index.ts index dafe734..b590fa5 100644 --- a/src/collections/Pages/index.ts +++ b/src/collections/Pages/index.ts @@ -6,6 +6,7 @@ import { authenticated } from '@/access/authenticated' import { authenticatedOrPublished } from '@/access/authenticatedOrPublished' import { BLOCK_SLUGS } from '@/blocks' import { revalidatePage } from '@/collections/Pages/hooks/revalidatePage' +import { GeneratorFlagsField } from '@/fields/GeneratorFlags' import { MetaField } from '@/fields/Meta' import { ProtectedField } from '@/fields/Protected' import { RichTextField } from '@/fields/RichText' @@ -198,6 +199,8 @@ export const Pages: CollectionConfig = { }, ], }, + + GeneratorFlagsField(), ], hooks: { afterChange: [ diff --git a/src/collections/ResumeCustomers/index.ts b/src/collections/ResumeCustomers/index.ts index 95d2585..5a3a789 100644 --- a/src/collections/ResumeCustomers/index.ts +++ b/src/collections/ResumeCustomers/index.ts @@ -1,6 +1,7 @@ import { CollectionConfig } from 'payload' import { authenticated } from '@/access/authenticated' +import { GeneratorFlagsField } from '@/fields/GeneratorFlags' import { SlugField } from '@/fields/Slug' import { SVGUpload } from '@/fields/SVGUpload' import { TitleField } from '@/fields/Title' @@ -111,5 +112,7 @@ export const ResumeCustomers: CollectionConfig = { isSortable: true, }, }, + + GeneratorFlagsField(), ], trash: true, versions: false, diff --git a/src/collections/ResumeProjects/index.ts b/src/collections/ResumeProjects/index.ts index c2e5f60..7df910e 100644 --- a/src/collections/ResumeProjects/index.ts +++ b/src/collections/ResumeProjects/index.ts @@ -1,6 +1,7 @@ import { CollectionConfig } from 'payload' import { authenticated } from '@/access/authenticated' +import { GeneratorFlagsField } from '@/fields/GeneratorFlags' import { RichTextField } from '@/fields/RichText' import { generateResumeDocumentHook } from '@/lib/hooks/collection' import { AdminGroup } from '@/types/admin-panel' @@ -78,6 +79,8 @@ export const ResumeProjects: CollectionConfig position: 'sidebar', }, }, + + GeneratorFlagsField(), ], trash: true, versions: false, diff --git a/src/collections/ResumeSkillTags/index.ts b/src/collections/ResumeSkillTags/index.ts index 02ec60b..15cf525 100644 --- a/src/collections/ResumeSkillTags/index.ts +++ b/src/collections/ResumeSkillTags/index.ts @@ -1,5 +1,6 @@ import { CollectionConfig } from 'payload' +import { GeneratorFlagsField } from '@/fields/GeneratorFlags' import { SlugField } from '@/fields/Slug' import { TitleField } from '@/fields/Title' import { generateResumeDocumentHook } from '@/lib/hooks/collection' @@ -107,5 +108,7 @@ export const ResumeSkillTags: CollectionConfig = { // ], // }, // }, + + GeneratorFlagsField(), ], trash: true, versions: false, diff --git a/src/types/payload.ts b/src/types/payload.ts index 7898bf3..ff825d7 100644 --- a/src/types/payload.ts +++ b/src/types/payload.ts @@ -339,6 +339,15 @@ export interface Page { title?: string | null; description?: string | null; }; + generatorFlags?: ( + | 'resume-asset' + | 'thumbnail' + | 'document' + | 'audio-thumbnail' + | 'video-thumbnail' + | 'document-thumbnail' + | 'seeded-dummy' + )[]; updatedAt: string; createdAt: string; deletedAt?: string | null; @@ -770,6 +779,15 @@ export interface BlogPostData { title?: string | null; description?: string | null; }; + generatorFlags?: ( + | 'resume-asset' + | 'thumbnail' + | 'document' + | 'audio-thumbnail' + | 'video-thumbnail' + | 'document-thumbnail' + | 'seeded-dummy' + )[]; updatedAt: string; createdAt: string; deletedAt?: string | null; @@ -817,6 +835,15 @@ export interface Topic { title?: string | null; description?: string | null; }; + generatorFlags?: ( + | 'resume-asset' + | 'thumbnail' + | 'document' + | 'audio-thumbnail' + | 'video-thumbnail' + | 'document-thumbnail' + | 'seeded-dummy' + )[]; updatedAt: string; createdAt: string; deletedAt?: string | null; @@ -959,6 +986,15 @@ export interface ResumeCustomerData { title: string; slug: string; svg?: string | null; + generatorFlags?: ( + | 'resume-asset' + | 'thumbnail' + | 'document' + | 'audio-thumbnail' + | 'video-thumbnail' + | 'document-thumbnail' + | 'seeded-dummy' + )[]; updatedAt: string; createdAt: string; } @@ -1013,6 +1049,15 @@ export interface ResumeDocumentData { | number | boolean | null; + generatorFlags?: ( + | 'resume-asset' + | 'thumbnail' + | 'document' + | 'audio-thumbnail' + | 'video-thumbnail' + | 'document-thumbnail' + | 'seeded-dummy' + )[]; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -1209,6 +1254,15 @@ export interface ResumeJobData { value: string | ResumeSkillTagData; }[] | null; + generatorFlags?: ( + | 'resume-asset' + | 'thumbnail' + | 'document' + | 'audio-thumbnail' + | 'video-thumbnail' + | 'document-thumbnail' + | 'seeded-dummy' + )[]; updatedAt: string; createdAt: string; deletedAt?: string | null; @@ -1223,6 +1277,15 @@ export interface ResumeSkillTagData { title: string; slug: string; interval?: number | null; + generatorFlags?: ( + | 'resume-asset' + | 'thumbnail' + | 'document' + | 'audio-thumbnail' + | 'video-thumbnail' + | 'document-thumbnail' + | 'seeded-dummy' + )[]; updatedAt: string; createdAt: string; } @@ -1260,6 +1323,15 @@ export interface ResumeProjectData { relationTo: 'posts'; value: string | BlogPostData; } | null; + generatorFlags?: ( + | 'resume-asset' + | 'thumbnail' + | 'document' + | 'audio-thumbnail' + | 'video-thumbnail' + | 'document-thumbnail' + | 'seeded-dummy' + )[]; updatedAt: string; createdAt: string; deletedAt?: string | null; @@ -1311,6 +1383,15 @@ export interface ResumeSkillData { value: string | ResumeSkillTagData; }[] | null; + generatorFlags?: ( + | 'resume-asset' + | 'thumbnail' + | 'document' + | 'audio-thumbnail' + | 'video-thumbnail' + | 'document-thumbnail' + | 'seeded-dummy' + )[]; updatedAt: string; createdAt: string; deletedAt?: string | null; @@ -1588,6 +1669,7 @@ export interface PostsSelect { title?: T; description?: T; }; + generatorFlags?: T; updatedAt?: T; createdAt?: T; deletedAt?: T; @@ -1611,6 +1693,7 @@ export interface TopicsSelect { title?: T; description?: T; }; + generatorFlags?: T; updatedAt?: T; createdAt?: T; deletedAt?: T; @@ -1638,6 +1721,7 @@ export interface PagesSelect { title?: T; description?: T; }; + generatorFlags?: T; updatedAt?: T; createdAt?: T; deletedAt?: T; @@ -1779,6 +1863,7 @@ export interface ResumeCustomersSelect { title?: T; slug?: T; svg?: T; + generatorFlags?: T; updatedAt?: T; createdAt?: T; } @@ -1800,6 +1885,7 @@ export interface ResumeDocumentsSelect { thumbnails_de?: T; data_en?: T; data_de?: T; + generatorFlags?: T; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -1823,6 +1909,7 @@ export interface ResumeJobsSelect { endDate?: T; interval?: T; skillTags?: T; + generatorFlags?: T; updatedAt?: T; createdAt?: T; deletedAt?: T; @@ -1838,6 +1925,7 @@ export interface ResumeProjectsSelect { description?: T; published?: T; relatedPost?: T; + generatorFlags?: T; updatedAt?: T; createdAt?: T; deletedAt?: T; @@ -1857,6 +1945,7 @@ export interface ResumeSkillsSelect { published?: T; type?: T; skillTags?: T; + generatorFlags?: T; updatedAt?: T; createdAt?: T; deletedAt?: T; @@ -1870,6 +1959,7 @@ export interface ResumeSkillTagsSelect { title?: T; slug?: T; interval?: T; + generatorFlags?: T; updatedAt?: T; createdAt?: T; } From d9ea0d1cce3662e3def97d977d265847a22c8f72 Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Thu, 20 Aug 2026 09:21:44 +0200 Subject: [PATCH 03/13] feat(seed): add seedPages/cleanPages shared logic Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT --- src/lib/seed/pages.test.ts | 174 ++++++++++++++++++++++++ src/lib/seed/pages.ts | 263 +++++++++++++++++++++++++++++++++++++ 2 files changed, 437 insertions(+) create mode 100644 src/lib/seed/pages.test.ts create mode 100644 src/lib/seed/pages.ts diff --git a/src/lib/seed/pages.test.ts b/src/lib/seed/pages.test.ts new file mode 100644 index 0000000..401225d --- /dev/null +++ b/src/lib/seed/pages.test.ts @@ -0,0 +1,174 @@ +import type { Payload } from 'payload' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { cleanPages, seedPages } from './pages' + +const find = vi.fn() +const create = vi.fn() +const deleteFn = vi.fn() + +const makePayload = (): Payload => + ({ + find, + create, + delete: deleteFn, + logger: { + info: vi.fn(), + error: vi.fn(), + }, + }) as unknown as Payload + +beforeEach(() => { + find.mockReset() + create.mockReset() + deleteFn.mockReset() +}) + +describe('seedPages', () => { + it('creates the requested number of pages, each with a tagged hero image', async () => { + find.mockResolvedValue({ + docs: [], + }) + create.mockImplementation(async ({ collection }) => { + if (collection === 'images') { + return { + id: 'image-1', + } + } + return { + id: 'page-1', + } + }) + + const result = await seedPages(makePayload(), 3) + + expect(result.created).toBe(3) + // one image create + one page create per iteration + expect(create).toHaveBeenCalledTimes(6) + }) + + it('skips a slug that already exists instead of creating a duplicate', async () => { + find.mockImplementation(async ({ collection, where }) => { + if (collection === 'pages' && where?.slug?.equals === 'seeded-dummy-page-1') { + return { + docs: [ + { + id: 'existing', + }, + ], + } + } + return { + docs: [], + } + }) + create.mockResolvedValue({ + id: 'created', + }) + + const onProgress = vi.fn() + const result = await seedPages(makePayload(), 1, onProgress) + + expect(result.created).toBe(0) + expect(create).not.toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'pages', + }), + ) + expect(onProgress).toHaveBeenCalledWith( + expect.objectContaining({ + current: 1, + total: 1, + }), + ) + }) + + it('tags every created page and image with the seeded-dummy flag', async () => { + find.mockResolvedValue({ + docs: [], + }) + create.mockImplementation(async ({ collection, data }) => { + expect(data.generatorFlags).toEqual([ + 'seeded-dummy', + ]) + return { + id: collection === 'images' ? 'image-1' : 'page-1', + } + }) + + await seedPages(makePayload(), 1) + + expect(create).toHaveBeenCalledTimes(2) + }) +}) + +describe('cleanPages', () => { + it('deletes every seeded-dummy page and only the seeded-dummy media it referenced', async () => { + find.mockImplementation(async ({ collection }) => { + if (collection === 'pages') { + return { + docs: [ + { + id: 'page-1', + hero: { + media: [ + { + relationTo: 'images', + value: 'image-1', + }, + ], + }, + }, + ], + } + } + return { + docs: [], + } + }) + + const result = await cleanPages(makePayload()) + + expect(result.deletedPages).toBe(1) + expect(result.deletedMedia).toBe(1) + expect(deleteFn).toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'pages', + id: 'page-1', + }), + ) + expect(deleteFn).toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'images', + id: 'image-1', + }), + ) + }) + + it('does not delete media that carries no reference from a seeded page', async () => { + find.mockImplementation(async ({ collection }) => { + if (collection === 'pages') { + return { + docs: [ + { + id: 'page-1', + hero: { + media: [], + }, + }, + ], + } + } + return { + docs: [], + } + }) + + const result = await cleanPages(makePayload()) + + expect(result.deletedPages).toBe(1) + expect(result.deletedMedia).toBe(0) + expect(deleteFn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/lib/seed/pages.ts b/src/lib/seed/pages.ts new file mode 100644 index 0000000..d445fed --- /dev/null +++ b/src/lib/seed/pages.ts @@ -0,0 +1,263 @@ +import type { Payload } from 'payload' + +import { CollectionSlug } from '@/types/collections' +import type { OneColumnContentBlock } from '@/types/payload' + +export type SeedProgress = { + step: string + current: number + total: number +} + +/** Filename/slug prefix for every document this module creates. */ +const SEED_PREFIX = 'seeded-dummy' + +const text = (value: string) => ({ + type: 'text', + version: 1, + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: value, +}) + +const paragraph = (value: string) => ({ + type: 'paragraph', + version: 1, + direction: 'ltr', + format: '', + indent: 0, + textFormat: 0, + textStyle: '', + children: [ + text(value), + ], +}) + +/** + * Minimal single-paragraph Lexical document, cast to whatever rich-text + * field type the caller assigns it to. Mirrors `root()` in + * `scripts/seed-blog.ts`. + */ +const lexicalParagraph = (value: string) => + ({ + root: { + type: 'root', + version: 1, + direction: 'ltr', + format: '', + indent: 0, + children: [ + paragraph(value), + ], + }, + }) as unknown as OneColumnContentBlock['content'] + +/** + * Downloads a placeholder image and creates it in `MediaImages`, tagged + * `seeded-dummy` so `cleanPages` can find and remove it. Reuses + * `scripts/seed-blog.ts`'s `picsum.photos` approach. + */ +const createSeedImage = async (payload: Payload, index: number): Promise => { + const filename = `${SEED_PREFIX}-page-${index}.jpg` + const source = `https://picsum.photos/seed/${SEED_PREFIX}-page-${index}/1600/900` + + const response = await fetch(source) + if (!response.ok) throw new Error(`Failed to download placeholder image: HTTP ${response.status}`) + + const data = Buffer.from(await response.arrayBuffer()) + + const created = await payload.create({ + collection: CollectionSlug.MediaImages, + context: { + skipRevalidate: true, + skipGenerateAlt: true, + }, + data: { + alt: `Placeholder image for seeded page ${index}`, + generatorFlags: [ + 'seeded-dummy', + ], + }, + file: { + name: filename, + data, + mimetype: 'image/jpeg', + size: data.byteLength, + }, + }) + + return String(created.id) +} + +/** + * Creates `count` fixture Pages, each with a downloaded hero image and one + * `OneColumnContentBlock`, all tagged `generatorFlags: ['seeded-dummy']`. + * + * Idempotent by slug: a page whose slug already exists is skipped, not + * duplicated, matching `scripts/seed-blog.ts`'s convention. Re-running with + * a larger `count` than a previous run tops up to that count rather than + * creating `count` new pages every time. + */ +export const seedPages = async ( + payload: Payload, + count: number, + onProgress?: (progress: SeedProgress) => void, +): Promise<{ + created: number +}> => { + let created = 0 + + for (let index = 1; index <= count; index += 1) { + const slug = `${SEED_PREFIX}-page-${index}` + + onProgress?.({ + step: `Checking ${slug}`, + current: index, + total: count, + }) + + const { docs: existing } = await payload.find({ + collection: CollectionSlug.Pages, + where: { + slug: { + equals: slug, + }, + }, + limit: 1, + pagination: false, + }) + + if (existing.length > 0) { + continue + } + + onProgress?.({ + step: `Creating hero image for ${slug}`, + current: index, + total: count, + }) + + const imageId = await createSeedImage(payload, index) + + onProgress?.({ + step: `Creating ${slug}`, + current: index, + total: count, + }) + + await payload.create({ + collection: CollectionSlug.Pages, + context: { + skipRevalidate: true, + }, + data: { + title: `Seeded Dummy Page ${index}`, + protected: false, + slug, + layout: 'default', + generatorFlags: [ + 'seeded-dummy', + ], + hero: { + contentType: 'title', + media: [ + { + relationTo: 'images', + value: imageId, + }, + ], + }, + content: [ + { + blockType: 'OneColumnContentBlock', + content: lexicalParagraph( + `This is placeholder content for seeded dummy page ${index}. It exists only for local testing and can be removed with the Clean action.`, + ), + }, + ], + }, + draft: false, + }) + + created += 1 + } + + return { + created, + } +} + +/** + * Deletes every `Pages` document tagged `seeded-dummy`, then deletes the + * `images`-collection media those pages referenced in their hero. Relies on + * `seedPages`' invariant that a seeded page's hero always points at media + * `seedPages` itself created (and tagged `seeded-dummy`) — a page with no + * hero media referenced has none of its media deleted, matching the count + * of media entries actually present on the seeded pages found. + */ +export const cleanPages = async ( + payload: Payload, + onProgress?: (progress: SeedProgress) => void, +): Promise<{ + deletedPages: number + deletedMedia: number +}> => { + const { docs: pages } = await payload.find({ + collection: CollectionSlug.Pages, + where: { + generatorFlags: { + in: [ + 'seeded-dummy', + ], + }, + }, + limit: 0, + pagination: false, + }) + + const mediaIds = new Set() + for (const page of pages) { + for (const entry of page.hero?.media ?? []) { + if (entry.relationTo === 'images') { + mediaIds.add(typeof entry.value === 'string' ? entry.value : String(entry.value.id)) + } + } + } + + let deletedPages = 0 + for (const [index, page] of pages.entries()) { + onProgress?.({ + step: `Deleting ${page.slug}`, + current: index + 1, + total: pages.length, + }) + + await payload.delete({ + collection: CollectionSlug.Pages, + id: page.id, + context: { + skipRevalidate: true, + }, + }) + deletedPages += 1 + } + + let deletedMedia = 0 + for (const mediaId of mediaIds) { + await payload.delete({ + collection: CollectionSlug.MediaImages, + id: mediaId, + context: { + skipRevalidate: true, + }, + }) + deletedMedia += 1 + } + + return { + deletedPages, + deletedMedia, + } +} From f7db5059dcafaf494f88708f8a87e6a47df03d2e Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Thu, 20 Aug 2026 09:26:56 +0200 Subject: [PATCH 04/13] fix(seed): restore live media-flag re-check in cleanPages Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT --- src/lib/seed/pages.test.ts | 56 ++++++++++++++++++++++++++++++++++++++ src/lib/seed/pages.ts | 37 +++++++++++++++++++++---- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/lib/seed/pages.test.ts b/src/lib/seed/pages.test.ts index 401225d..392b855 100644 --- a/src/lib/seed/pages.test.ts +++ b/src/lib/seed/pages.test.ts @@ -123,6 +123,18 @@ describe('cleanPages', () => { ], } } + if (collection === 'images') { + return { + docs: [ + { + id: 'image-1', + generatorFlags: [ + 'seeded-dummy', + ], + }, + ], + } + } return { docs: [], } @@ -146,6 +158,50 @@ describe('cleanPages', () => { ) }) + it('does not delete media that a seeded page referenced but which no longer carries the seeded-dummy flag', async () => { + find.mockImplementation(async ({ collection }) => { + if (collection === 'pages') { + return { + docs: [ + { + id: 'page-1', + hero: { + media: [ + { + relationTo: 'images', + value: 'image-1', + }, + ], + }, + }, + ], + } + } + if (collection === 'images') { + // Simulates a real, hand-uploaded image the seeded page's hero was + // manually edited to point at: no seeded-dummy flag, so the live + // find (filtered by id AND the flag) returns no match. + return { + docs: [], + } + } + return { + docs: [], + } + }) + + const result = await cleanPages(makePayload()) + + expect(result.deletedPages).toBe(1) + expect(result.deletedMedia).toBe(0) + expect(deleteFn).not.toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'images', + id: 'image-1', + }), + ) + }) + it('does not delete media that carries no reference from a seeded page', async () => { find.mockImplementation(async ({ collection }) => { if (collection === 'pages') { diff --git a/src/lib/seed/pages.ts b/src/lib/seed/pages.ts index d445fed..c099bfd 100644 --- a/src/lib/seed/pages.ts +++ b/src/lib/seed/pages.ts @@ -191,11 +191,12 @@ export const seedPages = async ( /** * Deletes every `Pages` document tagged `seeded-dummy`, then deletes the - * `images`-collection media those pages referenced in their hero. Relies on - * `seedPages`' invariant that a seeded page's hero always points at media - * `seedPages` itself created (and tagged `seeded-dummy`) — a page with no - * hero media referenced has none of its media deleted, matching the count - * of media entries actually present on the seeded pages found. + * `images`-collection media those pages referenced in their hero — but only + * if that media document itself still carries the `seeded-dummy` flag at + * delete time. A seeded page's hero could in principle be manually edited + * in the Payload admin UI to point at a real, hand-uploaded image, so each + * referenced media id is re-verified with a live `find` before deletion; + * media that no longer (or never did) carry the flag is left untouched. */ export const cleanPages = async ( payload: Payload, @@ -246,6 +247,32 @@ export const cleanPages = async ( let deletedMedia = 0 for (const mediaId of mediaIds) { + const { docs: seededMedia } = await payload.find({ + collection: CollectionSlug.MediaImages, + where: { + and: [ + { + id: { + equals: mediaId, + }, + }, + { + generatorFlags: { + in: [ + 'seeded-dummy', + ], + }, + }, + ], + }, + limit: 1, + pagination: false, + }) + + if (seededMedia.length === 0) { + continue + } + await payload.delete({ collection: CollectionSlug.MediaImages, id: mediaId, From 5bf138f88806c6a7629b33078f72a0cad061ad17 Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Thu, 20 Aug 2026 09:31:07 +0200 Subject: [PATCH 05/13] feat(seed): add pnpm seed:pages CLI script Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT --- package.json | 2 ++ scripts/seed-pages.ts | 44 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 scripts/seed-pages.ts diff --git a/package.json b/package.json index d188b2a..06813b8 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "seed": "pnpm run seed:blog", "seed:blog": "pnpm run payload run scripts/seed-blog.ts", "seed:blog:clean": "pnpm run payload run scripts/seed-blog.ts -- --clean", + "seed:pages": "pnpm run payload run scripts/seed-pages.ts", + "seed:pages:clean": "pnpm run payload run scripts/seed-pages.ts -- --clean", "skills:migrate-content-caption": "pnpm run payload run scripts/migrate-skill-content-caption.ts", "start": "pnpm run next start", "start:job-runner": "pnpm run payload jobs:run --cron \"* * * * *\" --all-queues --handle-schedules --limit 10", diff --git a/scripts/seed-pages.ts b/scripts/seed-pages.ts new file mode 100644 index 0000000..7f9a90d --- /dev/null +++ b/scripts/seed-pages.ts @@ -0,0 +1,44 @@ +/** + * Seeds fixture Pages (each with a placeholder hero image) for local + * testing. + * + * Usage: + * pnpm seed:pages # create 10 seed pages + * pnpm seed:pages -- --count 25 # create 25 seed pages + * pnpm seed:pages:clean # remove all seeded pages and their media + * + * Idempotent: existing seeded pages (matched by slug) are skipped, not + * duplicated. See src/lib/seed/pages.ts for the actual create/delete + * logic — this script only parses arguments and reports progress. + */ +import config from '@payload-config' +import { getPayload } from 'payload' + +import { cleanPages, seedPages } from '@/lib/seed/pages' + +const DEFAULT_COUNT = 10 + +const payload = await getPayload({ + config, +}) + +const clean = process.argv.includes('--clean') +const countIndex = process.argv.indexOf('--count') +const count = + countIndex !== -1 && process.argv[countIndex + 1] + ? Number.parseInt(process.argv[countIndex + 1], 10) + : DEFAULT_COUNT + +const onProgress = ({ step, current, total }: { step: string; current: number; total: number }) => { + console.info(`[${current}/${total}] ${step}`) +} + +if (clean) { + const { deletedPages, deletedMedia } = await cleanPages(payload, onProgress) + console.info(`\nRemoved ${deletedPages} pages and ${deletedMedia} media documents.`) +} else { + const { created } = await seedPages(payload, count, onProgress) + console.info(`\nCreated ${created} pages.`) +} + +process.exit(0) From 7841e6e42423fca558c6e4f03b90fbb4a1d659d9 Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Thu, 20 Aug 2026 09:34:08 +0200 Subject: [PATCH 06/13] feat(seed): add authenticated seed-task SSE channel Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT --- app/(frontend)/api/sse/route.ts | 4 ++-- src/lib/sse/channels.test.ts | 33 +++++++++++++++++++++++++++++ src/lib/sse/channels.ts | 37 +++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/app/(frontend)/api/sse/route.ts b/app/(frontend)/api/sse/route.ts index 1bc6879..16c2602 100644 --- a/app/(frontend)/api/sse/route.ts +++ b/app/(frontend)/api/sse/route.ts @@ -2,7 +2,7 @@ import config from '@payload-config' import { getPayload, type PayloadRequest } from 'payload' import { type RedisListener, subscribe } from '@/lib/RedisHandler' -import { isBilingualTranslateChannel, isSseChannel } from '@/lib/sse/channels' +import { isBilingualTranslateChannel, isSeedTaskChannel, isSseChannel } from '@/lib/sse/channels' const HEARTBEAT_INTERVAL_MS = 30_000 @@ -33,7 +33,7 @@ export async function GET(request: Request): Promise { * Unknown channels get the same 400 as a missing one — enumerating which * channels exist is not something an anonymous caller needs to do. */ - if (isBilingualTranslateChannel(channel)) { + if (isBilingualTranslateChannel(channel) || isSeedTaskChannel(channel)) { /** * Unlike the public channels in `SSE_CHANNELS`, a bilingual-translate * job channel carries translated CV content, so it requires a signed-in diff --git a/src/lib/sse/channels.test.ts b/src/lib/sse/channels.test.ts index 5a39cc6..46e3751 100644 --- a/src/lib/sse/channels.test.ts +++ b/src/lib/sse/channels.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it } from 'vitest' import { bilingualTranslateChannel, isBilingualTranslateChannel, + isSeedTaskChannel, isSseChannel, SSE_CHANNELS, + seedTaskChannel, } from './channels' describe('isSseChannel', () => { @@ -75,3 +77,34 @@ describe('isBilingualTranslateChannel', () => { expect(isBilingualTranslateChannel('other-prefix:job-1')).toBe(false) }) }) + +describe('seedTaskChannel', () => { + it('prefixes the job id', () => { + expect(seedTaskChannel('68abc123')).toBe('seed-task:68abc123') + }) +}) + +describe('isSeedTaskChannel', () => { + it('accepts a channel built by seedTaskChannel', () => { + expect(isSeedTaskChannel(seedTaskChannel('68abc123'))).toBe(true) + }) + + it('rejects a missing channel', () => { + expect(isSeedTaskChannel(null)).toBe(false) + expect(isSeedTaskChannel('')).toBe(false) + }) + + it('rejects a channel with no job id', () => { + expect(isSeedTaskChannel('seed-task:')).toBe(false) + }) + + it('rejects a job id containing characters outside the allowed set', () => { + expect(isSeedTaskChannel('seed-task:../etc/passwd')).toBe(false) + expect(isSeedTaskChannel('seed-task:job 1')).toBe(false) + }) + + it('rejects channels outside the seed-task prefix', () => { + expect(isSeedTaskChannel('service-status')).toBe(false) + expect(isSeedTaskChannel('other-prefix:job-1')).toBe(false) + }) +}) diff --git a/src/lib/sse/channels.ts b/src/lib/sse/channels.ts index f5c590e..b771cc7 100644 --- a/src/lib/sse/channels.ts +++ b/src/lib/sse/channels.ts @@ -72,3 +72,40 @@ export type AutoTranslateBilingualFieldProgress = status: 'error' message: string } + +/** + * Per-job progress channel for the seed/clean admin-panel action (see + * `src/jobs-queue/tasks/seedCollection.ts`), one per queued job. + * + * Requires an authenticated session, same as `bilingual-translate:` — + * seed/clean is an admin-only write operation, not something an + * anonymous caller has any reason to watch. + */ +const SEED_TASK_CHANNEL_PREFIX = 'seed-task:' + +export const seedTaskChannel = (jobId: string): string => `${SEED_TASK_CHANNEL_PREFIX}${jobId}` + +export const isSeedTaskChannel = (value: string | null): value is string => + Boolean(value?.startsWith(SEED_TASK_CHANNEL_PREFIX)) && + JOB_ID_PATTERN.test((value as string).slice(SEED_TASK_CHANNEL_PREFIX.length)) + +export type SeedTaskProgress = + | { + status: 'queued' + } + | { + status: 'progress' + step: string + current: number + total: number + } + | { + status: 'success' + created?: number + deletedPages?: number + deletedMedia?: number + } + | { + status: 'error' + message: string + } From cb1c69622d0b2a547e7bbc2d42475ae081ec0fa2 Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Thu, 20 Aug 2026 09:39:15 +0200 Subject: [PATCH 07/13] feat(seed): add SeedCollection jobs-queue task Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT --- src/jobs-queue/tasks/index.ts | 2 + src/jobs-queue/tasks/seedCollection.ts | 88 ++++++++++++++++++++++++++ src/types/jobs-queue.ts | 1 + src/types/payload.ts | 15 +++++ 4 files changed, 106 insertions(+) create mode 100644 src/jobs-queue/tasks/seedCollection.ts diff --git a/src/jobs-queue/tasks/index.ts b/src/jobs-queue/tasks/index.ts index f926a53..1ef3c7a 100644 --- a/src/jobs-queue/tasks/index.ts +++ b/src/jobs-queue/tasks/index.ts @@ -13,6 +13,7 @@ import { generateResumeFile } from './generateResumeFile' import { generateResumeFilename } from './generateResumeFilename' import { generateVideoThumbnails } from './generateVideoThumbnails' import { pingUptimeEndpoint } from './pingUptimeEndpoint' +import { seedCollection } from './seedCollection' // Every task is wrapped in withJobObservability here, at the single point // where tasks are registered — a task added to this array cannot skip Sentry @@ -31,6 +32,7 @@ export const TASKS = [ generateResumeDocumentSlug, createResumeDocument, pingUptimeEndpoint, + seedCollection, ].map(withJobObservability) export const TASKS_SLUGS = TASKS.map(({ slug }) => slug) as TaskSlugValue[] diff --git a/src/jobs-queue/tasks/seedCollection.ts b/src/jobs-queue/tasks/seedCollection.ts new file mode 100644 index 0000000..80cf8d1 --- /dev/null +++ b/src/jobs-queue/tasks/seedCollection.ts @@ -0,0 +1,88 @@ +import type { TaskConfig } from 'payload' + +import { extractErrorMessage } from '@/lib/extractErrorMessage' +import { publish } from '@/lib/RedisHandler' +import { cleanPages, type SeedProgress, seedPages } from '@/lib/seed/pages' +import { seedTaskChannel } from '@/lib/sse/channels' +import { TaskSlug } from '@/types/jobs-queue' + +/** + * Seeds or cleans fixture data for one collection, chosen by + * `input.collection`. One task, mode-parameterized, matching the + * established `AutoTranslateBilingualField` convention (`mode: 'manual' | + * 'auto'`) rather than a task per collection or per direction. + * + * Only used by the admin-panel action (`src/components/AdminPanel/SeedActions`) + * — the CLI scripts call `seedPages`/`cleanPages` directly and never touch + * the jobs queue. + */ +export const seedCollection: TaskConfig = { + slug: TaskSlug.SeedCollection, + label: 'Seed / Clean Collection', + inputSchema: [ + { + name: 'collection', + type: 'text', + required: true, + }, + { + name: 'mode', + type: 'text', + required: true, + }, + { + name: 'count', + type: 'number', + }, + ], + handler: async ({ input, job, req }) => { + 'use server' + + const { payload } = req + + const channel = seedTaskChannel(String(job.id)) + const onProgress = (progress: SeedProgress) => { + void publish(channel, { + status: 'progress', + ...progress, + }) + } + + if (input.collection !== 'pages') { + const message = `Unknown seedable collection: "${input.collection}"` + await publish(channel, { + status: 'error', + message, + }) + throw new Error(message) + } + + try { + if (input.mode === 'seed') { + const result = await seedPages(payload, input.count ?? 1, onProgress) + await publish(channel, { + status: 'success', + ...result, + }) + } else if (input.mode === 'clean') { + const result = await cleanPages(payload, onProgress) + await publish(channel, { + status: 'success', + ...result, + }) + } else { + throw new Error(`Unknown seed mode: "${input.mode}"`) + } + } catch (error) { + await publish(channel, { + status: 'error', + message: extractErrorMessage(error), + }) + throw error + } + + return { + output: {}, + } + }, +} diff --git a/src/types/jobs-queue.ts b/src/types/jobs-queue.ts index 1100890..cff0d0d 100644 --- a/src/types/jobs-queue.ts +++ b/src/types/jobs-queue.ts @@ -11,6 +11,7 @@ export const TaskSlug = { CreateResumeDocument: 'CreateResumeDocument', PingUptimeEndpoint: 'PingUptimeEndpoint', AutoTranslateBilingualField: 'AutoTranslateBilingualField', + SeedCollection: 'SeedCollection', } as const export type TaskSlug = typeof TaskSlug diff --git a/src/types/payload.ts b/src/types/payload.ts index ff825d7..dfbc1c7 100644 --- a/src/types/payload.ts +++ b/src/types/payload.ts @@ -176,6 +176,7 @@ export interface Config { GenerateResumeDocumentSlug: TaskGenerateResumeDocumentSlug; CreateResumeDocument: TaskCreateResumeDocument; PingUptimeEndpoint: TaskPingUptimeEndpoint; + SeedCollection: TaskSeedCollection; createCollectionExport: TaskCreateCollectionExport; createCollectionImport: TaskCreateCollectionImport; schedulePublish: TaskSchedulePublish; @@ -1125,6 +1126,7 @@ export interface PayloadJob { | 'GenerateResumeDocumentSlug' | 'CreateResumeDocument' | 'PingUptimeEndpoint' + | 'SeedCollection' | 'createCollectionExport' | 'createCollectionImport' | 'schedulePublish'; @@ -1176,6 +1178,7 @@ export interface PayloadJob { | 'GenerateResumeDocumentSlug' | 'CreateResumeDocument' | 'PingUptimeEndpoint' + | 'SeedCollection' | 'createCollectionExport' | 'createCollectionImport' | 'schedulePublish' @@ -2860,6 +2863,18 @@ export interface TaskPingUptimeEndpoint { input?: unknown; output?: unknown; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "TaskSeedCollection". + */ +export interface TaskSeedCollection { + input: { + collection: string; + mode: string; + count?: number | null; + }; + output?: unknown; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "TaskCreateCollectionExport". From 1adbdce01f5c21c8316dc87bda65660488398828 Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Thu, 20 Aug 2026 09:42:01 +0200 Subject: [PATCH 08/13] feat(seed): add enqueueSeedCollection server action --- src/lib/actions/enqueueSeedCollection.ts | 57 ++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/lib/actions/enqueueSeedCollection.ts diff --git a/src/lib/actions/enqueueSeedCollection.ts b/src/lib/actions/enqueueSeedCollection.ts new file mode 100644 index 0000000..2e0b4cd --- /dev/null +++ b/src/lib/actions/enqueueSeedCollection.ts @@ -0,0 +1,57 @@ +'use server' + +import { after } from 'next/server' +import config from '@payload-config' +import { getPayload } from 'payload' + +import { extractErrorMessage } from '@/lib/extractErrorMessage' +import { QueueSlug, TaskSlug } from '@/types/jobs-queue' + +type Args = { + collection: string + mode: 'seed' | 'clean' + count?: number +} + +/** + * Queues a `SeedCollection` job and immediately triggers it to run, rather + * than waiting for the next `autoRun` poll on `QueueSlug.Default`. + * + * The run is kicked off via `after()` so it keeps executing once this + * action's response has already gone back to the client with the job id — + * the caller only needs the id to open the `seed-task:` SSE + * subscription and does not wait for seeding/cleaning to finish here. + */ +export const enqueueSeedCollection = async ( + args: Args, +): Promise<{ + jobId: string +}> => { + const payload = await getPayload({ + config, + }) + + const job = await payload.jobs.queue({ + task: TaskSlug.SeedCollection, + queue: QueueSlug.Default, + input: { + collection: args.collection, + mode: args.mode, + count: args.count, + }, + }) + + after(async () => { + try { + await payload.jobs.runByID({ + id: job.id, + }) + } catch (error) { + payload.logger.error(`Failed running job ${job.id}: ${extractErrorMessage(error)}`) + } + }) + + return { + jobId: String(job.id), + } +} From 816671133907932a194512d8b719ee2554fe8306 Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Thu, 20 Aug 2026 09:47:10 +0200 Subject: [PATCH 09/13] feat(seed): add Seed/Clean list-view action for Pages Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT --- app/(payload)/admin/importMap.js | 2 + src/collections/Pages/index.ts | 11 ++ .../AdminPanel/SeedActions/SeedActions.tsx | 160 ++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 src/components/AdminPanel/SeedActions/SeedActions.tsx diff --git a/app/(payload)/admin/importMap.js b/app/(payload)/admin/importMap.js index 9d6655a..fcb6bdb 100644 --- a/app/(payload)/admin/importMap.js +++ b/app/(payload)/admin/importMap.js @@ -35,6 +35,7 @@ import { ExportListMenuItem as ExportListMenuItem_cdf7e044479f899a31f804427d568b import { ImportListMenuItem as ImportListMenuItem_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' import { default as default_739727f92d93200cec032c1e61a6cb15 } from '@/fields/Toggle/components/FieldComponent' import { default as default_22b4891c4f5fa760ee9f626882377967 } from '@/fields/Protected/components/FieldComponent' +import { SeedActions as SeedActions_51b46a0db5b98e49ecd919f9ce43dfec } from '@/components/AdminPanel/SeedActions' import { AltField as AltField_24f51e17ebe211ea3e1b6c287f79c7cc } from '@/collections/MediaImages/components' import { MediaScopeTabs as MediaScopeTabs_4629bc42a060e6d23d7d57d776e5da7b } from '@/components/AdminPanel/MediaScopeTabs' import { default as default_390597f1ad60ab95e5b4fbebc846ccb5 } from '@/fields/SVGUpload/components/FieldComponent' @@ -111,6 +112,7 @@ export const importMap = { "@payloadcms/plugin-import-export/rsc#ImportListMenuItem": ImportListMenuItem_cdf7e044479f899a31f804427d568b36, "@/fields/Toggle/components/FieldComponent#default": default_739727f92d93200cec032c1e61a6cb15, "@/fields/Protected/components/FieldComponent#default": default_22b4891c4f5fa760ee9f626882377967, + "@/components/AdminPanel/SeedActions#SeedActions": SeedActions_51b46a0db5b98e49ecd919f9ce43dfec, "@/collections/MediaImages/components#AltField": AltField_24f51e17ebe211ea3e1b6c287f79c7cc, "@/components/AdminPanel/MediaScopeTabs#MediaScopeTabs": MediaScopeTabs_4629bc42a060e6d23d7d57d776e5da7b, "@/fields/SVGUpload/components/FieldComponent#default": default_390597f1ad60ab95e5b4fbebc846ccb5, diff --git a/src/collections/Pages/index.ts b/src/collections/Pages/index.ts index b590fa5..20ad850 100644 --- a/src/collections/Pages/index.ts +++ b/src/collections/Pages/index.ts @@ -62,6 +62,17 @@ export const Pages: CollectionConfig = { url: ({ data }) => generatePreviewPath(CollectionSlug['Pages'], data.slug), }, preview: (data: Partial) => generatePreviewPath(CollectionSlug['Pages'], data.slug), + components: { + listMenuItems: [ + { + path: '@/components/AdminPanel/SeedActions#SeedActions', + clientProps: { + collectionSlug: CollectionSlug.Pages, + collectionLabel: 'Pages', + }, + }, + ], + }, }, fields: [ /* -------------- Main Content -------------- */ diff --git a/src/components/AdminPanel/SeedActions/SeedActions.tsx b/src/components/AdminPanel/SeedActions/SeedActions.tsx new file mode 100644 index 0000000..6258aaf --- /dev/null +++ b/src/components/AdminPanel/SeedActions/SeedActions.tsx @@ -0,0 +1,160 @@ +'use client' + +import { useCallback, useState } from 'react' +import { useRouter } from 'next/navigation' +import { Button, ConfirmationModal, Modal, toast, useModal } from '@payloadcms/ui' + +import { useServerSentEvents } from '@/components/hooks/use-server-sent-events' +import { enqueueSeedCollection } from '@/lib/actions/enqueueSeedCollection' +import { extractErrorMessage } from '@/lib/extractErrorMessage' +import type { SeedTaskProgress } from '@/lib/sse/channels' +import { seedTaskChannel } from '@/lib/sse/channels' + +type SeedActionsProps = { + collectionSlug: string + collectionLabel: string +} + +const DEFAULT_COUNT = 10 + +export const SeedActions = ({ collectionSlug, collectionLabel }: SeedActionsProps) => { + const router = useRouter() + const { openModal, closeModal } = useModal() + + const [count, setCount] = useState(DEFAULT_COUNT) + const [jobId, setJobId] = useState(null) + + const seedModalSlug = `${collectionSlug}-seed-modal` + const cleanModalSlug = `${collectionSlug}-clean-modal` + + const enqueue = useCallback( + async (mode: 'seed' | 'clean') => { + const title = mode === 'seed' ? `Seeding ${collectionLabel}` : `Cleaning ${collectionLabel}` + + try { + const { jobId: newJobId } = await enqueueSeedCollection({ + collection: collectionSlug, + mode, + count: mode === 'seed' ? count : undefined, + }) + + setJobId(newJobId) + toast.loading(title, { + id: newJobId, + description: 'Queued…', + }) + } catch (error) { + toast.error(extractErrorMessage(error)) + } + }, + [ + collectionLabel, + collectionSlug, + count, + ], + ) + + const handleProgress = useCallback( + (data: SeedTaskProgress) => { + if (!jobId) return + + switch (data.status) { + case 'progress': + toast.loading(`${data.step}`, { + id: jobId, + description: `${data.current}/${data.total}`, + }) + return + case 'success': { + const summary = + data.created !== undefined + ? `Created ${data.created} document(s).` + : `Deleted ${data.deletedPages ?? 0} document(s) and ${data.deletedMedia ?? 0} media file(s).` + toast.success(summary, { + id: jobId, + }) + setJobId(null) + router.refresh() + return + } + case 'error': + toast.error(data.message, { + id: jobId, + }) + setJobId(null) + return + } + }, + [ + jobId, + router, + ], + ) + + useServerSentEvents({ + channel: jobId ? seedTaskChannel(jobId) : '', + enabled: Boolean(jobId), + onMessage: handleProgress, + }) + + return ( + <> + + + + +
+

Seed {collectionLabel}

+ + setCount(Number.parseInt(event.target.value, 10) || 1)} + /> +
+ + +
+
+
+ + enqueue('clean')} + /> + + ) +} + +export default SeedActions From 7f983b854b1555d2231e5a85199d433d4c5fb926 Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Thu, 20 Aug 2026 09:50:46 +0200 Subject: [PATCH 10/13] fix(seed): clamp seed count input to its declared 1-100 range Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT --- src/components/AdminPanel/SeedActions/SeedActions.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/AdminPanel/SeedActions/SeedActions.tsx b/src/components/AdminPanel/SeedActions/SeedActions.tsx index 6258aaf..d6c845b 100644 --- a/src/components/AdminPanel/SeedActions/SeedActions.tsx +++ b/src/components/AdminPanel/SeedActions/SeedActions.tsx @@ -126,7 +126,10 @@ export const SeedActions = ({ collectionSlug, collectionLabel }: SeedActionsProp min={1} max={100} value={count} - onChange={(event) => setCount(Number.parseInt(event.target.value, 10) || 1)} + onChange={(event) => { + const parsed = Number.parseInt(event.target.value, 10) + setCount(Number.isNaN(parsed) ? 1 : Math.min(100, Math.max(1, parsed))) + }} />