diff --git a/.claude/launch.json b/.claude/launch.json
deleted file mode 100644
index 1e75680..0000000
--- a/.claude/launch.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "version": "0.0.1",
- "configurations": [
- {
- "name": "bmp-dev",
- "runtimeExecutable": "npm",
- "runtimeArgs": ["run", "dev", "--", "--port", "8082", "--host"],
- "port": 8082
- }
- ]
-}
diff --git a/.pi/auth.json b/.pi/auth.json
deleted file mode 100644
index 3261ba2..0000000
--- a/.pi/auth.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "openai-codex": {
- "type": "oauth",
- "access": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE5MzQ0ZTY1LWJiYzktNDRkMS1hOWQwLWY5NTdiMDc5YmQwZSIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS92MSJdLCJjbGllbnRfaWQiOiJhcHBfRU1vYW1FRVo3M2YwQ2tYYVhwN2hyYW5uIiwiZXhwIjoxNzgyMTE3MzgxLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiMDJjZDFlOGMtMzViZi00ODkxLTgyYTktOTkyY2RiZTFkNTZlIiwiY2hhdGdwdF9hY2NvdW50X3VzZXJfaWQiOiJ1c2VyLXFqNHVwSVJ1b25sTEdPTUZYRVNUOXNxOF9fMDJjZDFlOGMtMzViZi00ODkxLTgyYTktOTkyY2RiZTFkNTZlIiwiY2hhdGdwdF9jb21wdXRlX3Jlc2lkZW5jeSI6Im5vX2NvbnN0cmFpbnQiLCJjaGF0Z3B0X3BsYW5fdHlwZSI6InByb2xpdGUiLCJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyLXFqNHVwSVJ1b25sTEdPTUZYRVNUOXNxOCIsImxvY2FsaG9zdCI6dHJ1ZSwidXNlcl9pZCI6InVzZXItcWo0dXBJUnVvbmxMR09NRlhFU1Q5c3E4In0sImh0dHBzOi8vYXBpLm9wZW5haS5jb20vcHJvZmlsZSI6eyJlbWFpbCI6Iml2aXNjcmlwdHNAZ21haWwuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWV9LCJpYXQiOjE3ODEyNTMzODAsImlzcyI6Imh0dHBzOi8vYXV0aC5vcGVuYWkuY29tIiwianRpIjoiNmUzNmQ1ZDEtZTVmYi00YWMwLWI2ZjQtYTgyZmU3M2JhNGNmIiwibmJmIjoxNzgxMjUzMzgwLCJwd2RfYXV0aF90aW1lIjoxNzgxMjUzMzc5ODA0LCJzY3AiOlsib3BlbmlkIiwicHJvZmlsZSIsImVtYWlsIiwib2ZmbGluZV9hY2Nlc3MiXSwic2Vzc2lvbl9pZCI6ImF1dGhzZXNzXzdtUkllRWpicHk0OHJDOGJVRVlIM2hqOCIsInNsIjp0cnVlLCJzdWIiOiJnb29nbGUtb2F1dGgyfDExNTU5MjY0ODIxMjU4MzQ5NzgzMCJ9.ZVEDhcFG8jbJdwIMJcNdBwZQtLFXfqFHscHUReBan0VeDyrwdNi5PWPaRX_IinmthoNML5iCVOf9chpIYXh3OTsuI2V3r-5Cb8sMm7I54xm6oDPo_DQ5NEZPFBxRAyGlM0iI2rlb6rrc_Rqhn34UyvLrFSK8xJyQ94GR4P5RmYYGS8kMP0gghY4Eg8nKw_uTJ1c4DSpYOzSYbM3ocdp7J9pKt-oOosLLv4TdLsO6fu0gY8TGnDJ8WkpgmHSTsCYWG32fzRKq3gJzEhXkTQB72nuNYpbjFhWRYxAU4Wc2Lu6cfo-ulqa_CXCV6j_5gd9G-yCfQC2S5McgL6lT4XRE955mL5FPXlmu0A__IKDn1qbd0PqAX5XyO9vP7yp2kUFZO0a4QqEpsfoWLM_TjAf8dgD8_IlClDDRxlSMBl_ex6xPre208ion_WvJXv68tCxQnAQKrdEUoqRkD3gawvXjo7_GpJZqzldJS7Nb_s7ZVJrwfumu7Vg8NHUHfKSOdIJv36jtDhqxcklYEhpsqsIDFkiJttGr2aYmJioztISkc5KaVQBhnnRTmcrH1ui2mB4KHcjldv_u9ArdOX09WWxo2h-uDcyKFXThsGyNS-fT9_acFGlH1iL5gG5EvokxT_v_FeysufrrOy_NGHeGEMhvoJYqpvQtyYIJ7wD_fOwjD3E",
- "refresh": "rt.1.AAA-V8eXajSoeYFAkKuxM-B3FbeINU-bE9c0FFX_V3rl628PMBaM23IGydwbMqKRX6MuO5HeD8AevAyaOevN5e2LRj3hX0DKPjXzy0BMY0CjgY11fBZIewkNH5e11c3c-L9M7-jooIDcHvzOo_AmG1YQa2JpExX5lYRiL230GuAlTFZOg7vyZlyu3jixjdF1ZRslY7sbIOljJitITbXyf-0s0dmElZaHrMK8NKDPGWoTUgo9qYrx1XRNGazpNcOStkoCAsk-M9XZXcskUJGbuAwJFyC04MLLPE6Be80-6FY8pmWYiFCFiR4OM-ctu4AWXDQ",
- "expires": 1782117380801,
- "accountId": "02cd1e8c-35bf-4891-82a9-992cdbe1d56e"
- }
-}
\ No newline at end of file
diff --git a/.pi/settings.json b/.pi/settings.json
deleted file mode 100644
index b2c1bd1..0000000
--- a/.pi/settings.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "lastChangelogVersion": "0.79.1",
- "defaultProvider": "openai-codex",
- "defaultModel": "gpt-5.5",
- "defaultThinkingLevel": "medium"
-}
\ No newline at end of file
diff --git a/.pi/skills/cms-migration/SKILL.md b/.pi/skills/cms-migration/SKILL.md
deleted file mode 100644
index 3b3693f..0000000
--- a/.pi/skills/cms-migration/SKILL.md
+++ /dev/null
@@ -1,149 +0,0 @@
----
-name: cms-migration
-description: Use when user wants to migrate content from another CMS (WordPress, Contentful, Strapi, Sanity, Webflow, etc.) to Payload CMS
----
-
-# CMS Migration to Payload
-
-Interactive workflow to design Payload collections from source CMS data. Config-first approach: establish the data structure through conversation before any data import.
-
-## Workflow
-
-```
-Start
- ↓
-Ask for data sample
- ↓
-Analyze data shape
- ↓
-Propose collection config
- ↓
-User reviews ──────────────┐
- │ │
- ├─ changes needed ───→ Adjust config ──→ (back to User reviews)
- │
- └─ looks good ───→ Config confirmed
- ↓
- More collections? ──────┐
- │ │
- ├─ yes ──→ (back to Ask for data sample)
- │
- └─ no ───→ All collections confirmed
- ↓
- Discuss migration approach
- ↓
- Done
-```
-
-## Phase 1: Data Analysis
-
-When user provides data (JSON, CSV, or describes their schema):
-
-1. **Identify field types** - text, number, date, relationships, media, rich text
-2. **Spot patterns** - IDs, timestamps, nested objects, arrays
-3. **Note relationships** - foreign keys, embedded refs, linked content types
-4. **Flag ambiguities** - fields that could be multiple types, unclear purposes
-
-## Phase 2: Propose Collection Config
-
-Present a Payload collection config based on analysis:
-
-```typescript
-// Example output format
-export const Posts: CollectionConfig = {
- slug: 'posts',
- fields: [
- { name: 'title', type: 'text', required: true },
- { name: 'content', type: 'richText' },
- { name: 'author', type: 'relationship', relationTo: 'users' },
- // ...
- ],
-}
-```
-
-Explain your reasoning for each field choice. When something could go multiple ways (group vs JSON, text vs textarea, select vs relationship), ask rather than assume.
-
-## Phase 3: Iterate with User
-
-Work through uncertainties: required fields, hasMany relationships, rich text vs HTML, custom timestamps vs built-in. Continue until the user confirms the config.
-
-## Phase 4: Additional Collections
-
-After each confirmation, ask:
-
-> "Are there other content types we should create collections for?"
-
-If yes, loop back to Phase 1 with new data sample.
-
-Common related collections to prompt for:
-- Media/uploads
-- Users/authors
-- Categories/tags
-- Settings (global)
-
-## Phase 5: Migration Approach
-
-Only after ALL collections are confirmed, discuss data import:
-
-1. **Order matters** - which collections have no dependencies? Migrate those first
-2. **Relationship mapping** - how to resolve source IDs to Payload IDs
-3. **Media handling** - download/re-upload vs external URLs
-4. **Rich text** - HTML conversion needs or keep raw
-
-Offer to generate a seed script or walk through manual import.
-
-## Things to Clarify
-
-Throughout the process, watch for these:
-
-- **ID references** - are they relationships to other collections?
-- **Image/file URLs** - upload fields or keep as external URLs?
-- **Nested objects** - group, array, or blocks?
-- **Localization** - any fields need per-locale values?
-- **Access control** - who can read/write this collection?
-- **Related content types** - categories, tags, authors that need their own collections?
-
-## Critical: Select vs Relationship
-
-**This is the most common migration mistake.** Data that looks static often needs to be dynamic.
-
-When you see repeated string values (categories, tags, types, statuses):
-
-```json
-{ "category": "Technology" }
-{ "category": "News" }
-{ "category": "Technology" }
-```
-
-**Don't assume it's a select field.** Ask:
-
-> "I see `category` has values like 'Technology', 'News'. Should this be:
-> - A **select field** with fixed options (values won't change)
-> - A **relationship** to a Categories collection (users can add/edit/remove categories later)"
-
-**Default to relationship** for anything that looks like:
-- Categories, tags, topics, labels
-- Authors, assignees, reviewers
-- Statuses beyond simple draft/published
-- Types that might expand over time
-
-**Use select only for:**
-- Truly fixed enums (yes/no, draft/published/archived)
-- Options defined by business logic, not content (payment status, priority levels)
-- Values that would break functionality if changed (role types with code dependencies)
-
-If creating a relationship, remember to add the related collection (Categories, Tags, etc.) to the migration plan.
-
-## Reference Documentation
-
-- **[PAYLOAD-FIELD-REFERENCE.md](reference/PAYLOAD-FIELD-REFERENCE.md)** - Complete Payload field type schemas with examples
-
-## Common Pitfalls
-
-| Issue | How to Handle |
-|-------|---------------|
-| User provides partial data | Ask for more samples, especially edge cases |
-| Unclear relationships | Ask user to describe how content types connect |
-| Rich text ambiguity | Clarify: Lexical editor, Slate, or store raw HTML |
-| Missing media collection | Always confirm upload collection exists before referencing |
-| Overly complex nested data | Consider flattening or using blocks instead of deep groups |
diff --git a/.pi/skills/cms-migration/reference/PAYLOAD-FIELD-REFERENCE.md b/.pi/skills/cms-migration/reference/PAYLOAD-FIELD-REFERENCE.md
deleted file mode 100644
index d80ce6c..0000000
--- a/.pi/skills/cms-migration/reference/PAYLOAD-FIELD-REFERENCE.md
+++ /dev/null
@@ -1,1252 +0,0 @@
-# Payload CMS Field Reference for AI-Assisted Migration
-
-This document helps AI assistants analyze source CMS data and generate appropriate Payload collection configurations. When given sample data from a source CMS, use this reference to determine the correct Payload field types.
-
-## How to Use This Document
-
-1. Analyze the source data structure (JSON, API response, or database schema)
-2. For each field, determine the data type and pattern
-3. Match to the appropriate Payload field type below
-4. Generate a Payload collection config
-
----
-
-## Field Type Schemas
-
-Every field shares these **base properties**:
-
-```typescript
-type BaseField = {
- name: string // Required. Field identifier (camelCase)
- label?: string // Admin UI label. Defaults to name
- required?: boolean // Validation. Default: false
- unique?: boolean // Database unique constraint
- index?: boolean // Database index for faster queries
- localized?: boolean // Enable per-locale values
- hidden?: boolean // Hide from admin UI
- saveToJWT?: boolean // Include in auth JWT
- defaultValue?: unknown // Default when creating new docs
- validate?: Function // Custom validation function
- access?: { // Field-level access control
- create?: Function
- read?: Function
- update?: Function
- }
- hooks?: { // Field lifecycle hooks
- beforeValidate?: Function[]
- beforeChange?: Function[]
- afterChange?: Function[]
- afterRead?: Function[]
- }
- admin?: {
- condition?: Function // Conditionally show/hide field
- description?: string // Help text below field
- position?: 'sidebar' // Move to sidebar in admin
- width?: string // CSS width (e.g., '50%')
- style?: CSSProperties // Inline styles
- className?: string // CSS class
- readOnly?: boolean // Disable editing
- disabled?: boolean // Disable field entirely
- hidden?: boolean // Hide in admin
- components?: { // Custom React components
- Field?: Component
- Cell?: Component
- Filter?: Component
- }
- }
-}
-```
-
----
-
-## Field Types
-
-### text
-
-Single-line text input.
-
-**Full schema:**
-```typescript
-type TextField = BaseField & {
- type: 'text'
- minLength?: number // Minimum character count
- maxLength?: number // Maximum character count
- hasMany?: boolean // Allow multiple values (array of strings)
- minRows?: number // Min items when hasMany: true
- maxRows?: number // Max items when hasMany: true
- admin?: BaseField['admin'] & {
- placeholder?: string // Placeholder text
- autoComplete?: string // HTML autocomplete attribute
- rtl?: boolean // Right-to-left text
- }
-}
-```
-
-**Use when:**
-- Short strings (titles, names, slugs, URLs)
-- Data is typically < 200 characters
-- No line breaks expected
-
-**Source patterns:**
-```json
-{ "title": "Hello World" }
-{ "slug": "hello-world" }
-{ "url": "https://example.com" }
-{ "sku": "PROD-12345" }
-```
-
-**Payload config examples:**
-```typescript
-{ name: 'title', type: 'text', required: true }
-{ name: 'slug', type: 'text', unique: true, index: true }
-{ name: 'tags', type: 'text', hasMany: true, maxRows: 10 }
-{ name: 'sku', type: 'text', minLength: 5, maxLength: 20 }
-```
-
----
-
-### textarea
-
-Multi-line text without formatting.
-
-**Full schema:**
-```typescript
-type TextareaField = BaseField & {
- type: 'textarea'
- minLength?: number // Minimum character count
- maxLength?: number // Maximum character count
- admin?: BaseField['admin'] & {
- placeholder?: string // Placeholder text
- rows?: number // Visible rows (height)
- rtl?: boolean // Right-to-left text
- }
-}
-```
-
-**Use when:**
-- Longer text content without HTML/rich formatting
-- Descriptions, excerpts, plain summaries
-- Data contains line breaks but no markup
-
-**Source patterns:**
-```json
-{ "description": "A longer description\nthat spans multiple lines" }
-{ "excerpt": "Brief summary of the content..." }
-{ "bio": "Author biography text here" }
-```
-
-**Payload config examples:**
-```typescript
-{ name: 'description', type: 'textarea' }
-{ name: 'excerpt', type: 'textarea', maxLength: 500 }
-{ name: 'bio', type: 'textarea', admin: { rows: 6 } }
-```
-
----
-
-### richText
-
-Rich text editor (Lexical by default, or Slate).
-
-**Full schema:**
-```typescript
-type RichTextField = BaseField & {
- type: 'richText'
- editor?: LexicalEditorConfig // Lexical editor configuration
- // Lexical-specific options (via editor config):
- // - features: Enable/disable toolbar features
- // - lexical: Raw Lexical configuration
- admin?: BaseField['admin'] & {
- hideGutter?: boolean // Hide left gutter
- elements?: string[] // Deprecated (Slate). Use editor.features
- leaves?: string[] // Deprecated (Slate). Use editor.features
- }
-}
-```
-
-**Use when:**
-- HTML content from WYSIWYG editors
-- Markdown content (will need conversion)
-- Content with formatting (bold, italic, links, headings)
-- Content blocks from Contentful, Sanity, etc.
-
-**Source patterns:**
-```json
-{ "content": "
Hello world
" }
-{ "body": "# Heading\n\nParagraph with **bold**" }
-{ "content": { "nodeType": "document", "content": [...] } }
-```
-
-**Payload config examples:**
-```typescript
-{ name: 'content', type: 'richText' }
-{ name: 'body', type: 'richText', required: true }
-```
-
-**Migration notes:**
-- WordPress `content.rendered` can be imported as HTML
-- Contentful Rich Text requires conversion to Lexical format
-- Markdown should be converted to HTML first, or use Lexical markdown plugin
-- Data stored as Lexical JSON, not HTML
-
----
-
-### number
-
-Numeric values (integers or decimals).
-
-**Full schema:**
-```typescript
-type NumberField = BaseField & {
- type: 'number'
- min?: number // Minimum value
- max?: number // Maximum value
- hasMany?: boolean // Allow multiple values (array of numbers)
- minRows?: number // Min items when hasMany: true
- maxRows?: number // Max items when hasMany: true
- admin?: BaseField['admin'] & {
- placeholder?: string // Placeholder text
- autoComplete?: string // HTML autocomplete attribute
- step?: number // Increment step (e.g., 0.01 for currency)
- }
-}
-```
-
-**Use when:**
-- Prices, quantities, counts
-- Ratings, scores
-- Any numeric data
-
-**Source patterns:**
-```json
-{ "price": 29.99 }
-{ "quantity": 5 }
-{ "rating": 4.5 }
-{ "views": 1000 }
-```
-
-**Payload config examples:**
-```typescript
-{ name: 'price', type: 'number', min: 0, admin: { step: 0.01 } }
-{ name: 'quantity', type: 'number', min: 0, max: 1000 }
-{ name: 'rating', type: 'number', min: 0, max: 5 }
-{ name: 'scores', type: 'number', hasMany: true }
-```
-
----
-
-### email
-
-Email address field with built-in validation.
-
-**Full schema:**
-```typescript
-type EmailField = BaseField & {
- type: 'email'
- minLength?: number // Minimum character count
- maxLength?: number // Maximum character count
- admin?: BaseField['admin'] & {
- placeholder?: string // Placeholder text
- autoComplete?: string // HTML autocomplete attribute
- }
-}
-```
-
-**Use when:**
-- Field contains email addresses
-- Field name suggests email (email, contactEmail, etc.)
-
-**Source patterns:**
-```json
-{ "email": "user@example.com" }
-{ "contactEmail": "support@company.com" }
-```
-
-**Payload config examples:**
-```typescript
-{ name: 'email', type: 'email', required: true }
-{ name: 'contactEmail', type: 'email', admin: { placeholder: 'you@example.com' } }
-```
-
----
-
-### date
-
-Date/datetime picker.
-
-**Full schema:**
-```typescript
-type DateField = BaseField & {
- type: 'date'
- admin?: BaseField['admin'] & {
- placeholder?: string // Placeholder text
- date?: {
- displayFormat?: string // Display format (e.g., 'MMM d, yyyy')
- pickerAppearance?: 'dayOnly' | 'dayAndTime' | 'monthOnly' | 'timeOnly'
- minDate?: Date // Earliest selectable date
- maxDate?: Date // Latest selectable date
- }
- }
-}
-```
-
-**Use when:**
-- ISO date strings
-- Timestamps
-- Any date/time values
-
-**Source patterns:**
-```json
-{ "publishedAt": "2024-01-15T10:30:00Z" }
-{ "createdAt": "2024-01-15" }
-{ "eventDate": 1705312200000 }
-```
-
-**Payload config examples:**
-```typescript
-{ name: 'publishedAt', type: 'date' }
-{ name: 'eventDate', type: 'date', admin: { date: { pickerAppearance: 'dayAndTime' } } }
-{ name: 'birthDate', type: 'date', admin: { date: { pickerAppearance: 'dayOnly' } } }
-```
-
-**Migration notes:**
-- Payload stores dates as ISO strings
-- Unix timestamps should be converted: `new Date(timestamp).toISOString()`
-
----
-
-### checkbox
-
-Boolean true/false toggle.
-
-**Full schema:**
-```typescript
-type CheckboxField = BaseField & {
- type: 'checkbox'
- defaultValue?: boolean // Default checked state
- admin?: BaseField['admin'] // No additional checkbox-specific admin options
-}
-```
-
-**Use when:**
-- Boolean values
-- Yes/no flags
-- Feature toggles
-
-**Source patterns:**
-```json
-{ "featured": true }
-{ "isPublished": false }
-{ "allowComments": true }
-```
-
-**Payload config examples:**
-```typescript
-{ name: 'featured', type: 'checkbox', defaultValue: false }
-{ name: 'isPublished', type: 'checkbox' }
-{ name: 'allowComments', type: 'checkbox', defaultValue: true }
-```
-
----
-
-### select
-
-Dropdown with predefined options.
-
-**Full schema:**
-```typescript
-type SelectField = BaseField & {
- type: 'select'
- options: Array< // Required. List of options
- | string // Simple: just the value (label = value)
- | { label: string; value: string } // Full: separate label and value
- >
- hasMany?: boolean // Allow multiple selections
- defaultValue?: string | string[] // Default selected value(s)
- admin?: BaseField['admin'] & {
- isClearable?: boolean // Allow clearing selection
- isSortable?: boolean // Allow drag-to-reorder when hasMany
- }
-}
-```
-
-**Use when:**
-- Enum values
-- Status fields
-- Category/type with fixed options
-- Field has limited set of valid values
-
-**Source patterns:**
-```json
-{ "status": "published" }
-{ "priority": "high" }
-{ "type": "article" }
-{ "tags": ["featured", "trending"] }
-```
-
-**Payload config examples:**
-```typescript
-// Simple options (value = label)
-{ name: 'priority', type: 'select', options: ['low', 'medium', 'high'] }
-
-// Full options
-{
- name: 'status',
- type: 'select',
- options: [
- { label: 'Draft', value: 'draft' },
- { label: 'Published', value: 'published' },
- { label: 'Archived', value: 'archived' },
- ],
- defaultValue: 'draft',
-}
-
-// Multiple selection
-{
- name: 'tags',
- type: 'select',
- hasMany: true,
- options: [
- { label: 'Featured', value: 'featured' },
- { label: 'Trending', value: 'trending' },
- { label: 'New', value: 'new' },
- ],
-}
-```
-
-**Detecting options from data:**
-If you see the same field with different values across records, collect unique values to build options:
-```json
-// Record 1: { "status": "draft" }
-// Record 2: { "status": "published" }
-// Record 3: { "status": "published" }
-// options: draft, published
-```
-
----
-
-### radio
-
-Radio button group (single selection, always visible).
-
-**Full schema:**
-```typescript
-type RadioField = BaseField & {
- type: 'radio'
- options: Array< // Required. List of options
- | string // Simple: just the value
- | { label: string; value: string } // Full: separate label and value
- >
- defaultValue?: string // Default selected value
- admin?: BaseField['admin'] & {
- layout?: 'horizontal' | 'vertical' // Button arrangement
- }
-}
-```
-
-**Use when:**
-- Same as select, but fewer options (2-4)
-- User should see all options at once
-
-**Payload config examples:**
-```typescript
-{
- name: 'size',
- type: 'radio',
- options: [
- { label: 'Small', value: 'sm' },
- { label: 'Medium', value: 'md' },
- { label: 'Large', value: 'lg' },
- ],
- defaultValue: 'md',
-}
-
-{
- name: 'alignment',
- type: 'radio',
- options: ['left', 'center', 'right'],
- admin: { layout: 'horizontal' },
-}
-```
-
----
-
-### relationship
-
-Reference to another document.
-
-**Full schema:**
-```typescript
-type RelationshipField = BaseField & {
- type: 'relationship'
- relationTo: string | string[] // Required. Target collection slug(s)
- hasMany?: boolean // Allow multiple selections
- minRows?: number // Min items when hasMany: true
- maxRows?: number // Max items when hasMany: true
- filterOptions?: // Limit selectable documents
- | Where // Static where query
- | ((args: FilterOptionsProps) => Where | boolean) // Dynamic filter
- admin?: BaseField['admin'] & {
- isSortable?: boolean // Allow drag-to-reorder when hasMany
- allowCreate?: boolean // Allow creating new docs from field (default: true)
- allowEdit?: boolean // Allow editing related doc inline
- }
-}
-
-// When relationTo is an array (polymorphic), stored value shape is:
-// { relationTo: 'collectionSlug', value: 'documentId' }
-
-// When relationTo is a string, stored value is just the ID:
-// 'documentId'
-```
-
-**Use when:**
-- Foreign key / ID reference to another collection
-- Nested object that should be a separate document
-- Author, category, tag references
-
-**Source patterns:**
-```json
-// ID reference
-{ "author": 123 }
-{ "authorId": "user_abc123" }
-
-// Object with ID
-{ "author": { "id": 123, "name": "John" } }
-
-// Contentful link
-{ "author": { "sys": { "id": "abc123", "linkType": "Entry" } } }
-
-// Array of references
-{ "categories": [1, 2, 3] }
-{ "tags": [{ "id": 1 }, { "id": 2 }] }
-```
-
-**Payload config examples:**
-```typescript
-// Single relationship
-{ name: 'author', type: 'relationship', relationTo: 'users' }
-
-// Multiple relationships (hasMany)
-{ name: 'categories', type: 'relationship', relationTo: 'categories', hasMany: true }
-
-// Polymorphic (multiple collection types)
-{
- name: 'relatedContent',
- type: 'relationship',
- relationTo: ['posts', 'pages', 'products'],
- hasMany: true,
-}
-
-// With filter (only show published posts)
-{
- name: 'featuredPost',
- type: 'relationship',
- relationTo: 'posts',
- filterOptions: {
- status: { equals: 'published' },
- },
-}
-```
-
----
-
-### upload
-
-File/media upload field. References a document in an upload-enabled collection.
-
-**Full schema:**
-```typescript
-type UploadField = BaseField & {
- type: 'upload'
- relationTo: string // Required. Upload collection slug (e.g., 'media')
- hasMany?: boolean // Allow multiple files
- minRows?: number // Min items when hasMany: true
- maxRows?: number // Max items when hasMany: true
- filterOptions?: // Limit selectable files
- | Where
- | ((args: FilterOptionsProps) => Where | boolean)
- admin?: BaseField['admin'] & {
- isSortable?: boolean // Allow drag-to-reorder when hasMany
- }
-}
-
-// Stored value is the upload document ID (or array of IDs when hasMany)
-```
-
-**Use when:**
-- Image URLs or references
-- File attachments
-- Media library references
-
-**Source patterns:**
-```json
-// URL reference
-{ "featuredImage": "https://example.com/image.jpg" }
-
-// WordPress media ID
-{ "featured_media": 456 }
-
-// Object with URL
-{ "image": { "url": "https://...", "alt": "Description" } }
-
-// Contentful asset
-{ "image": { "sys": { "linkType": "Asset" }, "fields": { "file": { "url": "//images.ctfassets.net/..." } } } }
-
-// Multiple images
-{ "gallery": ["https://...", "https://..."] }
-```
-
-**Payload config examples:**
-```typescript
-{ name: 'featuredImage', type: 'upload', relationTo: 'media' }
-{ name: 'gallery', type: 'upload', relationTo: 'media', hasMany: true, maxRows: 10 }
-{ name: 'document', type: 'upload', relationTo: 'documents' }
-```
-
-**Migration notes:**
-- Download remote images and upload to Payload
-- Store the new Payload media ID in the field
-- Preserve alt text as a separate field on the media collection or via a group
-
----
-
-### array
-
-Repeatable group of fields.
-
-**Full schema:**
-```typescript
-type ArrayField = BaseField & {
- type: 'array'
- fields: Field[] // Required. Sub-fields for each row
- minRows?: number // Minimum number of rows
- maxRows?: number // Maximum number of rows
- labels?: { // Custom row labels
- singular?: string
- plural?: string
- }
- admin?: BaseField['admin'] & {
- initCollapsed?: boolean // Start rows collapsed
- isSortable?: boolean // Allow drag-to-reorder (default: true)
- components?: {
- RowLabel?: Component // Custom row label component
- }
- }
- // Each row automatically gets an 'id' field
-}
-
-// Stored as array of objects:
-// [{ id: 'abc', field1: 'value', field2: 'value' }, ...]
-```
-
-**Use when:**
-- Array of objects with consistent structure
-- Repeater fields (ACF, etc.)
-- List of items with multiple properties each
-
-**Source patterns:**
-```json
-{
- "socialLinks": [
- { "platform": "twitter", "url": "https://twitter.com/..." },
- { "platform": "github", "url": "https://github.com/..." }
- ]
-}
-
-{
- "features": [
- { "title": "Feature 1", "description": "..." },
- { "title": "Feature 2", "description": "..." }
- ]
-}
-```
-
-**Payload config examples:**
-```typescript
-{
- name: 'socialLinks',
- type: 'array',
- labels: { singular: 'Link', plural: 'Links' },
- minRows: 1,
- maxRows: 5,
- fields: [
- {
- name: 'platform',
- type: 'select',
- options: ['twitter', 'github', 'linkedin'],
- required: true,
- },
- { name: 'url', type: 'text', required: true },
- ],
-}
-
-{
- name: 'features',
- type: 'array',
- fields: [
- { name: 'title', type: 'text', required: true },
- { name: 'description', type: 'textarea' },
- { name: 'icon', type: 'upload', relationTo: 'media' },
- ],
-}
-```
-
----
-
-### group
-
-Nested object (non-repeating).
-
-**Full schema:**
-```typescript
-type GroupField = BaseField & {
- type: 'group'
- fields: Field[] // Required. Sub-fields
- admin?: BaseField['admin'] & {
- hideGutter?: boolean // Remove left border/gutter
- }
-}
-
-// Stored as nested object:
-// { field1: 'value', field2: 'value' }
-```
-
-**Use when:**
-- Nested object that's always singular
-- Organizational grouping of related fields
-- SEO metadata, address blocks, etc.
-
-**Source patterns:**
-```json
-{
- "seo": {
- "title": "Page Title",
- "description": "Meta description",
- "keywords": ["a", "b"]
- }
-}
-
-{
- "address": {
- "street": "123 Main St",
- "city": "Springfield",
- "zip": "12345"
- }
-}
-```
-
-**Payload config examples:**
-```typescript
-{
- name: 'seo',
- type: 'group',
- label: 'SEO Settings',
- fields: [
- { name: 'title', type: 'text', maxLength: 60 },
- { name: 'description', type: 'textarea', maxLength: 160 },
- { name: 'keywords', type: 'text', hasMany: true },
- ],
-}
-
-{
- name: 'address',
- type: 'group',
- fields: [
- { name: 'street', type: 'text' },
- { name: 'city', type: 'text' },
- { name: 'state', type: 'text' },
- { name: 'zip', type: 'text' },
- { name: 'country', type: 'select', options: ['US', 'CA', 'UK'] },
- ],
-}
-```
-
----
-
-### blocks
-
-Flexible content / page builder blocks.
-
-**Full schema:**
-```typescript
-type BlocksField = BaseField & {
- type: 'blocks'
- blocks: Block[] // Required. Available block types
- minRows?: number // Minimum number of blocks
- maxRows?: number // Maximum number of blocks
- admin?: BaseField['admin'] & {
- initCollapsed?: boolean // Start blocks collapsed
- isSortable?: boolean // Allow drag-to-reorder (default: true)
- }
-}
-
-type Block = {
- slug: string // Required. Unique block identifier
- labels?: { // Custom labels
- singular?: string
- plural?: string
- }
- fields: Field[] // Required. Fields in this block
- imageURL?: string // Preview image URL
- imageAltText?: string // Alt text for preview
- admin?: {
- components?: {
- Label?: Component // Custom block label
- }
- }
-}
-
-// Stored as array with blockType identifier:
-// [
-// { id: 'abc', blockType: 'hero', title: 'Welcome' },
-// { id: 'def', blockType: 'textBlock', content: {...} }
-// ]
-```
-
-**Use when:**
-- Dynamic content zones
-- Page builder layouts
-- ACF Flexible Content
-- Contentful/Sanity block content
-
-**Source patterns:**
-```json
-{
- "layout": [
- { "type": "hero", "title": "Welcome", "image": "..." },
- { "type": "textBlock", "content": "...
" },
- { "type": "gallery", "images": [...] }
- ]
-}
-```
-
-**Payload config examples:**
-```typescript
-{
- name: 'layout',
- type: 'blocks',
- minRows: 1,
- blocks: [
- {
- slug: 'hero',
- labels: { singular: 'Hero Section', plural: 'Hero Sections' },
- fields: [
- { name: 'title', type: 'text', required: true },
- { name: 'subtitle', type: 'text' },
- { name: 'image', type: 'upload', relationTo: 'media' },
- {
- name: 'cta',
- type: 'group',
- fields: [
- { name: 'label', type: 'text' },
- { name: 'url', type: 'text' },
- ],
- },
- ],
- },
- {
- slug: 'textBlock',
- labels: { singular: 'Text Block', plural: 'Text Blocks' },
- fields: [
- { name: 'content', type: 'richText', required: true },
- ],
- },
- {
- slug: 'gallery',
- fields: [
- { name: 'images', type: 'upload', relationTo: 'media', hasMany: true },
- { name: 'columns', type: 'select', options: ['2', '3', '4'] },
- ],
- },
- ],
-}
-```
-
-**Migration notes:**
-- Map source block `type` field to Payload `blockType`
-- Each block type needs its own field definitions
-
----
-
-### json
-
-Arbitrary JSON data.
-
-**Full schema:**
-```typescript
-type JSONField = BaseField & {
- type: 'json'
- jsonSchema?: JSONSchema // Optional JSON Schema for validation
- admin?: BaseField['admin'] & {
- editorOptions?: object // Monaco editor options
- }
-}
-
-// Stored as-is (any valid JSON)
-```
-
-**Use when:**
-- Unstructured or highly variable data
-- Third-party API responses to store
-- Data that doesn't fit other types
-- Temporary/flexible storage during migration
-
-**Source patterns:**
-```json
-{ "metadata": { "arbitrary": "data", "nested": { "values": true } } }
-{ "apiResponse": { ... } }
-{ "config": { "settings": [...] } }
-```
-
-**Payload config examples:**
-```typescript
-{ name: 'metadata', type: 'json' }
-
-// With JSON Schema validation
-{
- name: 'settings',
- type: 'json',
- jsonSchema: {
- type: 'object',
- properties: {
- theme: { type: 'string' },
- notifications: { type: 'boolean' },
- },
- },
-}
-```
-
-**Migration notes:**
-- Use as fallback when data structure is unknown or highly variable
-- Consider converting to proper fields later for better querying
-
----
-
-### point
-
-Geographic coordinates (longitude, latitude).
-
-**Full schema:**
-```typescript
-type PointField = BaseField & {
- type: 'point'
- admin?: BaseField['admin'] // No additional point-specific admin options
-}
-
-// Stored as GeoJSON Point:
-// [longitude, latitude] // Note: longitude first!
-// e.g., [-74.0060, 40.7128] for New York City
-```
-
-**Use when:**
-- Latitude/longitude pairs
-- Map locations
-- Geolocation data
-
-**Source patterns:**
-```json
-{ "location": { "lat": 40.7128, "lng": -74.0060 } }
-{ "coordinates": [40.7128, -74.0060] }
-{ "geo": { "latitude": 40.7128, "longitude": -74.0060 } }
-```
-
-**Payload config examples:**
-```typescript
-{ name: 'location', type: 'point' }
-{ name: 'coordinates', type: 'point', required: true }
-```
-
-**Migration notes:**
-- Payload uses GeoJSON format: `[longitude, latitude]`
-- Many sources use `[latitude, longitude]` - swap if needed!
-- Convert from `{ lat, lng }` objects to `[lng, lat]` array
-
----
-
-### row (Layout)
-
-Horizontal layout for placing fields side-by-side.
-
-**Full schema:**
-```typescript
-type RowField = {
- type: 'row'
- fields: Field[] // Required. Fields to display in row
- admin?: {
- condition?: Function // Conditionally show/hide
- }
-}
-// No name required - purely layout
-```
-
-**Payload config example:**
-```typescript
-{
- type: 'row',
- fields: [
- { name: 'firstName', type: 'text', admin: { width: '50%' } },
- { name: 'lastName', type: 'text', admin: { width: '50%' } },
- ],
-}
-```
-
----
-
-### collapsible (Layout)
-
-Collapsible section for grouping fields.
-
-**Full schema:**
-```typescript
-type CollapsibleField = {
- type: 'collapsible'
- label: string | Function // Required. Section header
- fields: Field[] // Required. Fields inside
- admin?: {
- initCollapsed?: boolean // Start collapsed (default: false)
- condition?: Function
- }
-}
-// No name required - purely layout
-```
-
-**Payload config example:**
-```typescript
-{
- type: 'collapsible',
- label: 'Advanced Settings',
- admin: { initCollapsed: true },
- fields: [
- { name: 'customCSS', type: 'textarea' },
- { name: 'customJS', type: 'textarea' },
- ],
-}
-```
-
----
-
-### tabs (Layout)
-
-Tabbed interface for organizing fields.
-
-**Full schema:**
-```typescript
-type TabsField = {
- type: 'tabs'
- tabs: Tab[] // Required. Array of tabs
- admin?: {
- condition?: Function
- }
-}
-
-type Tab = {
- label: string // Required. Tab label
- name?: string // If set, fields are nested under this key
- fields: Field[] // Required. Fields in this tab
- description?: string // Help text for tab
-}
-// No name on parent - tabs are layout only (unless tab has name)
-```
-
-**Payload config example:**
-```typescript
-{
- type: 'tabs',
- tabs: [
- {
- label: 'Content',
- fields: [
- { name: 'title', type: 'text' },
- { name: 'body', type: 'richText' },
- ],
- },
- {
- label: 'SEO',
- name: 'seo', // Fields nested under 'seo' key
- fields: [
- { name: 'title', type: 'text' },
- { name: 'description', type: 'textarea' },
- ],
- },
- ],
-}
-```
-
----
-
-### ui (Custom Component)
-
-Render custom React component without storing data.
-
-**Full schema:**
-```typescript
-type UIField = {
- type: 'ui'
- name: string // Required (for key, not storage)
- admin: {
- components: {
- Field: Component // Required. React component to render
- Cell?: Component // List view component
- }
- condition?: Function
- }
-}
-// Does NOT store data - purely visual
-```
-
----
-
-## Collection-Level Configuration
-
-### Basic Collection
-
-```typescript
-const posts: CollectionConfig = {
- slug: 'posts',
- labels: {
- singular: 'Post',
- plural: 'Posts',
- },
- fields: [
- { name: 'title', type: 'text', required: true },
- { name: 'slug', type: 'text', unique: true },
- { name: 'content', type: 'richText' },
- { name: 'author', type: 'relationship', relationTo: 'users' },
- { name: 'publishedAt', type: 'date' },
- ],
-}
-```
-
-### Upload Collection
-
-```typescript
-const media: CollectionConfig = {
- slug: 'media',
- labels: {
- singular: 'Media',
- plural: 'Media',
- },
- upload: {
- staticDir: 'media', // Directory for files (relative to project)
- staticURL: '/media', // URL path prefix
- mimeTypes: ['image/*', 'application/pdf'], // Allowed types
- filesRequiredOnCreate: true, // Require file on create (default: true)
-
- // Image-specific options:
- imageSizes: [ // Auto-generate resized versions
- { name: 'thumbnail', width: 300, height: 300, position: 'centre' },
- { name: 'card', width: 768, height: 1024, position: 'centre' },
- { name: 'tablet', width: 1024 }, // Height auto
- ],
- adminThumbnail: 'thumbnail', // Size to show in admin
- focalPoint: true, // Enable focal point selection
- crop: true, // Enable cropping
-
- // Storage adapter (optional - defaults to local):
- // adapter: s3Adapter({ ... })
- },
- fields: [
- { name: 'alt', type: 'text', required: true },
- { name: 'caption', type: 'textarea' },
- ],
-}
-```
-
-**Upload document auto-fields:**
-When you create an upload collection, Payload automatically adds these fields:
-- `filename` - Original filename
-- `mimeType` - File MIME type
-- `filesize` - Size in bytes
-- `width` - Image width (images only)
-- `height` - Image height (images only)
-- `url` - Public URL to file
-- `thumbnailURL` - URL to thumbnail (if imageSizes configured)
-- `sizes` - Object with all generated size URLs
-
-### Auth Collection
-
-```typescript
-const users: CollectionConfig = {
- slug: 'users',
- auth: true,
- fields: [
- { name: 'name', type: 'text' },
- { name: 'role', type: 'select', options: ['admin', 'editor', 'user'] },
- ],
-}
-```
-
----
-
-## Common Migration Patterns
-
-### WordPress to Payload
-
-| WordPress | Payload |
-|-----------|---------|
-| `post_title` | `text` (title) |
-| `post_content` | `richText` (HTML) |
-| `post_excerpt` | `textarea` |
-| `post_status` | `select` (draft/published) |
-| `post_author` | `relationship` to users |
-| `featured_media` | `upload` to media |
-| `post_date` | `date` |
-| ACF Repeater | `array` |
-| ACF Group | `group` |
-| ACF Flexible Content | `blocks` |
-
-### Contentful to Payload
-
-| Contentful | Payload |
-|------------|---------|
-| Short Text | `text` |
-| Long Text | `textarea` |
-| Rich Text | `richText` (needs conversion) |
-| Number | `number` |
-| Date | `date` |
-| Boolean | `checkbox` |
-| Media | `upload` |
-| Reference | `relationship` |
-| Array of References | `relationship` (hasMany) |
-
-### Strapi to Payload
-
-| Strapi | Payload |
-|--------|---------|
-| string | `text` |
-| text | `textarea` |
-| richtext/blocks | `richText` |
-| integer/float/decimal | `number` |
-| boolean | `checkbox` |
-| date/datetime | `date` |
-| enumeration | `select` |
-| media | `upload` |
-| relation | `relationship` |
-| component | `group` or `array` |
-| dynamiczone | `blocks` |
-
----
-
-## AI Instructions
-
-When analyzing source data to generate Payload config:
-
-1. **Identify collections** - Each distinct content type becomes a collection
-2. **Detect relationships** - ID references between types become `relationship` fields
-3. **Infer field types** - Use the patterns above to match data to Payload types
-4. **Preserve structure** - Nested objects become `group`, arrays of objects become `array`
-5. **Flag unknowns** - If data doesn't match patterns, suggest `json` as fallback and add a warning
-6. **Generate valid TypeScript** - Output should be copy-paste ready
-
-**Output format:**
-```typescript
-import type { CollectionConfig } from 'payload'
-
-export const collectionName: CollectionConfig = {
- slug: 'collection-name',
- fields: [
- // fields here
- ],
-}
-```
diff --git a/.pi/skills/payload/README.md b/.pi/skills/payload/README.md
deleted file mode 100644
index d6d9510..0000000
--- a/.pi/skills/payload/README.md
+++ /dev/null
@@ -1,57 +0,0 @@
-# Payload Skill for AI Coding Agents
-
-Agent skill providing comprehensive guidance for Payload development with TypeScript patterns, field configurations, hooks, access control, and API examples.
-
-## What's Included
-
-The `payload` skill provides expert guidance on:
-
-- **Collections**: Auth, uploads, drafts, live preview configurations
-- **Fields**: All field types including relationships, arrays, blocks, joins, virtual fields
-- **Hooks**: beforeChange, afterChange, beforeValidate, field hooks
-- **Access Control**: Collection, field, and global access patterns including RBAC and multi-tenant
-- **Queries**: Local API, REST, and GraphQL with complex operators
-- **Database Adapters**: MongoDB, Postgres, SQLite configurations and transactions
-- **Advanced Features**: Jobs queue, custom endpoints, localization, plugins
-
-## Usage
-
-Once installed, the Agent will automatically invoke the skill when you're working on Payload CMS projects. The skill activates when you:
-
-- Edit `payload.config.ts` files
-- Work with collection or global configurations
-- Ask about Payload-specific patterns
-- Need guidance on fields, hooks, or access control
-
-You can also explicitly invoke it:
-
-```
-@payload how do I implement row-level access control?
-```
-
-## Documentation Structure
-
-```
-skills/payload/
-├── SKILL.md # Main skill file with quick reference
-└── reference/
- ├── FIELDS.md # All field types and configurations
- ├── COLLECTIONS.md # Collection patterns
- ├── HOOKS.md # Hook patterns and examples
- ├── ACCESS-CONTROL.md # Basic access control
- ├── ACCESS-CONTROL-ADVANCED.md # Advanced access patterns
- ├── QUERIES.md # Query patterns and APIs
- ├── ADAPTERS.md # Database and storage adapters
- └── ADVANCED.md # Jobs, endpoints, localization
-```
-
-## Resources
-
-- [Payload Documentation](https://payloadcms.com/docs)
-- [GitHub Repository](https://github.com/payloadcms/payload)
-- [Examples](https://github.com/payloadcms/payload/tree/main/examples)
-- [Templates](https://github.com/payloadcms/payload/tree/main/templates)
-
-## License
-
-MIT
diff --git a/.pi/skills/payload/SKILL.md b/.pi/skills/payload/SKILL.md
deleted file mode 100644
index 5abccb4..0000000
--- a/.pi/skills/payload/SKILL.md
+++ /dev/null
@@ -1,409 +0,0 @@
----
-name: payload
-description: Use when working with Payload projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior.
----
-
-# Payload Application Development
-
-Payload is a Next.js native CMS with TypeScript-first architecture, providing admin panel, database management, REST/GraphQL APIs, authentication, and file storage.
-
-## Quick Reference
-
-| Task | Solution | Details |
-| ------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
-| Auto-generate slugs | `slugField()` | [FIELDS.md#slug-field-helper](reference/FIELDS.md#slug-field-helper) |
-| Restrict content by user | Access control with query | [ACCESS-CONTROL.md#row-level-security-with-complex-queries](reference/ACCESS-CONTROL.md#row-level-security-with-complex-queries) |
-| Local API user ops | `user` + `overrideAccess: false` | [QUERIES.md#access-control-in-local-api](reference/QUERIES.md#access-control-in-local-api) |
-| Draft/publish workflow | `versions: { drafts: true }` | [COLLECTIONS.md#versioning--drafts](reference/COLLECTIONS.md#versioning--drafts) |
-| Computed fields | `virtual: true` with afterRead | [FIELDS.md#virtual-fields](reference/FIELDS.md#virtual-fields) |
-| Conditional fields | `admin.condition` | [FIELDS.md#conditional-fields](reference/FIELDS.md#conditional-fields) |
-| Custom field validation | `validate` function | [FIELDS.md#text-field](reference/FIELDS.md#text-field) |
-| Filter relationship list | `filterOptions` on field | [FIELDS.md#relationship](reference/FIELDS.md#relationship) |
-| Select specific fields | `select` parameter | [QUERIES.md#local-api](reference/QUERIES.md#local-api) |
-| Auto-set author/dates | beforeChange hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) |
-| Prevent hook loops | `req.context` check | [HOOKS.md#hook-context](reference/HOOKS.md#hook-context) |
-| Cascading deletes | beforeDelete hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) |
-| Geospatial queries | `point` field with `near`/`within` | [FIELDS.md#point-geolocation](reference/FIELDS.md#point-geolocation) |
-| Reverse relationships | `join` field type | [FIELDS.md#join-fields](reference/FIELDS.md#join-fields) |
-| Next.js revalidation | Context control in afterChange | [HOOKS.md#nextjs-revalidation-with-context-control](reference/HOOKS.md#nextjs-revalidation-with-context-control) |
-| Query by relationship | Nested property syntax | [QUERIES.md#nested-properties](reference/QUERIES.md#nested-properties) |
-| Complex queries | AND/OR logic | [QUERIES.md#andor-logic](reference/QUERIES.md#andor-logic) |
-| Transactions | Pass `req` to operations | [ADAPTERS.md#threading-req-through-operations](reference/ADAPTERS.md#threading-req-through-operations) |
-| Background jobs | Jobs queue with tasks | [ADVANCED.md#jobs-queue](reference/ADVANCED.md#jobs-queue) |
-| Custom API routes | Collection custom endpoints | [ADVANCED.md#custom-endpoints](reference/ADVANCED.md#custom-endpoints) |
-| Cloud storage | Storage adapter plugins | [ADAPTERS.md#storage-adapters](reference/ADAPTERS.md#storage-adapters) |
-| Multi-language | `localization` config + `localized: true` | [ADVANCED.md#localization](reference/ADVANCED.md#localization) |
-| Create plugin | `(options) => (config) => Config` | [PLUGIN-DEVELOPMENT.md#plugin-architecture](reference/PLUGIN-DEVELOPMENT.md#plugin-architecture) |
-| Plugin package setup | Package structure with SWC | [PLUGIN-DEVELOPMENT.md#plugin-package-structure](reference/PLUGIN-DEVELOPMENT.md#plugin-package-structure) |
-| Add fields to collection | Map collections, spread fields | [PLUGIN-DEVELOPMENT.md#adding-fields-to-collections](reference/PLUGIN-DEVELOPMENT.md#adding-fields-to-collections) |
-| Plugin hooks | Preserve existing hooks in array | [PLUGIN-DEVELOPMENT.md#adding-hooks](reference/PLUGIN-DEVELOPMENT.md#adding-hooks) |
-| Check field type | Type guard functions | [FIELD-TYPE-GUARDS.md](reference/FIELD-TYPE-GUARDS.md) |
-
-## Quick Start
-
-```bash
-npx create-payload-app@latest my-app
-cd my-app
-pnpm dev
-```
-
-### Minimal Config
-
-```ts
-import { buildConfig } from 'payload'
-import { mongooseAdapter } from '@payloadcms/db-mongodb'
-import { lexicalEditor } from '@payloadcms/richtext-lexical'
-import path from 'path'
-import { fileURLToPath } from 'url'
-
-const filename = fileURLToPath(import.meta.url)
-const dirname = path.dirname(filename)
-
-export default buildConfig({
- admin: {
- user: 'users',
- importMap: {
- baseDir: path.resolve(dirname),
- },
- },
- collections: [Users, Media],
- editor: lexicalEditor(),
- secret: process.env.PAYLOAD_SECRET,
- typescript: {
- outputFile: path.resolve(dirname, 'payload-types.ts'),
- },
- db: mongooseAdapter({
- url: process.env.DATABASE_URL,
- }),
-})
-```
-
-## Essential Patterns
-
-### Basic Collection
-
-```ts
-import type { CollectionConfig } from 'payload'
-
-export const Posts: CollectionConfig = {
- slug: 'posts',
- admin: {
- useAsTitle: 'title',
- defaultColumns: ['title', 'author', 'status', 'createdAt'],
- },
- fields: [
- { name: 'title', type: 'text', required: true },
- { name: 'slug', type: 'text', unique: true, index: true },
- { name: 'content', type: 'richText' },
- { name: 'author', type: 'relationship', relationTo: 'users' },
- ],
- timestamps: true,
-}
-```
-
-For more collection patterns (auth, upload, drafts, live preview), see [COLLECTIONS.md](reference/COLLECTIONS.md).
-
-### Common Fields
-
-```ts
-// Text field
-{ name: 'title', type: 'text', required: true }
-
-// Relationship
-{ name: 'author', type: 'relationship', relationTo: 'users', required: true }
-
-// Rich text
-{ name: 'content', type: 'richText', required: true }
-
-// Select
-{ name: 'status', type: 'select', options: ['draft', 'published'], defaultValue: 'draft' }
-
-// Upload
-{ name: 'image', type: 'upload', relationTo: 'media' }
-```
-
-For all field types (array, blocks, point, join, virtual, conditional, etc.), see [FIELDS.md](reference/FIELDS.md).
-
-### Hook Example
-
-```ts
-export const Posts: CollectionConfig = {
- slug: 'posts',
- hooks: {
- beforeChange: [
- async ({ data, operation }) => {
- if (operation === 'create') {
- data.slug = slugify(data.title)
- }
- return data
- },
- ],
- },
- fields: [{ name: 'title', type: 'text' }],
-}
-```
-
-For all hook patterns, see [HOOKS.md](reference/HOOKS.md). For access control, see [ACCESS-CONTROL.md](reference/ACCESS-CONTROL.md).
-
-### Access Control with Type Safety
-
-```ts
-import type { Access } from 'payload'
-import type { User } from '@/payload-types'
-
-// Type-safe access control
-export const adminOnly: Access = ({ req }) => {
- const user = req.user as User
- return user?.roles?.includes('admin') || false
-}
-
-// Row-level access control
-export const ownPostsOnly: Access = ({ req }) => {
- const user = req.user as User
- if (!user) return false
- if (user.roles?.includes('admin')) return true
-
- return {
- author: { equals: user.id },
- }
-}
-```
-
-### Query Example
-
-```ts
-// Local API
-const posts = await payload.find({
- collection: 'posts',
- where: {
- status: { equals: 'published' },
- 'author.name': { contains: 'john' },
- },
- depth: 2,
- limit: 10,
- sort: '-createdAt',
-})
-
-// Query with populated relationships
-const post = await payload.findByID({
- collection: 'posts',
- id: '123',
- depth: 2, // Populates relationships (default is 2)
-})
-// Returns: { author: { id: "user123", name: "John" } }
-
-// Without depth, relationships return IDs only
-const post = await payload.findByID({
- collection: 'posts',
- id: '123',
- depth: 0,
-})
-// Returns: { author: "user123" }
-```
-
-For all query operators and REST/GraphQL examples, see [QUERIES.md](reference/QUERIES.md).
-
-### Getting Payload Instance
-
-```ts
-// In API routes (Next.js)
-import { getPayload } from 'payload'
-import config from '@payload-config'
-
-export async function GET() {
- const payload = await getPayload({ config })
-
- const posts = await payload.find({
- collection: 'posts',
- })
-
- return Response.json(posts)
-}
-
-// In Server Components
-import { getPayload } from 'payload'
-import config from '@payload-config'
-
-export default async function Page() {
- const payload = await getPayload({ config })
- const { docs } = await payload.find({ collection: 'posts' })
-
- return {docs.map(post =>
{post.title}
)}
-}
-```
-
-### Logger Usage
-
-```ts
-// ✅ Valid: single string
-payload.logger.error('Something went wrong')
-
-// ✅ Valid: object with msg and err
-payload.logger.error({ msg: 'Failed to process', err: error })
-
-// ❌ Invalid: don't pass error as second argument
-payload.logger.error('Failed to process', error)
-
-// ❌ Invalid: use `err` not `error`, use `msg` not `message`
-payload.logger.error({ message: 'Failed', error: error })
-```
-
-## Security Pitfalls
-
-### 1. Local API Access Control (CRITICAL)
-
-**By default, Local API operations bypass ALL access control**, even when passing a user.
-
-```ts
-// ❌ SECURITY BUG: Passes user but ignores their permissions
-await payload.find({
- collection: 'posts',
- user: someUser, // Access control is BYPASSED!
-})
-
-// ✅ SECURE: Actually enforces the user's permissions
-await payload.find({
- collection: 'posts',
- user: someUser,
- overrideAccess: false, // REQUIRED for access control
-})
-```
-
-**When to use each:**
-
-- `overrideAccess: true` (default) - Server-side operations you trust (cron jobs, system tasks)
-- `overrideAccess: false` - When operating on behalf of a user (API routes, webhooks)
-
-See [QUERIES.md#access-control-in-local-api](reference/QUERIES.md#access-control-in-local-api).
-
-### 2. Transaction Failures in Hooks
-
-**Nested operations in hooks without `req` break transaction atomicity.**
-
-```ts
-// ❌ DATA CORRUPTION RISK: Separate transaction
-hooks: {
- afterChange: [
- async ({ doc, req }) => {
- await req.payload.create({
- collection: 'audit-log',
- data: { docId: doc.id },
- // Missing req - runs in separate transaction!
- })
- },
- ]
-}
-
-// ✅ ATOMIC: Same transaction
-hooks: {
- afterChange: [
- async ({ doc, req }) => {
- await req.payload.create({
- collection: 'audit-log',
- data: { docId: doc.id },
- req, // Maintains atomicity
- })
- },
- ]
-}
-```
-
-See [ADAPTERS.md#threading-req-through-operations](reference/ADAPTERS.md#threading-req-through-operations).
-
-### 3. Infinite Hook Loops
-
-**Hooks triggering operations that trigger the same hooks create infinite loops.**
-
-```ts
-// ❌ INFINITE LOOP
-hooks: {
- afterChange: [
- async ({ doc, req }) => {
- await req.payload.update({
- collection: 'posts',
- id: doc.id,
- data: { views: doc.views + 1 },
- req,
- }) // Triggers afterChange again!
- },
- ]
-}
-
-// ✅ SAFE: Use context flag
-hooks: {
- afterChange: [
- async ({ doc, req, context }) => {
- if (context.skipHooks) return
-
- await req.payload.update({
- collection: 'posts',
- id: doc.id,
- data: { views: doc.views + 1 },
- context: { skipHooks: true },
- req,
- })
- },
- ]
-}
-```
-
-See [HOOKS.md#context](reference/HOOKS.md#context).
-
-## Project Structure
-
-```txt
-src/
-├── app/
-│ ├── (frontend)/
-│ │ └── page.tsx
-│ └── (payload)/
-│ └── admin/[[...segments]]/page.tsx
-├── collections/
-│ ├── Posts.ts
-│ ├── Media.ts
-│ └── Users.ts
-├── globals/
-│ └── Header.ts
-├── components/
-│ └── CustomField.tsx
-├── hooks/
-│ └── slugify.ts
-└── payload.config.ts
-```
-
-## Type Generation
-
-```ts
-// payload.config.ts
-export default buildConfig({
- typescript: {
- outputFile: path.resolve(dirname, 'payload-types.ts'),
- },
- // ...
-})
-
-// Usage
-import type { Post, User } from '@/payload-types'
-```
-
-## Reference Documentation
-
-- **[FIELDS.md](reference/FIELDS.md)** - All field types, validation, admin options
-- **[FIELD-TYPE-GUARDS.md](reference/FIELD-TYPE-GUARDS.md)** - Type guards for runtime field type checking and narrowing
-- **[COLLECTIONS.md](reference/COLLECTIONS.md)** - Collection configs, auth, upload, drafts, live preview
-- **[HOOKS.md](reference/HOOKS.md)** - Collection hooks, field hooks, context patterns
-- **[ACCESS-CONTROL.md](reference/ACCESS-CONTROL.md)** - Collection, field, global access control, RBAC, multi-tenant
-- **[ACCESS-CONTROL-ADVANCED.md](reference/ACCESS-CONTROL-ADVANCED.md)** - Context-aware, time-based, subscription-based access, factory functions, templates
-- **[QUERIES.md](reference/QUERIES.md)** - Query operators, Local/REST/GraphQL APIs
-- **[ENDPOINTS.md](reference/ENDPOINTS.md)** - Custom API endpoints: authentication, helpers, request/response patterns
-- **[ADAPTERS.md](reference/ADAPTERS.md)** - Database, storage, email adapters, transactions
-- **[ADVANCED.md](reference/ADVANCED.md)** - Authentication, jobs, endpoints, components, plugins, localization
-- **[PLUGIN-DEVELOPMENT.md](reference/PLUGIN-DEVELOPMENT.md)** - Plugin architecture, monorepo structure, patterns, best practices
-
-## Resources
-
-- llms-full.txt:
-- Docs:
-- GitHub:
-- Examples:
-- Templates:
diff --git a/.pi/skills/payload/reference/ACCESS-CONTROL-ADVANCED.md b/.pi/skills/payload/reference/ACCESS-CONTROL-ADVANCED.md
deleted file mode 100644
index ad164f2..0000000
--- a/.pi/skills/payload/reference/ACCESS-CONTROL-ADVANCED.md
+++ /dev/null
@@ -1,704 +0,0 @@
-# Payload Access Control - Advanced Patterns
-
-Advanced access control patterns including context-aware access, time-based restrictions, factory functions, and production templates.
-
-## Context-Aware Access Patterns
-
-### Locale-Specific Access
-
-Control access based on user locale for internationalized content.
-
-```ts
-import type { Access } from 'payload'
-
-export const localeSpecificAccess: Access = ({ req: { user, locale } }) => {
- // Authenticated users can access all locales
- if (user) return true
-
- // Public users can only access English content
- if (locale === 'en') return true
-
- return false
-}
-
-// Usage in collection
-export const Posts: CollectionConfig = {
- slug: 'posts',
- access: {
- read: localeSpecificAccess,
- },
- fields: [{ name: 'title', type: 'text', localized: true }],
-}
-```
-
-**Source**: `docs/access-control/overview.mdx` (req.locale argument)
-
-### Device-Specific Access
-
-Restrict access based on device type or user agent.
-
-```ts
-import type { Access } from 'payload'
-
-export const mobileOnlyAccess: Access = ({ req: { headers } }) => {
- const userAgent = headers?.get('user-agent') || ''
- return /mobile|android|iphone/i.test(userAgent)
-}
-
-export const desktopOnlyAccess: Access = ({ req: { headers } }) => {
- const userAgent = headers?.get('user-agent') || ''
- return !/mobile|android|iphone/i.test(userAgent)
-}
-
-// Usage
-export const MobileContent: CollectionConfig = {
- slug: 'mobile-content',
- access: {
- read: mobileOnlyAccess,
- },
- fields: [{ name: 'title', type: 'text' }],
-}
-```
-
-**Source**: Synthesized (headers pattern)
-
-### IP-Based Access
-
-Restrict access from specific IP addresses (requires middleware/proxy headers).
-
-```ts
-import type { Access } from 'payload'
-
-export const restrictedIpAccess = (allowedIps: string[]): Access => {
- return ({ req: { headers } }) => {
- const ip = headers?.get('x-forwarded-for') || headers?.get('x-real-ip')
- return allowedIps.includes(ip || '')
- }
-}
-
-// Usage
-const internalIps = ['192.168.1.0/24', '10.0.0.5']
-
-export const InternalDocs: CollectionConfig = {
- slug: 'internal-docs',
- access: {
- read: restrictedIpAccess(internalIps),
- },
- fields: [{ name: 'content', type: 'richText' }],
-}
-```
-
-**Note**: Requires your server to pass IP address via headers (common with proxies/load balancers).
-
-**Source**: Synthesized (headers pattern)
-
-## Time-Based Access Patterns
-
-### Today's Records Only
-
-```ts
-import type { Access } from 'payload'
-
-export const todayOnlyAccess: Access = ({ req: { user } }) => {
- if (!user) return false
-
- const now = new Date()
- const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate())
- const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000)
-
- return {
- createdAt: {
- greater_than_equal: startOfDay.toISOString(),
- less_than: endOfDay.toISOString(),
- },
- }
-}
-```
-
-**Source**: `test/access-control/config.ts` (query constraint patterns)
-
-### Recent Records (Last N Days)
-
-```ts
-import type { Access } from 'payload'
-
-export const recentRecordsAccess = (days: number): Access => {
- return ({ req: { user } }) => {
- if (!user) return false
- if (user.roles?.includes('admin')) return true
-
- const cutoff = new Date()
- cutoff.setDate(cutoff.getDate() - days)
-
- return {
- createdAt: {
- greater_than_equal: cutoff.toISOString(),
- },
- }
- }
-}
-
-// Usage: Users see only last 30 days, admins see all
-export const Logs: CollectionConfig = {
- slug: 'logs',
- access: {
- read: recentRecordsAccess(30),
- },
- fields: [{ name: 'message', type: 'text' }],
-}
-```
-
-### Scheduled Content (Publish Date Range)
-
-```ts
-import type { Access } from 'payload'
-
-export const scheduledContentAccess: Access = ({ req: { user } }) => {
- // Editors see all content
- if (user?.roles?.includes('admin') || user?.roles?.includes('editor')) {
- return true
- }
-
- const now = new Date().toISOString()
-
- // Public sees only content within publish window
- return {
- and: [
- { publishDate: { less_than_equal: now } },
- {
- or: [{ unpublishDate: { exists: false } }, { unpublishDate: { greater_than: now } }],
- },
- ],
- }
-}
-```
-
-**Source**: Synthesized (query constraint + date patterns)
-
-## Subscription-Based Access
-
-### Active Subscription Required
-
-```ts
-import type { Access } from 'payload'
-
-export const activeSubscriptionAccess: Access = async ({ req: { user } }) => {
- if (!user) return false
- if (user.roles?.includes('admin')) return true
-
- try {
- const subscription = await req.payload.findByID({
- collection: 'subscriptions',
- id: user.subscriptionId,
- })
-
- return subscription?.status === 'active'
- } catch {
- return false
- }
-}
-
-// Usage
-export const PremiumContent: CollectionConfig = {
- slug: 'premium-content',
- access: {
- read: activeSubscriptionAccess,
- },
- fields: [{ name: 'title', type: 'text' }],
-}
-```
-
-### Subscription Tier-Based Access
-
-```ts
-import type { Access } from 'payload'
-
-export const tierBasedAccess = (requiredTier: string): Access => {
- const tierHierarchy = ['free', 'basic', 'pro', 'enterprise']
-
- return async ({ req: { user } }) => {
- if (!user) return false
- if (user.roles?.includes('admin')) return true
-
- try {
- const subscription = await req.payload.findByID({
- collection: 'subscriptions',
- id: user.subscriptionId,
- })
-
- if (subscription?.status !== 'active') return false
-
- const userTierIndex = tierHierarchy.indexOf(subscription.tier)
- const requiredTierIndex = tierHierarchy.indexOf(requiredTier)
-
- return userTierIndex >= requiredTierIndex
- } catch {
- return false
- }
- }
-}
-
-// Usage
-export const EnterpriseFeatures: CollectionConfig = {
- slug: 'enterprise-features',
- access: {
- read: tierBasedAccess('enterprise'),
- },
- fields: [{ name: 'feature', type: 'text' }],
-}
-```
-
-**Source**: Synthesized (async + cross-collection pattern)
-
-## Factory Functions
-
-Reusable functions that generate access control configurations.
-
-### createRoleBasedAccess
-
-Generate access control for specific roles.
-
-```ts
-import type { Access } from 'payload'
-
-export function createRoleBasedAccess(roles: string[]): Access {
- return ({ req: { user } }) => {
- if (!user) return false
- return roles.some((role) => user.roles?.includes(role))
- }
-}
-
-// Usage
-const adminOrEditor = createRoleBasedAccess(['admin', 'editor'])
-const moderatorAccess = createRoleBasedAccess(['admin', 'moderator'])
-
-export const Posts: CollectionConfig = {
- slug: 'posts',
- access: {
- create: adminOrEditor,
- update: adminOrEditor,
- delete: moderatorAccess,
- },
- fields: [{ name: 'title', type: 'text' }],
-}
-```
-
-**Source**: `test/access-control/config.ts`
-
-### createOrgScopedAccess
-
-Generate organization-scoped access with optional admin bypass.
-
-```ts
-import type { Access } from 'payload'
-
-export function createOrgScopedAccess(allowAdmin = true): Access {
- return ({ req: { user } }) => {
- if (!user) return false
- if (allowAdmin && user.roles?.includes('admin')) return true
-
- return {
- organizationId: { in: user.organizationIds || [] },
- }
- }
-}
-
-// Usage
-const orgScoped = createOrgScopedAccess() // Admins bypass
-const strictOrgScoped = createOrgScopedAccess(false) // Admins also scoped
-
-export const Projects: CollectionConfig = {
- slug: 'projects',
- access: {
- read: orgScoped,
- update: orgScoped,
- delete: strictOrgScoped,
- },
- fields: [
- { name: 'title', type: 'text' },
- { name: 'organizationId', type: 'text', required: true },
- ],
-}
-```
-
-**Source**: `test/access-control/config.ts`
-
-### createTeamBasedAccess
-
-Generate team-scoped access with configurable field name.
-
-```ts
-import type { Access } from 'payload'
-
-export function createTeamBasedAccess(teamField = 'teamId'): Access {
- return ({ req: { user } }) => {
- if (!user) return false
- if (user.roles?.includes('admin')) return true
-
- return {
- [teamField]: { in: user.teamIds || [] },
- }
- }
-}
-
-// Usage with custom field name
-const projectTeamAccess = createTeamBasedAccess('projectTeam')
-
-export const Tasks: CollectionConfig = {
- slug: 'tasks',
- access: {
- read: projectTeamAccess,
- update: projectTeamAccess,
- },
- fields: [
- { name: 'title', type: 'text' },
- { name: 'projectTeam', type: 'text', required: true },
- ],
-}
-```
-
-**Source**: Synthesized (org pattern variation)
-
-### createTimeLimitedAccess
-
-Generate access limited to records within specified days.
-
-```ts
-import type { Access } from 'payload'
-
-export function createTimeLimitedAccess(daysAccess: number): Access {
- return ({ req: { user } }) => {
- if (!user) return false
- if (user.roles?.includes('admin')) return true
-
- const cutoff = new Date()
- cutoff.setDate(cutoff.getDate() - daysAccess)
-
- return {
- createdAt: {
- greater_than_equal: cutoff.toISOString(),
- },
- }
- }
-}
-
-// Usage: Users see 90 days, admins see all
-export const ActivityLogs: CollectionConfig = {
- slug: 'activity-logs',
- access: {
- read: createTimeLimitedAccess(90),
- },
- fields: [{ name: 'action', type: 'text' }],
-}
-```
-
-**Source**: Synthesized (time + query pattern)
-
-## Configuration Templates
-
-Complete collection configurations for common scenarios.
-
-### Basic Authenticated Collection
-
-```ts
-import type { CollectionConfig } from 'payload'
-
-export const BasicCollection: CollectionConfig = {
- slug: 'basic-collection',
- access: {
- create: ({ req: { user } }) => Boolean(user),
- read: ({ req: { user } }) => Boolean(user),
- update: ({ req: { user } }) => Boolean(user),
- delete: ({ req: { user } }) => Boolean(user),
- },
- fields: [
- { name: 'title', type: 'text', required: true },
- { name: 'content', type: 'richText' },
- ],
-}
-```
-
-**Source**: `docs/access-control/collections.mdx`
-
-### Public + Authenticated Collection
-
-```ts
-import type { CollectionConfig } from 'payload'
-
-export const PublicAuthCollection: CollectionConfig = {
- slug: 'posts',
- access: {
- // Only admins/editors can create
- create: ({ req: { user } }) => {
- return user?.roles?.some((role) => ['admin', 'editor'].includes(role)) || false
- },
-
- // Authenticated users see all, public sees only published
- read: ({ req: { user } }) => {
- if (user) return true
- return { _status: { equals: 'published' } }
- },
-
- // Only admins/editors can update
- update: ({ req: { user } }) => {
- return user?.roles?.some((role) => ['admin', 'editor'].includes(role)) || false
- },
-
- // Only admins can delete
- delete: ({ req: { user } }) => {
- return user?.roles?.includes('admin') || false
- },
- },
- versions: {
- drafts: true,
- },
- fields: [
- { name: 'title', type: 'text', required: true },
- { name: 'content', type: 'richText', required: true },
- { name: 'author', type: 'relationship', relationTo: 'users' },
- ],
-}
-```
-
-**Source**: `templates/website/src/collections/Posts/index.ts`
-
-### Multi-User/Self-Service Collection
-
-```ts
-import type { CollectionConfig } from 'payload'
-
-export const SelfServiceCollection: CollectionConfig = {
- slug: 'users',
- auth: true,
- access: {
- // Admins can create users
- create: ({ req: { user } }) => user?.roles?.includes('admin') || false,
-
- // Anyone can read user profiles
- read: () => true,
-
- // Users can update self, admins can update anyone
- update: ({ req: { user }, id }) => {
- if (!user) return false
- if (user.roles?.includes('admin')) return true
- return user.id === id
- },
-
- // Only admins can delete
- delete: ({ req: { user } }) => user?.roles?.includes('admin') || false,
- },
- fields: [
- { name: 'name', type: 'text', required: true },
- { name: 'email', type: 'email', required: true },
- {
- name: 'roles',
- type: 'select',
- hasMany: true,
- options: ['admin', 'editor', 'user'],
- access: {
- // Only admins can read/update roles
- read: ({ req: { user } }) => user?.roles?.includes('admin') || false,
- update: ({ req: { user } }) => user?.roles?.includes('admin') || false,
- },
- },
- ],
-}
-```
-
-**Source**: `templates/website/src/collections/Users/index.ts`
-
-## Debugging Tips
-
-### Log Access Check Execution
-
-```ts
-export const debugAccess: Access = ({ req: { user }, id }) => {
- console.log('Access check:', {
- userId: user?.id,
- userRoles: user?.roles,
- docId: id,
- timestamp: new Date().toISOString(),
- })
- return true
-}
-```
-
-### Verify Arguments Availability
-
-```ts
-export const checkArgsAccess: Access = (args) => {
- console.log('Available arguments:', {
- hasReq: 'req' in args,
- hasUser: args.req?.user ? 'yes' : 'no',
- hasId: args.id ? 'provided' : 'undefined',
- hasData: args.data ? 'provided' : 'undefined',
- })
- return true
-}
-```
-
-### Measure Async Operation Timing
-
-```ts
-export const timedAsyncAccess: Access = async ({ req }) => {
- const start = Date.now()
-
- const result = await fetch('https://auth-service.example.com/validate', {
- headers: { userId: req.user?.id },
- })
-
- console.log(`Access check took ${Date.now() - start}ms`)
-
- return result.ok
-}
-```
-
-### Test Access Without User
-
-```ts
-// In test/development
-const testAccess = await payload.find({
- collection: 'posts',
- overrideAccess: false, // Enforce access control
- user: undefined, // Simulate no user
-})
-
-console.log('Public access result:', testAccess.docs.length)
-```
-
-**Source**: Synthesized (debugging best practices)
-
-## Performance Considerations
-
-### Async Operations Impact
-
-```ts
-// ❌ Slow: Multiple sequential async calls
-export const slowAccess: Access = async ({ req: { user } }) => {
- const org = await req.payload.findByID({ collection: 'orgs', id: user.orgId })
- const team = await req.payload.findByID({ collection: 'teams', id: user.teamId })
- const subscription = await req.payload.findByID({ collection: 'subs', id: user.subId })
-
- return org.active && team.active && subscription.active
-}
-
-// ✅ Fast: Use query constraints or cache in context
-export const fastAccess: Access = ({ req: { user, context } }) => {
- // Cache expensive lookups
- if (!context.orgStatus) {
- context.orgStatus = checkOrgStatus(user.orgId)
- }
-
- return context.orgStatus
-}
-```
-
-### Query Constraint Optimization
-
-```ts
-// ❌ Avoid: Non-indexed fields in constraints
-export const slowQuery: Access = () => ({
- 'metadata.internalCode': { equals: 'ABC123' }, // Slow if not indexed
-})
-
-// ✅ Better: Use indexed fields
-export const fastQuery: Access = () => ({
- status: { equals: 'active' }, // Indexed field
- organizationId: { in: ['org1', 'org2'] }, // Indexed field
-})
-```
-
-### Field Access on Large Arrays
-
-```ts
-// ❌ Slow: Complex access on array fields
-const arrayField: ArrayField = {
- name: 'items',
- type: 'array',
- fields: [
- {
- name: 'secretData',
- type: 'text',
- access: {
- read: async ({ req }) => {
- // Async call runs for EVERY array item
- const result = await expensiveCheck()
- return result
- },
- },
- },
- ],
-}
-
-// ✅ Fast: Simple checks or cache result
-const optimizedArrayField: ArrayField = {
- name: 'items',
- type: 'array',
- fields: [
- {
- name: 'secretData',
- type: 'text',
- access: {
- read: ({ req: { user }, context }) => {
- // Cache once, reuse for all items
- if (context.canReadSecret === undefined) {
- context.canReadSecret = user?.roles?.includes('admin')
- }
- return context.canReadSecret
- },
- },
- },
- ],
-}
-```
-
-### Avoid N+1 Queries
-
-```ts
-// ❌ N+1 Problem: Query per access check
-export const n1Access: Access = async ({ req, id }) => {
- // Runs for EACH document in list
- const doc = await req.payload.findByID({ collection: 'docs', id })
- return doc.isPublic
-}
-
-// ✅ Better: Use query constraint to filter at DB level
-export const efficientAccess: Access = () => {
- return { isPublic: { equals: true } }
-}
-```
-
-**Performance Best Practices:**
-
-1. **Minimize Async Operations**: Use query constraints over async lookups when possible
-2. **Cache Expensive Checks**: Store results in `req.context` for reuse
-3. **Index Query Fields**: Ensure fields in query constraints are indexed
-4. **Avoid Complex Logic in Array Fields**: Simple boolean checks preferred
-5. **Use Query Constraints**: Let database filter rather than loading all records
-
-**Source**: Synthesized (operational best practices)
-
-## Enhanced Best Practices
-
-Comprehensive security and implementation guidelines:
-
-1. **Default Deny**: Start with restrictive access, gradually add permissions
-2. **Type Guards**: Use TypeScript for user type safety and better IDE support
-3. **Validate Data**: Never trust frontend-provided IDs or data
-4. **Async for Critical Checks**: Use async operations for important security decisions
-5. **Consistent Logic**: Apply same rules at field and collection levels
-6. **Test Edge Cases**: Test with no user, wrong user, admin user scenarios
-7. **Monitor Access**: Log failed access attempts for security review
-8. **Regular Audit**: Review access rules quarterly or after major changes
-9. **Cache Wisely**: Use `req.context` for expensive operations
-10. **Document Intent**: Add comments explaining complex access rules
-11. **Avoid Secrets in Client**: Never expose sensitive logic to client-side
-12. **Rate Limit External Calls**: Protect against DoS on external validation services
-13. **Handle Errors Gracefully**: Access functions should return `false` on error, not throw
-14. **Use Environment Vars**: Store configuration (IPs, API keys) in env vars
-15. **Test Local API**: Remember to set `overrideAccess: false` when testing
-16. **Consider Performance**: Measure impact of async operations on login time
-17. **Version Control**: Track access control changes in git history
-18. **Principle of Least Privilege**: Grant minimum access required for functionality
-
-**Sources**: `docs/access-control/*.mdx`, synthesized best practices
diff --git a/.pi/skills/payload/reference/ACCESS-CONTROL.md b/.pi/skills/payload/reference/ACCESS-CONTROL.md
deleted file mode 100644
index 39571f5..0000000
--- a/.pi/skills/payload/reference/ACCESS-CONTROL.md
+++ /dev/null
@@ -1,697 +0,0 @@
-# Payload Access Control Reference
-
-Complete reference for access control patterns across collections, fields, and globals.
-
-## At a Glance
-
-| Feature | Scope | Returns | Use Case |
-| --------------------- | --------------------------------------------------------- | ---------------------- | ---------------------------------- |
-| **Collection Access** | create, read, update, delete, admin, unlock, readVersions | boolean \| Where query | Document-level permissions |
-| **Field Access** | create, read, update | boolean only | Field-level visibility/editability |
-| **Global Access** | read, update, readVersions | boolean \| Where query | Global document permissions |
-
-## Three Layers of Access Control
-
-Payload provides three distinct access control layers:
-
-1. **Collection-Level**: Controls operations on entire documents (create, read, update, delete, admin, unlock, readVersions)
-2. **Field-Level**: Controls access to individual fields (create, read, update)
-3. **Global-Level**: Controls access to global documents (read, update, readVersions)
-
-## Return Value Types
-
-Access control functions can return:
-
-- **Boolean**: `true` (allow) or `false` (deny)
-- **Query Constraint**: `Where` object for row-level security (collection-level only)
-
-Field-level access does NOT support query constraints - only boolean returns.
-
-## Operation Decision Tree
-
-```txt
-User makes request
- │
- ├─ Collection access check
- │ ├─ Returns false? → Deny entire operation
- │ ├─ Returns true? → Continue
- │ └─ Returns Where? → Apply query constraint
- │
- ├─ Field access check (if applicable)
- │ ├─ Returns false? → Field omitted from result
- │ └─ Returns true? → Include field
- │
- └─ Operation completed
-```
-
-## Collection Access Control
-
-### Basic Patterns
-
-```ts
-import type { CollectionConfig, Access } from 'payload'
-
-export const Posts: CollectionConfig = {
- slug: 'posts',
- access: {
- // Boolean: Only authenticated users can create
- create: ({ req: { user } }) => Boolean(user),
-
- // Query constraint: Public sees published, users see all
- read: ({ req: { user } }) => {
- if (user) return true
- return { status: { equals: 'published' } }
- },
-
- // User-specific: Admins or document owner
- update: ({ req: { user }, id }) => {
- if (user?.roles?.includes('admin')) return true
- return { author: { equals: user?.id } }
- },
-
- // Async: Check related data
- delete: async ({ req, id }) => {
- const hasComments = await req.payload.count({
- collection: 'comments',
- where: { post: { equals: id } },
- })
- return hasComments === 0
- },
-
- // Admin panel visibility
- admin: ({ req: { user } }) => {
- return user?.roles?.includes('admin') || user?.roles?.includes('editor')
- },
- },
- fields: [
- { name: 'title', type: 'text' },
- { name: 'status', type: 'select', options: ['draft', 'published'] },
- { name: 'author', type: 'relationship', relationTo: 'users' },
- ],
-}
-```
-
-### Role-Based Access Control (RBAC) Pattern
-
-Payload does NOT provide a roles system by default. The following is a commonly accepted pattern for implementing role-based access control in auth collections:
-
-```ts
-import type { CollectionConfig } from 'payload'
-
-export const Users: CollectionConfig = {
- slug: 'users',
- auth: true,
- fields: [
- { name: 'name', type: 'text', required: true },
- { name: 'email', type: 'email', required: true },
- {
- name: 'roles',
- type: 'select',
- hasMany: true,
- options: ['admin', 'editor', 'user'],
- defaultValue: ['user'],
- required: true,
- // Save roles to JWT for access control without database lookups
- saveToJWT: true,
- access: {
- // Only admins can update roles
- update: ({ req: { user } }) => user?.roles?.includes('admin'),
- },
- },
- ],
-}
-```
-
-**Important Notes:**
-
-1. **Not Built-In**: Payload does not provide a roles system out of the box. You must add a `roles` field to your auth collection.
-2. **Save to JWT**: Use `saveToJWT: true` to include roles in the JWT token, enabling role checks without database queries.
-3. **Default Value**: Set a `defaultValue` to automatically assign new users a default role.
-4. **Access Control**: Restrict who can modify roles (typically only admins).
-5. **Role Options**: Define your own role hierarchy based on your application needs.
-
-**Using Roles in Access Control:**
-
-```ts
-import type { Access } from 'payload'
-
-// Check for specific role
-export const adminOnly: Access = ({ req: { user } }) => {
- return user?.roles?.includes('admin')
-}
-
-// Check for multiple roles
-export const adminOrEditor: Access = ({ req: { user } }) => {
- return Boolean(user?.roles?.some((role) => ['admin', 'editor'].includes(role)))
-}
-
-// Role hierarchy check
-export const hasMinimumRole: Access = ({ req: { user } }, minRole: string) => {
- const roleHierarchy = ['user', 'editor', 'admin']
- const userHighestRole = Math.max(...(user?.roles?.map((r) => roleHierarchy.indexOf(r)) || [-1]))
- const requiredRoleIndex = roleHierarchy.indexOf(minRole)
-
- return userHighestRole >= requiredRoleIndex
-}
-```
-
-### Reusable Access Functions
-
-```ts
-import type { Access } from 'payload'
-
-// Anyone (public)
-export const anyone: Access = () => true
-
-// Authenticated only
-export const authenticated: Access = ({ req: { user } }) => Boolean(user)
-
-// Authenticated or published content
-export const authenticatedOrPublished: Access = ({ req: { user } }) => {
- if (user) return true
- return { _status: { equals: 'published' } }
-}
-
-// Admin only
-export const admins: Access = ({ req: { user } }) => {
- return user?.roles?.includes('admin')
-}
-
-// Admin or editor
-export const adminsOrEditors: Access = ({ req: { user } }) => {
- return Boolean(user?.roles?.some((role) => ['admin', 'editor'].includes(role)))
-}
-
-// Self or admin
-export const adminsOrSelf: Access = ({ req: { user } }) => {
- if (user?.roles?.includes('admin')) return true
- return { id: { equals: user?.id } }
-}
-
-// Usage
-export const Posts: CollectionConfig = {
- slug: 'posts',
- access: {
- create: authenticated,
- read: authenticatedOrPublished,
- update: adminsOrEditors,
- delete: admins,
- },
- fields: [{ name: 'title', type: 'text' }],
-}
-```
-
-### Row-Level Security with Complex Queries
-
-```ts
-import type { Access } from 'payload'
-
-// Organization-scoped access
-export const organizationScoped: Access = ({ req: { user } }) => {
- if (user?.roles?.includes('admin')) return true
-
- // Users see only their organization's data
- return {
- organization: {
- equals: user?.organization,
- },
- }
-}
-
-// Multiple conditions with AND
-export const complexAccess: Access = ({ req: { user } }) => {
- return {
- and: [
- { status: { equals: 'published' } },
- { 'author.isActive': { equals: true } },
- {
- or: [{ visibility: { equals: 'public' } }, { author: { equals: user?.id } }],
- },
- ],
- }
-}
-
-// Team-based access
-export const teamMemberAccess: Access = ({ req: { user } }) => {
- if (!user) return false
- if (user.roles?.includes('admin')) return true
-
- return {
- 'team.members': {
- contains: user.id,
- },
- }
-}
-```
-
-### Header-Based Access (API Keys)
-
-```ts
-import type { Access } from 'payload'
-
-export const apiKeyAccess: Access = ({ req }) => {
- const apiKey = req.headers.get('x-api-key')
-
- if (!apiKey) return false
-
- // Validate against stored keys
- return apiKey === process.env.VALID_API_KEY
-}
-
-// Bearer token validation
-export const bearerTokenAccess: Access = async ({ req }) => {
- const auth = req.headers.get('authorization')
-
- if (!auth?.startsWith('Bearer ')) return false
-
- const token = auth.slice(7)
- const isValid = await validateToken(token)
-
- return isValid
-}
-```
-
-## Field Access Control
-
-Field access does NOT support query constraints - only boolean returns.
-
-### Basic Field Access
-
-```ts
-import type { NumberField, FieldAccess } from 'payload'
-
-const salaryReadAccess: FieldAccess = ({ req: { user }, doc }) => {
- // Self can read own salary
- if (user?.id === doc?.id) return true
- // Admin can read all salaries
- return user?.roles?.includes('admin')
-}
-
-const salaryUpdateAccess: FieldAccess = ({ req: { user } }) => {
- // Only admins can update salary
- return user?.roles?.includes('admin')
-}
-
-const salaryField: NumberField = {
- name: 'salary',
- type: 'number',
- access: {
- read: salaryReadAccess,
- update: salaryUpdateAccess,
- },
-}
-```
-
-### Sibling Data Access
-
-```ts
-import type { ArrayField, FieldAccess } from 'payload'
-
-const contentReadAccess: FieldAccess = ({ req: { user }, siblingData }) => {
- // Authenticated users see all
- if (user) return true
- // Public sees only if marked public
- return siblingData?.isPublic === true
-}
-
-const arrayField: ArrayField = {
- name: 'sections',
- type: 'array',
- fields: [
- {
- name: 'isPublic',
- type: 'checkbox',
- defaultValue: false,
- },
- {
- name: 'content',
- type: 'text',
- access: {
- read: contentReadAccess,
- },
- },
- ],
-}
-```
-
-### Nested Field Access
-
-```ts
-import type { GroupField, FieldAccess } from 'payload'
-
-const internalOnlyAccess: FieldAccess = ({ req: { user } }) => {
- return user?.roles?.includes('admin') || user?.roles?.includes('internal')
-}
-
-const groupField: GroupField = {
- name: 'internalMetadata',
- type: 'group',
- access: {
- read: internalOnlyAccess,
- update: internalOnlyAccess,
- },
- fields: [
- { name: 'internalNotes', type: 'textarea' },
- { name: 'priority', type: 'select', options: ['low', 'medium', 'high'] },
- ],
-}
-```
-
-### Hiding Admin Fields
-
-```ts
-import type { CollectionConfig } from 'payload'
-
-export const Users: CollectionConfig = {
- slug: 'users',
- auth: true,
- fields: [
- { name: 'name', type: 'text', required: true },
- { name: 'email', type: 'email', required: true },
- {
- name: 'roles',
- type: 'select',
- hasMany: true,
- options: ['admin', 'editor', 'user'],
- access: {
- // Hide from UI, but still saved/queried
- read: ({ req: { user } }) => user?.roles?.includes('admin'),
- // Only admins can update roles
- update: ({ req: { user } }) => user?.roles?.includes('admin'),
- },
- },
- ],
-}
-```
-
-## Global Access Control
-
-```ts
-import type { GlobalConfig, Access } from 'payload'
-
-const adminOnly: Access = ({ req: { user } }) => {
- return user?.roles?.includes('admin')
-}
-
-export const SiteSettings: GlobalConfig = {
- slug: 'site-settings',
- access: {
- read: () => true, // Anyone can read settings
- update: adminOnly, // Only admins can update
- readVersions: adminOnly, // Only admins can see version history
- },
- fields: [
- { name: 'siteName', type: 'text' },
- { name: 'maintenanceMode', type: 'checkbox' },
- ],
-}
-```
-
-## Multi-Tenant Access Control
-
-```ts
-import type { Access, CollectionConfig } from 'payload'
-
-// Add tenant field to user type
-interface User {
- id: string
- tenantId: string
- roles?: string[]
-}
-
-// Tenant-scoped access
-const tenantAccess: Access = ({ req: { user } }) => {
- // No user = no access
- if (!user) return false
-
- // Super admin sees all
- if (user.roles?.includes('super-admin')) return true
-
- // Users see only their tenant's data
- return {
- tenant: {
- equals: (user as User).tenantId,
- },
- }
-}
-
-export const Posts: CollectionConfig = {
- slug: 'posts',
- access: {
- create: tenantAccess,
- read: tenantAccess,
- update: tenantAccess,
- delete: tenantAccess,
- },
- fields: [
- { name: 'title', type: 'text' },
- {
- name: 'tenant',
- type: 'text',
- required: true,
- access: {
- // Tenant field hidden from non-admins
- update: ({ req: { user } }) => user?.roles?.includes('super-admin'),
- },
- hooks: {
- // Auto-set tenant on create
- beforeChange: [
- ({ req, operation, value }) => {
- if (operation === 'create' && !value) {
- return (req.user as User)?.tenantId
- }
- return value
- },
- ],
- },
- },
- ],
-}
-```
-
-## Auth Collection Patterns
-
-### Self or Admin Pattern
-
-```ts
-import type { CollectionConfig } from 'payload'
-
-export const Users: CollectionConfig = {
- slug: 'users',
- auth: true,
- access: {
- // Anyone can read user profiles
- read: () => true,
-
- // Users can update themselves, admins can update anyone
- update: ({ req: { user }, id }) => {
- if (user?.roles?.includes('admin')) return true
- return user?.id === id
- },
-
- // Only admins can delete
- delete: ({ req: { user } }) => user?.roles?.includes('admin'),
- },
- fields: [
- { name: 'name', type: 'text' },
- { name: 'email', type: 'email' },
- ],
-}
-```
-
-### Restrict Self-Updates
-
-```ts
-import type { CollectionConfig, FieldAccess } from 'payload'
-
-const preventSelfRoleChange: FieldAccess = ({ req: { user }, id }) => {
- // Admins can change anyone's roles
- if (user?.roles?.includes('admin')) return true
- // Users cannot change their own roles
- if (user?.id === id) return false
- return false
-}
-
-export const Users: CollectionConfig = {
- slug: 'users',
- auth: true,
- fields: [
- {
- name: 'roles',
- type: 'select',
- hasMany: true,
- options: ['admin', 'editor', 'user'],
- access: {
- update: preventSelfRoleChange,
- },
- },
- ],
-}
-```
-
-## Cross-Collection Validation
-
-```ts
-import type { Access } from 'payload'
-
-// Check if user is a project member before allowing access
-export const projectMemberAccess: Access = async ({ req, id }) => {
- const { user, payload } = req
-
- if (!user) return false
- if (user.roles?.includes('admin')) return true
-
- // Check if document exists and user is member
- const project = await payload.findByID({
- collection: 'projects',
- id: id as string,
- depth: 0,
- })
-
- return project.members?.includes(user.id)
-}
-
-// Prevent deletion if document has dependencies
-export const preventDeleteWithDependencies: Access = async ({ req, id }) => {
- const { payload } = req
-
- const dependencyCount = await payload.count({
- collection: 'related-items',
- where: {
- parent: { equals: id },
- },
- })
-
- return dependencyCount === 0
-}
-```
-
-## Access Control Function Arguments
-
-### Collection Create
-
-```ts
-create: ({ req, data }) => boolean | Where
-
-// req: PayloadRequest
-// - req.user: Authenticated user (if any)
-// - req.payload: Payload instance for queries
-// - req.headers: Request headers
-// - req.locale: Current locale
-// data: The data being created
-```
-
-### Collection Read
-
-```ts
-read: ({ req, id }) => boolean | Where
-
-// req: PayloadRequest
-// id: Document ID being read
-// - undefined during Access Operation (login check)
-// - string when reading specific document
-```
-
-### Collection Update
-
-```ts
-update: ({ req, id, data }) => boolean | Where
-
-// req: PayloadRequest
-// id: Document ID being updated
-// data: New values being applied
-```
-
-### Collection Delete
-
-```ts
-delete: ({ req, id }) => boolean | Where
-
-// req: PayloadRequest
-// id: Document ID being deleted
-```
-
-### Field Create
-
-```ts
-access: {
- create: ({ req, data, siblingData }) => boolean
-}
-
-// req: PayloadRequest
-// data: Full document data
-// siblingData: Adjacent field values at same level
-```
-
-### Field Read
-
-```ts
-access: {
- read: ({ req, id, doc, siblingData }) => boolean
-}
-
-// req: PayloadRequest
-// id: Document ID
-// doc: Full document
-// siblingData: Adjacent field values
-```
-
-### Field Update
-
-```ts
-access: {
- update: ({ req, id, data, doc, siblingData }) => boolean
-}
-
-// req: PayloadRequest
-// id: Document ID
-// data: New values
-// doc: Current document
-// siblingData: Adjacent field values
-```
-
-## Important Notes
-
-1. **Local API Default**: Access control is **skipped by default** in Local API (`overrideAccess: true`). When passing a `user` parameter, you almost always want to set `overrideAccess: false` to respect that user's permissions:
-
- ```ts
- // ❌ WRONG: Passes user but bypasses access control (default behavior)
- await payload.find({
- collection: 'posts',
- user: someUser, // User is ignored for access control!
- })
-
- // ✅ CORRECT: Respects the user's permissions
- await payload.find({
- collection: 'posts',
- user: someUser,
- overrideAccess: false, // Required to enforce access control
- })
- ```
-
- **Why this matters**: If you pass `user` without `overrideAccess: false`, the operation runs with admin privileges regardless of the user's actual permissions. This is a common security mistake.
-
-2. **Field Access Limitations**: Field-level access does NOT support query constraints - only boolean returns.
-
-3. **Admin Panel Visibility**: The `admin` access control determines if a collection appears in the admin panel for a user.
-
-4. **Access Before Hooks**: Access control executes BEFORE hooks run, so hooks cannot modify access behavior.
-
-5. **Query Constraints**: Only collection-level `read` access supports query constraints. All other operations and field-level access require boolean returns.
-
-## Best Practices
-
-1. **Reusable Functions**: Create named access functions for common patterns
-2. **Fail Secure**: Default to `false` for sensitive operations
-3. **Cache Checks**: Use `req.context` to cache expensive validation
-4. **Type Safety**: Type your user object for better IDE support
-5. **Test Thoroughly**: Write tests for complex access control logic
-6. **Document Intent**: Add comments explaining access rules
-7. **Audit Logs**: Track access control decisions for security review
-8. **Performance**: Avoid N+1 queries in access functions
-9. **Error Handling**: Access functions should not throw - return `false` instead
-10. **Tenant Hooks**: Auto-set tenant fields in `beforeChange` hooks
-
-## Advanced Patterns
-
-For advanced access control patterns including context-aware access, time-based restrictions, subscription-based access, factory functions, configuration templates, debugging tips, and performance optimization, see [ACCESS-CONTROL-ADVANCED.md](ACCESS-CONTROL-ADVANCED.md).
diff --git a/.pi/skills/payload/reference/ADAPTERS.md b/.pi/skills/payload/reference/ADAPTERS.md
deleted file mode 100644
index 64d0f10..0000000
--- a/.pi/skills/payload/reference/ADAPTERS.md
+++ /dev/null
@@ -1,326 +0,0 @@
-# Payload Adapters Reference
-
-Complete reference for database, storage, and email adapters.
-
-## Database Adapters
-
-### MongoDB
-
-```ts
-import { mongooseAdapter } from '@payloadcms/db-mongodb'
-
-export default buildConfig({
- db: mongooseAdapter({
- url: process.env.DATABASE_URL,
- }),
-})
-```
-
-### Postgres
-
-```ts
-import { postgresAdapter } from '@payloadcms/db-postgres'
-
-export default buildConfig({
- db: postgresAdapter({
- pool: {
- connectionString: process.env.DATABASE_URL,
- },
- push: false, // Don't auto-push schema changes
- migrationDir: './migrations',
- }),
-})
-```
-
-### SQLite
-
-```ts
-import { sqliteAdapter } from '@payloadcms/db-sqlite'
-
-export default buildConfig({
- db: sqliteAdapter({
- client: {
- url: 'file:./payload.db',
- },
- transactionOptions: {}, // Enable transactions (disabled by default)
- }),
-})
-```
-
-## Transactions
-
-Payload automatically uses transactions for all-or-nothing database operations. Pass `req` to include operations in the same transaction.
-
-```ts
-import type { CollectionAfterChangeHook } from 'payload'
-
-const afterChange: CollectionAfterChangeHook = async ({ req, doc }) => {
- // This will be part of the same transaction
- await req.payload.create({
- req, // Pass req to use same transaction
- collection: 'audit-log',
- data: { action: 'created', docId: doc.id },
- })
-}
-
-// Manual transaction control
-const transactionID = await payload.db.beginTransaction()
-try {
- await payload.create({
- collection: 'orders',
- data: orderData,
- req: { transactionID },
- })
- await payload.update({
- collection: 'inventory',
- id: itemId,
- data: { stock: newStock },
- req: { transactionID },
- })
- await payload.db.commitTransaction(transactionID)
-} catch (error) {
- await payload.db.rollbackTransaction(transactionID)
- throw error
-}
-```
-
-**Note**: MongoDB requires replicaset for transactions. SQLite requires `transactionOptions: {}` to enable.
-
-### Threading req Through Operations
-
-**Critical**: When performing nested operations in hooks, always pass `req` to maintain transaction context. Failing to do so breaks atomicity and can cause partial updates.
-
-```ts
-import type { CollectionAfterChangeHook } from 'payload'
-
-// ✅ CORRECT: Thread req through nested operations
-const resaveChildren: CollectionAfterChangeHook = async ({ collection, doc, req }) => {
- // Find children - pass req
- const children = await req.payload.find({
- collection: 'children',
- where: { parent: { equals: doc.id } },
- req, // Maintains transaction context
- })
-
- // Update each child - pass req
- for (const child of children.docs) {
- await req.payload.update({
- id: child.id,
- collection: 'children',
- data: { updatedField: 'value' },
- req, // Same transaction as parent operation
- })
- }
-}
-
-// ❌ WRONG: Missing req breaks transaction
-const brokenHook: CollectionAfterChangeHook = async ({ collection, doc, req }) => {
- const children = await req.payload.find({
- collection: 'children',
- where: { parent: { equals: doc.id } },
- // Missing req - separate transaction or no transaction
- })
-
- for (const child of children.docs) {
- await req.payload.update({
- id: child.id,
- collection: 'children',
- data: { updatedField: 'value' },
- // Missing req - if parent operation fails, these updates persist
- })
- }
-}
-```
-
-**Why This Matters:**
-
-- **MongoDB (with replica sets)**: Creates atomic session across operations
-- **PostgreSQL**: All operations use same Drizzle transaction
-- **SQLite (with transactions enabled)**: Ensures rollback on errors
-- **Without req**: Each operation runs independently, breaking atomicity
-
-**When req is Required:**
-
-- All mutating operations in hooks (create, update, delete)
-- Operations that must succeed/fail together
-- When using MongoDB replica sets or Postgres
-- Any operation that relies on `req.context` or `req.user`
-
-**When req is Optional:**
-
-- Read-only lookups independent of current transaction
-- Operations with `disableTransaction: true`
-- Administrative operations with `overrideAccess: true`
-
-## Storage Adapters
-
-Available storage adapters:
-
-- **@payloadcms/storage-s3** - AWS S3
-- **@payloadcms/storage-azure** - Azure Blob Storage
-- **@payloadcms/storage-gcs** - Google Cloud Storage
-- **@payloadcms/storage-r2** - Cloudflare R2
-- **@payloadcms/storage-vercel-blob** - Vercel Blob
-- **@payloadcms/storage-uploadthing** - Uploadthing
-
-### AWS S3
-
-```ts
-import { s3Storage } from '@payloadcms/storage-s3'
-
-export default buildConfig({
- plugins: [
- s3Storage({
- collections: {
- media: true,
- },
- bucket: process.env.S3_BUCKET,
- config: {
- credentials: {
- accessKeyId: process.env.S3_ACCESS_KEY_ID,
- secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
- },
- region: process.env.S3_REGION,
- },
- }),
- ],
-})
-```
-
-### Azure Blob Storage
-
-```ts
-import { azureStorage } from '@payloadcms/storage-azure'
-
-export default buildConfig({
- plugins: [
- azureStorage({
- collections: {
- media: true,
- },
- connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
- containerName: process.env.AZURE_STORAGE_CONTAINER_NAME,
- }),
- ],
-})
-```
-
-### Google Cloud Storage
-
-```ts
-import { gcsStorage } from '@payloadcms/storage-gcs'
-
-export default buildConfig({
- plugins: [
- gcsStorage({
- collections: {
- media: true,
- },
- bucket: process.env.GCS_BUCKET,
- options: {
- projectId: process.env.GCS_PROJECT_ID,
- credentials: JSON.parse(process.env.GCS_CREDENTIALS),
- },
- }),
- ],
-})
-```
-
-### Cloudflare R2
-
-```ts
-import { r2Storage } from '@payloadcms/storage-r2'
-
-export default buildConfig({
- plugins: [
- r2Storage({
- collections: {
- media: true,
- },
- bucket: process.env.R2_BUCKET,
- config: {
- credentials: {
- accessKeyId: process.env.R2_ACCESS_KEY_ID,
- secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
- },
- region: 'auto',
- endpoint: process.env.R2_ENDPOINT,
- },
- }),
- ],
-})
-```
-
-### Vercel Blob
-
-```ts
-import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
-
-export default buildConfig({
- plugins: [
- vercelBlobStorage({
- collections: {
- media: true,
- },
- token: process.env.BLOB_READ_WRITE_TOKEN,
- }),
- ],
-})
-```
-
-### Uploadthing
-
-```ts
-import { uploadthingStorage } from '@payloadcms/storage-uploadthing'
-
-export default buildConfig({
- plugins: [
- uploadthingStorage({
- collections: {
- media: true,
- },
- options: {
- token: process.env.UPLOADTHING_TOKEN,
- acl: 'public-read',
- },
- }),
- ],
-})
-```
-
-## Email Adapters
-
-### Nodemailer (SMTP)
-
-```ts
-import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
-
-export default buildConfig({
- email: nodemailerAdapter({
- defaultFromAddress: 'noreply@example.com',
- defaultFromName: 'My App',
- transportOptions: {
- host: process.env.SMTP_HOST,
- port: 587,
- auth: {
- user: process.env.SMTP_USER,
- pass: process.env.SMTP_PASS,
- },
- },
- }),
-})
-```
-
-### Resend
-
-```ts
-import { resendAdapter } from '@payloadcms/email-resend'
-
-export default buildConfig({
- email: resendAdapter({
- defaultFromAddress: 'noreply@example.com',
- defaultFromName: 'My App',
- apiKey: process.env.RESEND_API_KEY,
- }),
-})
-```
diff --git a/.pi/skills/payload/reference/ADVANCED.md b/.pi/skills/payload/reference/ADVANCED.md
deleted file mode 100644
index ce5e359..0000000
--- a/.pi/skills/payload/reference/ADVANCED.md
+++ /dev/null
@@ -1,386 +0,0 @@
-# Payload Advanced Features
-
-Complete reference for authentication, jobs, custom endpoints, components, plugins, and localization.
-
-## Authentication
-
-### Login
-
-```ts
-// REST API
-const response = await fetch('/api/users/login', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- email: 'user@example.com',
- password: 'password',
- }),
-})
-
-// Local API
-const result = await payload.login({
- collection: 'users',
- data: {
- email: 'user@example.com',
- password: 'password',
- },
-})
-```
-
-### Forgot Password
-
-```ts
-await payload.forgotPassword({
- collection: 'users',
- data: {
- email: 'user@example.com',
- },
-})
-```
-
-### Custom Strategy
-
-```ts
-import type { CollectionConfig, Strategy } from 'payload'
-
-const customStrategy: Strategy = {
- name: 'custom',
- authenticate: async ({ payload, headers }) => {
- const token = headers.get('authorization')?.split(' ')[1]
- if (!token) return { user: null }
-
- const user = await verifyToken(token)
- return { user }
- },
-}
-
-export const Users: CollectionConfig = {
- slug: 'users',
- auth: {
- strategies: [customStrategy],
- },
- fields: [],
-}
-```
-
-### API Keys
-
-```ts
-import type { CollectionConfig } from 'payload'
-
-export const APIKeys: CollectionConfig = {
- slug: 'api-keys',
- auth: {
- disableLocalStrategy: true,
- useAPIKey: true,
- },
- fields: [],
-}
-```
-
-## Jobs Queue
-
-Offload long-running or scheduled tasks to background workers.
-
-### Tasks
-
-```ts
-import { buildConfig } from 'payload'
-import type { TaskConfig } from 'payload'
-
-export default buildConfig({
- jobs: {
- tasks: [
- {
- slug: 'sendWelcomeEmail',
- inputSchema: [
- { name: 'userEmail', type: 'text', required: true },
- { name: 'userName', type: 'text', required: true },
- ],
- outputSchema: [{ name: 'emailSent', type: 'checkbox', required: true }],
- retries: 2, // Retry up to 2 times on failure
- handler: async ({ input, req }) => {
- await sendEmail({
- to: input.userEmail,
- subject: `Welcome ${input.userName}`,
- })
- return { output: { emailSent: true } }
- },
- } as TaskConfig<'sendWelcomeEmail'>,
- ],
- },
-})
-```
-
-### Queueing Jobs
-
-```ts
-// In a hook or endpoint
-await req.payload.jobs.queue({
- task: 'sendWelcomeEmail',
- input: {
- userEmail: 'user@example.com',
- userName: 'John',
- },
- waitUntil: new Date('2024-12-31'), // Optional: schedule for future
-})
-```
-
-### Workflows
-
-Multi-step jobs that run in sequence:
-
-```ts
-{
- slug: 'onboardUser',
- inputSchema: [{ name: 'userId', type: 'text' }],
- handler: async ({ job, req }) => {
- const results = await job.runInlineTask({
- task: async ({ input }) => {
- // Step 1: Send welcome email
- await sendEmail(input.userId)
- return { output: { emailSent: true } }
- },
- })
-
- await job.runInlineTask({
- task: async () => {
- // Step 2: Create onboarding tasks
- await createTasks()
- return { output: { tasksCreated: true } }
- },
- })
- },
-}
-```
-
-## Custom Endpoints
-
-Add custom REST API routes to collections, globals, or root config. See [ENDPOINTS.md](ENDPOINTS.md) for detailed patterns, authentication, helpers, and real-world examples.
-
-### Root Endpoints
-
-```ts
-import { buildConfig } from 'payload'
-import type { Endpoint } from 'payload'
-
-const helloEndpoint: Endpoint = {
- path: '/hello',
- method: 'get',
- handler: () => {
- return Response.json({ message: 'Hello!' })
- },
-}
-
-const greetEndpoint: Endpoint = {
- path: '/greet/:name',
- method: 'get',
- handler: (req) => {
- return Response.json({
- message: `Hello ${req.routeParams.name}!`,
- })
- },
-}
-
-export default buildConfig({
- endpoints: [helloEndpoint, greetEndpoint],
- collections: [],
- secret: process.env.PAYLOAD_SECRET || '',
-})
-```
-
-### Collection Endpoints
-
-```ts
-import type { CollectionConfig, Endpoint } from 'payload'
-
-const featuredEndpoint: Endpoint = {
- path: '/featured',
- method: 'get',
- handler: async (req) => {
- const posts = await req.payload.find({
- collection: 'posts',
- where: { featured: { equals: true } },
- })
- return Response.json(posts)
- },
-}
-
-export const Posts: CollectionConfig = {
- slug: 'posts',
- endpoints: [featuredEndpoint],
- fields: [
- { name: 'title', type: 'text' },
- { name: 'featured', type: 'checkbox' },
- ],
-}
-```
-
-## Custom Components
-
-### Field Component (Client)
-
-```tsx
-'use client'
-import { useField } from '@payloadcms/ui'
-import type { TextFieldClientComponent } from 'payload'
-
-export const CustomField: TextFieldClientComponent = () => {
- const { value, setValue } = useField()
-
- return setValue(e.target.value)} />
-}
-```
-
-### Custom View
-
-```tsx
-'use client'
-import { DefaultTemplate } from '@payloadcms/next/templates'
-
-export const CustomView = () => {
- return (
-
- Custom Dashboard
- {/* Your content */}
-
- )
-}
-```
-
-### Admin Config
-
-```ts
-import { buildConfig } from 'payload'
-
-export default buildConfig({
- admin: {
- components: {
- beforeDashboard: ['/components/BeforeDashboard'],
- beforeLogin: ['/components/BeforeLogin'],
- views: {
- custom: {
- Component: '/views/Custom',
- path: '/custom',
- },
- },
- },
- },
- collections: [],
- secret: process.env.PAYLOAD_SECRET || '',
-})
-```
-
-## Plugins
-
-### Available Plugins
-
-- **@payloadcms/plugin-seo** - SEO fields with meta title/description, Open Graph, preview generation
-- **@payloadcms/plugin-redirects** - Manage URL redirects (301/302) for Next.js apps
-- **@payloadcms/plugin-nested-docs** - Hierarchical document structures with breadcrumbs
-- **@payloadcms/plugin-form-builder** - Dynamic form builder with submissions and validation
-- **@payloadcms/plugin-search** - Full-text search integration (Algolia support)
-- **@payloadcms/plugin-stripe** - Stripe payments, subscriptions, webhooks
-- **@payloadcms/plugin-ecommerce** - Complete ecommerce solution (products, variants, carts, orders)
-- **@payloadcms/plugin-import-export** - Import/export data via CSV
-- **@payloadcms/plugin-multi-tenant** - Multi-tenancy with tenant isolation
-- **@payloadcms/plugin-sentry** - Sentry error tracking integration
-- **@payloadcms/plugin-mcp** - Model Context Protocol for AI integrations
-
-### Using Plugins
-
-```ts
-import { buildConfig } from 'payload'
-import { seoPlugin } from '@payloadcms/plugin-seo'
-import { redirectsPlugin } from '@payloadcms/plugin-redirects'
-
-export default buildConfig({
- plugins: [
- seoPlugin({
- collections: ['posts', 'pages'],
- }),
- redirectsPlugin({
- collections: ['pages'],
- }),
- ],
- collections: [],
- secret: process.env.PAYLOAD_SECRET || '',
-})
-```
-
-### Creating Plugins
-
-```ts
-import type { Config } from 'payload'
-
-interface PluginOptions {
- enabled?: boolean
-}
-
-export const myPlugin =
- (options: PluginOptions) =>
- (config: Config): Config => ({
- ...config,
- collections: [
- ...(config.collections || []),
- {
- slug: 'plugin-collection',
- fields: [{ name: 'title', type: 'text' }],
- },
- ],
- onInit: async (payload) => {
- if (config.onInit) await config.onInit(payload)
- // Plugin initialization
- },
- })
-```
-
-## Localization
-
-```ts
-import { buildConfig } from 'payload'
-import type { Field, Payload } from 'payload'
-
-export default buildConfig({
- localization: {
- locales: ['en', 'es', 'de'],
- defaultLocale: 'en',
- fallback: true,
- },
- collections: [],
- secret: process.env.PAYLOAD_SECRET || '',
-})
-
-// Localized field
-const localizedField: TextField = {
- name: 'title',
- type: 'text',
- localized: true,
-}
-
-// Query with locale
-const posts = await payload.find({
- collection: 'posts',
- locale: 'es',
-})
-```
-
-## TypeScript Type References
-
-For complete TypeScript type definitions and signatures, reference these files from the Payload source:
-
-### Core Configuration Types
-
-- **[All Commonly-Used Types](https://github.com/payloadcms/payload/blob/main/packages/payload/src/index.ts)** - Check here first for commonly used types and interfaces. All core types are exported from this file.
-
-### Database & Adapters
-
-- **[Database Adapter Types](https://github.com/payloadcms/payload/blob/main/packages/payload/src/database/types.ts)** - Base adapter interface
-- **[MongoDB Adapter](https://github.com/payloadcms/payload/blob/main/packages/db-mongodb/src/index.ts)** - MongoDB-specific options
-- **[Postgres Adapter](https://github.com/payloadcms/payload/blob/main/packages/db-postgres/src/index.ts)** - Postgres-specific options
-
-### Rich Text & Plugins
-
-- **[Lexical Types](https://github.com/payloadcms/payload/blob/main/packages/richtext-lexical/src/exports/server/index.ts)** - Lexical editor configuration
-
-When users need detailed type information, fetch these URLs to provide complete signatures and optional parameters.
diff --git a/.pi/skills/payload/reference/COLLECTIONS.md b/.pi/skills/payload/reference/COLLECTIONS.md
deleted file mode 100644
index e5a582c..0000000
--- a/.pi/skills/payload/reference/COLLECTIONS.md
+++ /dev/null
@@ -1,303 +0,0 @@
-# Payload Collections Reference
-
-Complete reference for collection configurations and patterns.
-
-## Basic Collection
-
-```ts
-import type { CollectionConfig } from 'payload'
-
-export const Posts: CollectionConfig = {
- slug: 'posts',
- labels: {
- singular: 'Post',
- plural: 'Posts',
- },
- admin: {
- useAsTitle: 'title',
- defaultColumns: ['title', 'author', 'status', 'createdAt'],
- group: 'Content', // Organize in admin sidebar
- description: 'Blog posts and articles',
- listSearchableFields: ['title', 'slug'],
- },
- fields: [
- {
- name: 'title',
- type: 'text',
- required: true,
- index: true,
- },
- {
- name: 'slug',
- type: 'text',
- unique: true,
- index: true,
- admin: { position: 'sidebar' },
- },
- {
- name: 'status',
- type: 'select',
- options: ['draft', 'published'],
- defaultValue: 'draft',
- },
- ],
- defaultSort: '-createdAt',
- timestamps: true,
-}
-```
-
-## Auth Collection
-
-```ts
-export const Users: CollectionConfig = {
- slug: 'users',
- auth: {
- tokenExpiration: 7200, // 2 hours
- verify: true,
- maxLoginAttempts: 5,
- lockTime: 600000, // 10 minutes
- useAPIKey: true,
- },
- admin: {
- useAsTitle: 'email',
- },
- fields: [
- {
- name: 'roles',
- type: 'select',
- hasMany: true,
- options: ['admin', 'editor', 'user'],
- required: true,
- defaultValue: ['user'],
- saveToJWT: true,
- },
- {
- name: 'name',
- type: 'text',
- required: true,
- },
- ],
-}
-```
-
-## Upload Collection
-
-```ts
-export const Media: CollectionConfig = {
- slug: 'media',
- upload: {
- staticDir: 'media',
- mimeTypes: ['image/*'],
- imageSizes: [
- {
- name: 'thumbnail',
- width: 400,
- height: 300,
- position: 'centre',
- },
- {
- name: 'card',
- width: 768,
- height: 1024,
- },
- ],
- adminThumbnail: 'thumbnail',
- focalPoint: true,
- crop: true,
- },
- access: {
- read: () => true,
- },
- fields: [
- {
- name: 'alt',
- type: 'text',
- required: true,
- },
- {
- name: 'caption',
- type: 'text',
- localized: true,
- },
- ],
-}
-```
-
-## Live Preview
-
-Enable real-time content preview during editing.
-
-```ts
-import type { CollectionConfig } from 'payload'
-
-const generatePreviewPath = ({
- slug,
- collection,
- req,
-}: {
- slug: string
- collection: string
- req: any
-}) => {
- const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL
- return `${baseUrl}/api/preview?slug=${slug}&collection=${collection}`
-}
-
-export const Pages: CollectionConfig = {
- slug: 'pages',
- admin: {
- useAsTitle: 'title',
- // Live preview during editing
- livePreview: {
- url: ({ data, req }) =>
- generatePreviewPath({
- slug: data?.slug as string,
- collection: 'pages',
- req,
- }),
- },
- // Static preview button
- preview: (data, { req }) =>
- generatePreviewPath({
- slug: data?.slug as string,
- collection: 'pages',
- req,
- }),
- },
- fields: [
- { name: 'title', type: 'text' },
- { name: 'slug', type: 'text' },
- ],
-}
-```
-
-## Versioning & Drafts
-
-Payload maintains version history and supports draft/publish workflows.
-
-```ts
-import type { CollectionConfig } from 'payload'
-
-// Basic versioning (audit log only)
-export const Users: CollectionConfig = {
- slug: 'users',
- versions: true, // or { maxPerDoc: 100 }
- fields: [{ name: 'name', type: 'text' }],
-}
-
-// Drafts enabled (draft/publish workflow)
-export const Posts: CollectionConfig = {
- slug: 'posts',
- versions: {
- drafts: true, // Enables _status field
- maxPerDoc: 50,
- },
- fields: [{ name: 'title', type: 'text' }],
-}
-
-// Full configuration with autosave and scheduled publish
-export const Pages: CollectionConfig = {
- slug: 'pages',
- versions: {
- drafts: {
- autosave: true, // Auto-save while editing
- schedulePublish: true, // Schedule future publish/unpublish
- validate: false, // Don't validate drafts (default)
- },
- maxPerDoc: 100, // Keep last 100 versions (0 = unlimited)
- },
- fields: [{ name: 'title', type: 'text' }],
-}
-```
-
-### Draft API Usage
-
-```ts
-// Create draft
-await payload.create({
- collection: 'posts',
- data: { title: 'Draft Post' },
- draft: true, // Saves as draft, skips required field validation
-})
-
-// Update as draft
-await payload.update({
- collection: 'posts',
- id: '123',
- data: { title: 'Updated Draft' },
- draft: true,
-})
-
-// Read with drafts (returns newest draft if available)
-const post = await payload.findByID({
- collection: 'posts',
- id: '123',
- draft: true, // Returns draft version if exists
-})
-
-// Query only published (REST API)
-// GET /api/posts (returns only _status: 'published')
-
-// Access control for drafts
-export const Posts: CollectionConfig = {
- slug: 'posts',
- versions: { drafts: true },
- access: {
- read: ({ req: { user } }) => {
- // Public can only see published
- if (!user) return { _status: { equals: 'published' } }
- // Authenticated can see all
- return true
- },
- },
- fields: [{ name: 'title', type: 'text' }],
-}
-```
-
-### Document Status
-
-The `_status` field is auto-injected when drafts are enabled:
-
-- `draft` - Never published
-- `published` - Published with no newer drafts
-- `changed` - Published but has newer unpublished drafts
-
-## Globals
-
-Globals are single-instance documents (not collections).
-
-```ts
-import type { GlobalConfig } from 'payload'
-
-export const Header: GlobalConfig = {
- slug: 'header',
- label: 'Header',
- admin: {
- group: 'Settings',
- },
- fields: [
- {
- name: 'logo',
- type: 'upload',
- relationTo: 'media',
- required: true,
- },
- {
- name: 'nav',
- type: 'array',
- maxRows: 8,
- fields: [
- {
- name: 'link',
- type: 'relationship',
- relationTo: 'pages',
- },
- {
- name: 'label',
- type: 'text',
- },
- ],
- },
- ],
-}
-```
diff --git a/.pi/skills/payload/reference/ENDPOINTS.md b/.pi/skills/payload/reference/ENDPOINTS.md
deleted file mode 100644
index 99ef908..0000000
--- a/.pi/skills/payload/reference/ENDPOINTS.md
+++ /dev/null
@@ -1,634 +0,0 @@
-# Payload Custom API Endpoints Reference
-
-Custom REST API endpoints extend Payload's auto-generated CRUD operations with custom logic, authentication flows, webhooks, and integrations.
-
-## Quick Reference
-
-### Endpoint Configuration
-
-| Property | Type | Description |
-| --------- | ------------------------------------------------- | --------------------------------------------------------------- |
-| `path` | `string` | Route path after collection/global slug (e.g., `/:id/tracking`) |
-| `method` | `'get' \| 'post' \| 'put' \| 'patch' \| 'delete'` | HTTP method (lowercase) |
-| `handler` | `(req: PayloadRequest) => Promise` | Async function returning Web API Response |
-| `custom` | `Record` | Extension point for plugins/metadata |
-
-### Request Context
-
-| Property | Type | Description |
-| ----------------- | ----------------------- | ------------------------------------------------------ |
-| `req.user` | `User \| null` | Authenticated user (null if not authenticated) |
-| `req.payload` | `Payload` | Payload instance for operations (find, create...) |
-| `req.routeParams` | `Record` | Path parameters (e.g., `:id`) |
-| `req.url` | `string` | Full request URL |
-| `req.method` | `string` | HTTP method |
-| `req.headers` | `Headers` | Request headers |
-| `req.json()` | `() => Promise` | Parse JSON body |
-| `req.text()` | `() => Promise` | Read body as text |
-| `req.data` | `any` | Parsed body (after `addDataAndFileToRequest()`) |
-| `req.file` | `File` | Uploaded file (after `addDataAndFileToRequest()`) |
-| `req.locale` | `string` | Request locale (after `addLocalesToRequestFromData()`) |
-| `req.i18n` | `I18n` | i18n instance |
-| `req.t` | `TFunction` | Translation function |
-
-## Common Patterns
-
-### Authentication Check
-
-Custom endpoints are **not authenticated by default**. Check `req.user` to enforce authentication.
-
-```ts
-import { APIError } from 'payload'
-
-export const authenticatedEndpoint = {
- path: '/protected',
- method: 'get',
- handler: async (req) => {
- if (!req.user) {
- throw new APIError('Unauthorized', 401)
- }
-
- // User is authenticated
- return Response.json({ message: 'Access granted' })
- },
-}
-```
-
-### Using Payload Operations
-
-Use `req.payload` for database operations with access control and hooks.
-
-```ts
-export const getRelatedPosts = {
- path: '/:id/related',
- method: 'get',
- handler: async (req) => {
- const { id } = req.routeParams
-
- // Find related posts
- const posts = await req.payload.find({
- collection: 'posts',
- where: {
- category: {
- equals: id,
- },
- },
- limit: 5,
- sort: '-createdAt',
- })
-
- return Response.json(posts)
- },
-}
-```
-
-### Route Parameters
-
-Access path parameters via `req.routeParams`.
-
-```ts
-export const getTrackingEndpoint = {
- path: '/:id/tracking',
- method: 'get',
- handler: async (req) => {
- const orderId = req.routeParams.id
-
- const tracking = await getTrackingInfo(orderId)
-
- if (!tracking) {
- return Response.json({ error: 'not found' }, { status: 404 })
- }
-
- return Response.json(tracking)
- },
-}
-```
-
-### Request Body Handling
-
-**Option 1: Manual JSON parsing**
-
-```ts
-export const createEndpoint = {
- path: '/create',
- method: 'post',
- handler: async (req) => {
- const data = await req.json()
-
- const result = await req.payload.create({
- collection: 'posts',
- data,
- })
-
- return Response.json(result)
- },
-}
-```
-
-**Option 2: Using helper (handles JSON + files)**
-
-```ts
-import { addDataAndFileToRequest } from 'payload'
-
-export const uploadEndpoint = {
- path: '/upload',
- method: 'post',
- handler: async (req) => {
- await addDataAndFileToRequest(req)
-
- // req.data now contains parsed body
- // req.file contains uploaded file (if multipart)
-
- const result = await req.payload.create({
- collection: 'media',
- data: req.data,
- file: req.file,
- })
-
- return Response.json(result)
- },
-}
-```
-
-### CORS Headers
-
-Use `headersWithCors` helper to apply config CORS settings.
-
-```ts
-import { headersWithCors } from 'payload'
-
-export const corsEndpoint = {
- path: '/public-data',
- method: 'get',
- handler: async (req) => {
- const data = await fetchPublicData()
-
- return Response.json(data, {
- headers: headersWithCors({
- headers: new Headers(),
- req,
- }),
- })
- },
-}
-```
-
-### Error Handling
-
-Throw `APIError` with status codes for proper error responses.
-
-```ts
-import { APIError } from 'payload'
-
-export const validateEndpoint = {
- path: '/validate',
- method: 'post',
- handler: async (req) => {
- const data = await req.json()
-
- if (!data.email) {
- throw new APIError('Email is required', 400)
- }
-
- // Validation passed
- return Response.json({ valid: true })
- },
-}
-```
-
-### Query Parameters
-
-Extract query params from URL.
-
-```ts
-export const searchEndpoint = {
- path: '/search',
- method: 'get',
- handler: async (req) => {
- const url = new URL(req.url)
- const query = url.searchParams.get('q')
- const limit = parseInt(url.searchParams.get('limit') || '10')
-
- const results = await req.payload.find({
- collection: 'posts',
- where: {
- title: {
- contains: query,
- },
- },
- limit,
- })
-
- return Response.json(results)
- },
-}
-```
-
-## Helper Functions
-
-### addDataAndFileToRequest
-
-Parses request body and attaches to `req.data` and `req.file`.
-
-```ts
-import { addDataAndFileToRequest } from 'payload'
-
-export const endpoint = {
- path: '/process',
- method: 'post',
- handler: async (req) => {
- await addDataAndFileToRequest(req)
-
- // req.data: parsed JSON or form data
- // req.file: uploaded file (if multipart)
-
- console.log(req.data) // { title: 'My Post' }
- console.log(req.file) // File object or undefined
- },
-}
-```
-
-**Handles:**
-
-- JSON bodies (`Content-Type: application/json`)
-- Form data (`Content-Type: multipart/form-data`)
-- File uploads
-
-### addLocalesToRequestFromData
-
-Extracts locale from request data and validates against config.
-
-```ts
-import { addLocalesToRequestFromData } from 'payload'
-
-export const endpoint = {
- path: '/translate',
- method: 'post',
- handler: async (req) => {
- await addLocalesToRequestFromData(req)
-
- // req.locale: validated locale string
- // req.fallbackLocale: fallback locale string
-
- const result = await req.payload.find({
- collection: 'posts',
- locale: req.locale,
- })
-
- return Response.json(result)
- },
-}
-```
-
-### headersWithCors
-
-Applies CORS headers from Payload config.
-
-```ts
-import { headersWithCors } from 'payload'
-
-export const endpoint = {
- path: '/data',
- method: 'get',
- handler: async (req) => {
- const data = { message: 'Hello' }
-
- return Response.json(data, {
- headers: headersWithCors({
- headers: new Headers({
- 'Cache-Control': 'public, max-age=3600',
- }),
- req,
- }),
- })
- },
-}
-```
-
-## Real-World Examples
-
-### Multi-Tenant Login Endpoint
-
-From `examples/multi-tenant`:
-
-```ts
-import { APIError, generatePayloadCookie, headersWithCors } from 'payload'
-
-export const externalUsersLogin = {
- path: '/login-external',
- method: 'post',
- handler: async (req) => {
- const { email, password, tenant } = await req.json()
-
- if (!email || !password || !tenant) {
- throw new APIError('Missing credentials', 400)
- }
-
- // Find user with tenant constraint
- const userQuery = await req.payload.find({
- collection: 'users',
- where: {
- and: [
- { email: { equals: email } },
- {
- or: [{ tenants: { equals: tenant } }, { 'tenants.tenant': { equals: tenant } }],
- },
- ],
- },
- })
-
- if (!userQuery.docs.length) {
- throw new APIError('Invalid credentials', 401)
- }
-
- // Authenticate user
- const result = await req.payload.login({
- collection: 'users',
- data: { email, password },
- })
-
- return Response.json(result, {
- headers: headersWithCors({
- headers: new Headers({
- 'Set-Cookie': generatePayloadCookie({
- collectionAuthConfig: req.payload.config.collections.find((c) => c.slug === 'users')
- .auth,
- cookiePrefix: req.payload.config.cookiePrefix,
- token: result.token,
- }),
- }),
- req,
- }),
- })
- },
-}
-```
-
-### Webhook Handler (Stripe)
-
-From `packages/plugin-ecommerce`:
-
-```ts
-export const webhookEndpoint = {
- path: '/webhooks',
- method: 'post',
- handler: async (req) => {
- const body = await req.text()
- const signature = req.headers.get('stripe-signature')
-
- try {
- const event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
-
- // Process event
- switch (event.type) {
- case 'payment_intent.succeeded':
- await handlePaymentSuccess(req.payload, event.data.object)
- break
- case 'payment_intent.failed':
- await handlePaymentFailure(req.payload, event.data.object)
- break
- }
-
- return Response.json({ received: true })
- } catch (err) {
- req.payload.logger.error(`Webhook error: ${err.message}`)
- return Response.json({ error: err.message }, { status: 400 })
- }
- },
-}
-```
-
-### Data Preview Endpoint
-
-From `packages/plugin-import-export`:
-
-```ts
-import { addDataAndFileToRequest } from 'payload'
-
-export const previewEndpoint = {
- path: '/preview',
- method: 'post',
- handler: async (req) => {
- if (!req.user) {
- throw new APIError('Unauthorized', 401)
- }
-
- await addDataAndFileToRequest(req)
-
- const { collection, where, limit = 10 } = req.data
-
- // Validate collection exists
- const collectionConfig = req.payload.config.collections.find((c) => c.slug === collection)
- if (!collectionConfig) {
- throw new APIError('Collection not found', 404)
- }
-
- // Preview data
- const results = await req.payload.find({
- collection,
- where,
- limit,
- depth: 0,
- })
-
- return Response.json({
- docs: results.docs,
- totalDocs: results.totalDocs,
- fields: collectionConfig.fields,
- })
- },
-}
-```
-
-### Reindex Action Endpoint
-
-From `packages/plugin-search`:
-
-```ts
-export const reindexEndpoint = (pluginConfig) => ({
- path: '/reindex',
- method: 'post',
- handler: async (req) => {
- if (!req.user) {
- throw new APIError('Unauthorized', 401)
- }
-
- const { collection } = req.routeParams
-
- // Reindex collection
- const result = await reindexCollection(req.payload, collection, pluginConfig)
-
- return Response.json({
- message: `Reindexed ${result.count} documents`,
- count: result.count,
- })
- },
-})
-```
-
-## Endpoint Placement
-
-### Collection Endpoints
-
-Mounted at `/api/{collection-slug}/{path}`.
-
-```ts
-import type { CollectionConfig } from 'payload'
-
-export const Orders: CollectionConfig = {
- slug: 'orders',
- fields: [
- /* ... */
- ],
- endpoints: [
- {
- path: '/:id/tracking',
- method: 'get',
- handler: async (req) => {
- // Available at: /api/orders/:id/tracking
- const orderId = req.routeParams.id
- return Response.json({ orderId })
- },
- },
- ],
-}
-```
-
-### Global Endpoints
-
-Mounted at `/api/globals/{global-slug}/{path}`.
-
-```ts
-import type { GlobalConfig } from 'payload'
-
-export const Settings: GlobalConfig = {
- slug: 'settings',
- fields: [
- /* ... */
- ],
- endpoints: [
- {
- path: '/clear-cache',
- method: 'post',
- handler: async (req) => {
- // Available at: /api/globals/settings/clear-cache
- await clearCache()
- return Response.json({ message: 'Cache cleared' })
- },
- },
- ],
-}
-```
-
-## Advanced Patterns
-
-### Factory Functions
-
-Create reusable endpoint factories for plugins.
-
-```ts
-export const createWebhookEndpoint = (config) => ({
- path: '/webhook',
- method: 'post',
- handler: async (req) => {
- const signature = req.headers.get('x-webhook-signature')
-
- if (!verifySignature(signature, config.secret)) {
- throw new APIError('Invalid signature', 401)
- }
-
- const data = await req.json()
- await processWebhook(req.payload, data, config)
-
- return Response.json({ received: true })
- },
-})
-```
-
-### Conditional Endpoints
-
-Add endpoints based on config options.
-
-```ts
-export const MyCollection: CollectionConfig = {
- slug: 'posts',
- fields: [
- /* ... */
- ],
- endpoints: [
- // Always included
- {
- path: '/public',
- method: 'get',
- handler: async (req) => Response.json({ data: [] }),
- },
- // Conditionally included
- ...(process.env.ENABLE_ANALYTICS
- ? [
- {
- path: '/analytics',
- method: 'get',
- handler: async (req) => Response.json({ analytics: [] }),
- },
- ]
- : []),
- ],
-}
-```
-
-### OpenAPI Documentation
-
-Use `custom` property for API documentation metadata.
-
-```ts
-export const endpoint = {
- path: '/search',
- method: 'get',
- handler: async (req) => {
- // Handler implementation
- },
- custom: {
- openapi: {
- summary: 'Search posts',
- parameters: [
- {
- name: 'q',
- in: 'query',
- required: true,
- schema: { type: 'string' },
- },
- ],
- responses: {
- 200: {
- description: 'Search results',
- content: {
- 'application/json': {
- schema: { type: 'array' },
- },
- },
- },
- },
- },
- },
-}
-```
-
-## Best Practices
-
-1. **Always check authentication** - Custom endpoints are not authenticated by default
-2. **Use `req.payload` for operations** - Ensures access control and hooks execute
-3. **Use helpers for common tasks** - `addDataAndFileToRequest`, `headersWithCors`, etc.
-4. **Throw `APIError` for errors** - Provides consistent error responses
-5. **Return Web API `Response`** - Use `Response.json()` for consistent responses
-6. **Validate input** - Check required fields, validate types
-7. **Handle CORS** - Use `headersWithCors` for cross-origin requests
-8. **Log errors** - Use `req.payload.logger` for debugging
-9. **Document with `custom`** - Add OpenAPI metadata for API docs
-10. **Factory pattern for reuse** - Create endpoint factories for plugins
-
-## Resources
-
-- REST API Overview:
-- Custom Endpoints:
-- Access Control:
-- Local API:
diff --git a/.pi/skills/payload/reference/FIELD-TYPE-GUARDS.md b/.pi/skills/payload/reference/FIELD-TYPE-GUARDS.md
deleted file mode 100644
index 59ec938..0000000
--- a/.pi/skills/payload/reference/FIELD-TYPE-GUARDS.md
+++ /dev/null
@@ -1,553 +0,0 @@
-# Payload Field Type Guards Reference
-
-Complete reference with detailed examples and patterns. See [FIELDS.md](FIELDS.md#field-type-guards) for quick reference table of all guards.
-
-## Structural Guards
-
-### fieldHasSubFields
-
-Checks if field contains nested fields (group, array, row, or collapsible).
-
-```ts
-import type { Field } from 'payload'
-import { fieldHasSubFields } from 'payload'
-
-function traverseFields(fields: Field[]): void {
- fields.forEach((field) => {
- if (fieldHasSubFields(field)) {
- // Safe to access field.fields
- traverseFields(field.fields)
- }
- })
-}
-```
-
-**Signature:**
-
-```ts
-fieldHasSubFields(
- field: TField
-): field is TField & (FieldWithSubFieldsClient | FieldWithSubFields)
-```
-
-**Common Pattern - Exclude Arrays:**
-
-```ts
-if (fieldHasSubFields(field) && !fieldIsArrayType(field)) {
- // Groups, rows, collapsibles only (not arrays)
-}
-```
-
-### fieldIsArrayType
-
-Checks if field type is `'array'`.
-
-```ts
-import { fieldIsArrayType } from 'payload'
-
-if (fieldIsArrayType(field)) {
- // field.type === 'array'
- console.log(`Min rows: ${field.minRows}`)
- console.log(`Max rows: ${field.maxRows}`)
-}
-```
-
-**Signature:**
-
-```ts
-fieldIsArrayType(
- field: TField
-): field is TField & (ArrayFieldClient | ArrayField)
-```
-
-### fieldIsBlockType
-
-Checks if field type is `'blocks'`.
-
-```ts
-import { fieldIsBlockType } from 'payload'
-
-if (fieldIsBlockType(field)) {
- // field.type === 'blocks'
- field.blocks.forEach((block) => {
- console.log(`Block: ${block.slug}`)
- })
-}
-```
-
-**Signature:**
-
-```ts
-fieldIsBlockType(
- field: TField
-): field is TField & (BlocksFieldClient | BlocksField)
-```
-
-**Common Pattern - Distinguish Containers:**
-
-```ts
-if (fieldIsArrayType(field)) {
- // Handle array rows
-} else if (fieldIsBlockType(field)) {
- // Handle block types
-}
-```
-
-### fieldIsGroupType
-
-Checks if field type is `'group'`.
-
-```ts
-import { fieldIsGroupType } from 'payload'
-
-if (fieldIsGroupType(field)) {
- // field.type === 'group'
- console.log(`Interface: ${field.interfaceName}`)
-}
-```
-
-**Signature:**
-
-```ts
-fieldIsGroupType(
- field: TField
-): field is TField & (GroupFieldClient | GroupField)
-```
-
-## Capability Guards
-
-### fieldSupportsMany
-
-Checks if field can have multiple values (select, relationship, or upload with `hasMany`).
-
-```ts
-import { fieldSupportsMany } from 'payload'
-
-if (fieldSupportsMany(field)) {
- // field.type is 'select' | 'relationship' | 'upload'
- // Safe to check field.hasMany
- if (field.hasMany) {
- console.log('Field accepts multiple values')
- }
-}
-```
-
-**Signature:**
-
-```ts
-fieldSupportsMany(
- field: TField
-): field is TField & (FieldWithManyClient | FieldWithMany)
-```
-
-### fieldHasMaxDepth
-
-Checks if field is relationship/upload/join with numeric `maxDepth` property.
-
-```ts
-import { fieldHasMaxDepth } from 'payload'
-
-if (fieldHasMaxDepth(field)) {
- // field.type is 'upload' | 'relationship' | 'join'
- // AND field.maxDepth is number
- const remainingDepth = field.maxDepth - currentDepth
-}
-```
-
-**Signature:**
-
-```ts
-fieldHasMaxDepth(
- field: TField
-): field is TField & (FieldWithMaxDepthClient | FieldWithMaxDepth)
-```
-
-### fieldShouldBeLocalized
-
-Checks if field needs localization handling (accounts for parent localization).
-
-```ts
-import { fieldShouldBeLocalized } from 'payload'
-
-function processField(field: Field, parentIsLocalized: boolean) {
- if (fieldShouldBeLocalized({ field, parentIsLocalized })) {
- // Create locale-specific table or index
- }
-}
-```
-
-**Signature:**
-
-```ts
-fieldShouldBeLocalized({
- field,
- parentIsLocalized,
-}: {
- field: ClientField | ClientTab | Field | Tab
- parentIsLocalized: boolean
-}): boolean
-```
-
-```ts
-// Accounts for parent localization
-if (fieldShouldBeLocalized({ field, parentIsLocalized: false })) {
- /* ... */
-}
-```
-
-### fieldIsVirtual
-
-Checks if field is virtual (computed or virtual relationship).
-
-```ts
-import { fieldIsVirtual } from 'payload'
-
-if (fieldIsVirtual(field)) {
- // field.virtual is truthy
- if (typeof field.virtual === 'string') {
- // Virtual relationship path
- console.log(`Virtual path: ${field.virtual}`)
- } else {
- // Computed virtual field (uses hooks)
- }
-}
-```
-
-**Signature:**
-
-```ts
-fieldIsVirtual(field: Field | Tab): boolean
-```
-
-## Data Guards
-
-### fieldAffectsData
-
-**Most commonly used guard.** Checks if field stores data (has name and is not UI-only).
-
-```ts
-import { fieldAffectsData } from 'payload'
-
-function generateSchema(fields: Field[]) {
- fields.forEach((field) => {
- if (fieldAffectsData(field)) {
- // Safe to access field.name
- schema[field.name] = getFieldType(field)
- }
- })
-}
-```
-
-**Signature:**
-
-```ts
-fieldAffectsData(
- field: TField
-): field is TField & (FieldAffectingDataClient | FieldAffectingData)
-```
-
-**Pattern - Data Fields Only:**
-
-```ts
-const dataFields = fields.filter(fieldAffectsData)
-```
-
-### fieldIsPresentationalOnly
-
-Checks if field is UI-only (type `'ui'`).
-
-```ts
-import { fieldIsPresentationalOnly } from 'payload'
-
-if (fieldIsPresentationalOnly(field)) {
- // field.type === 'ui'
- // Skip in data operations, GraphQL schema, etc.
- return
-}
-```
-
-**Signature:**
-
-```ts
-fieldIsPresentationalOnly(
- field: TField
-): field is TField & (UIFieldClient | UIField)
-```
-
-### fieldIsID
-
-Checks if field name is exactly `'id'`.
-
-```ts
-import { fieldIsID } from 'payload'
-
-if (fieldIsID(field)) {
- // field.name === 'id'
- // Special handling for ID field
-}
-```
-
-**Signature:**
-
-```ts
-fieldIsID(
- field: TField
-): field is { name: 'id' } & TField
-```
-
-### fieldIsHiddenOrDisabled
-
-Checks if field is hidden or admin-disabled.
-
-```ts
-import { fieldIsHiddenOrDisabled } from 'payload'
-
-const visibleFields = fields.filter((field) => !fieldIsHiddenOrDisabled(field))
-```
-
-**Signature:**
-
-```ts
-fieldIsHiddenOrDisabled(
- field: TField
-): field is { admin: { hidden: true } } & TField
-```
-
-## Layout Guards
-
-### fieldIsSidebar
-
-Checks if field is positioned in sidebar.
-
-```ts
-import { fieldIsSidebar } from 'payload'
-
-const [mainFields, sidebarFields] = fields.reduce(
- ([main, sidebar], field) => {
- if (fieldIsSidebar(field)) {
- return [main, [...sidebar, field]]
- }
- return [[...main, field], sidebar]
- },
- [[], []],
-)
-```
-
-**Signature:**
-
-```ts
-fieldIsSidebar(
- field: TField
-): field is { admin: { position: 'sidebar' } } & TField
-```
-
-## Tab & Group Guards
-
-### tabHasName
-
-Checks if tab is named (stores data under tab name).
-
-```ts
-import { tabHasName } from 'payload'
-
-tabs.forEach((tab) => {
- if (tabHasName(tab)) {
- // tab.name exists
- dataPath.push(tab.name)
- }
- // Process tab.fields
-})
-```
-
-**Signature:**
-
-```ts
-tabHasName(
- tab: TField
-): tab is NamedTab & TField
-```
-
-### groupHasName
-
-Checks if group is named (stores data under group name).
-
-```ts
-import { groupHasName } from 'payload'
-
-if (groupHasName(group)) {
- // group.name exists
- return data[group.name]
-}
-```
-
-**Signature:**
-
-```ts
-groupHasName(group: Partial): group is NamedGroupFieldClient
-```
-
-## Option & Value Guards
-
-### optionIsObject
-
-Checks if option is object format `{label, value}` vs string.
-
-```ts
-import { optionIsObject } from 'payload'
-
-field.options.forEach((option) => {
- if (optionIsObject(option)) {
- console.log(`${option.label}: ${option.value}`)
- } else {
- console.log(option) // string value
- }
-})
-```
-
-**Signature:**
-
-```ts
-optionIsObject(option: Option): option is OptionObject
-```
-
-### optionsAreObjects
-
-Checks if entire options array contains objects.
-
-```ts
-import { optionsAreObjects } from 'payload'
-
-if (optionsAreObjects(field.options)) {
- // All options are OptionObject[]
- const labels = field.options.map((opt) => opt.label)
-}
-```
-
-**Signature:**
-
-```ts
-optionsAreObjects(options: Option[]): options is OptionObject[]
-```
-
-### optionIsValue
-
-Checks if option is string value (not object).
-
-```ts
-import { optionIsValue } from 'payload'
-
-if (optionIsValue(option)) {
- // option is string
- const value = option
-}
-```
-
-**Signature:**
-
-```ts
-optionIsValue(option: Option): option is string
-```
-
-### valueIsValueWithRelation
-
-Checks if relationship value is polymorphic format `{relationTo, value}`.
-
-```ts
-import { valueIsValueWithRelation } from 'payload'
-
-if (valueIsValueWithRelation(fieldValue)) {
- // fieldValue.relationTo exists
- // fieldValue.value exists
- console.log(`Related to ${fieldValue.relationTo}: ${fieldValue.value}`)
-}
-```
-
-**Signature:**
-
-```ts
-valueIsValueWithRelation(value: unknown): value is ValueWithRelation
-```
-
-## Common Patterns
-
-### Recursive Field Traversal
-
-```ts
-import { fieldAffectsData, fieldHasSubFields } from 'payload'
-
-function traverseFields(fields: Field[], callback: (field: Field) => void) {
- fields.forEach((field) => {
- if (fieldAffectsData(field)) {
- callback(field)
- }
-
- if (fieldHasSubFields(field)) {
- traverseFields(field.fields, callback)
- }
- })
-}
-```
-
-### Filter Data-Bearing Fields
-
-```ts
-import { fieldAffectsData, fieldIsPresentationalOnly, fieldIsHiddenOrDisabled } from 'payload'
-
-const dataFields = fields.filter(
- (field) =>
- fieldAffectsData(field) && !fieldIsPresentationalOnly(field) && !fieldIsHiddenOrDisabled(field),
-)
-```
-
-### Container Type Switching
-
-```ts
-import { fieldIsArrayType, fieldIsBlockType, fieldHasSubFields } from 'payload'
-
-if (fieldIsArrayType(field)) {
- // Handle array-specific logic
-} else if (fieldIsBlockType(field)) {
- // Handle blocks-specific logic
-} else if (fieldHasSubFields(field)) {
- // Handle group/row/collapsible
-}
-```
-
-### Safe Property Access
-
-```ts
-import { fieldSupportsMany, fieldHasMaxDepth } from 'payload'
-
-// Without guard - TypeScript error
-// if (field.hasMany) { /* ... */ }
-
-// With guard - safe access
-if (fieldSupportsMany(field) && field.hasMany) {
- console.log('Multiple values supported')
-}
-
-if (fieldHasMaxDepth(field)) {
- const depth = field.maxDepth // TypeScript knows this is number
-}
-```
-
-## Type Preservation
-
-All guards preserve the original type constraint:
-
-```ts
-import type { ClientField, Field } from 'payload'
-import { fieldHasSubFields } from 'payload'
-
-function processServerField(field: Field) {
- if (fieldHasSubFields(field)) {
- // field is Field & FieldWithSubFields (not ClientField)
- }
-}
-
-function processClientField(field: ClientField) {
- if (fieldHasSubFields(field)) {
- // field is ClientField & FieldWithSubFieldsClient
- }
-}
-```
diff --git a/.pi/skills/payload/reference/FIELDS.md b/.pi/skills/payload/reference/FIELDS.md
deleted file mode 100644
index 997db7d..0000000
--- a/.pi/skills/payload/reference/FIELDS.md
+++ /dev/null
@@ -1,744 +0,0 @@
-# Payload Field Types Reference
-
-Complete reference for all Payload field types with examples.
-
-## Text Field
-
-```ts
-import type { TextField } from 'payload'
-
-const textField: TextField = {
- name: 'title',
- type: 'text',
- required: true,
- unique: true,
- minLength: 5,
- maxLength: 100,
- index: true,
- localized: true,
- defaultValue: 'Default Title',
- validate: (value) => Boolean(value) || 'Required',
- admin: {
- placeholder: 'Enter title...',
- position: 'sidebar',
- condition: (data) => data.showTitle === true,
- },
-}
-```
-
-### Slug Field Helper
-
-Built-in helper for auto-generating slugs:
-
-```ts
-import { slugField } from 'payload'
-import type { CollectionConfig } from 'payload'
-
-export const Pages: CollectionConfig = {
- slug: 'pages',
- fields: [
- { name: 'title', type: 'text', required: true },
- slugField({
- name: 'slug', // defaults to 'slug'
- useAsSlug: 'title', // defaults to 'title'
- checkboxName: 'generateSlug', // defaults to 'generateSlug'
- localized: true,
- required: true,
- overrides: (defaultField) => {
- // Customize the generated fields if needed
- return defaultField
- },
- }),
- ],
-}
-```
-
-## Rich Text (Lexical)
-
-```ts
-import type { RichTextField } from 'payload'
-import { lexicalEditor } from '@payloadcms/richtext-lexical'
-import { HeadingFeature, LinkFeature } from '@payloadcms/richtext-lexical'
-
-const richTextField: RichTextField = {
- name: 'content',
- type: 'richText',
- required: true,
- localized: true,
- editor: lexicalEditor({
- features: ({ defaultFeatures }) => [
- ...defaultFeatures,
- HeadingFeature({
- enabledHeadingSizes: ['h1', 'h2', 'h3'],
- }),
- LinkFeature({
- enabledCollections: ['posts', 'pages'],
- }),
- ],
- }),
-}
-```
-
-### Advanced Lexical Configuration
-
-```ts
-import {
- BoldFeature,
- EXPERIMENTAL_TableFeature,
- FixedToolbarFeature,
- HeadingFeature,
- IndentFeature,
- InlineToolbarFeature,
- ItalicFeature,
- LinkFeature,
- OrderedListFeature,
- UnderlineFeature,
- UnorderedListFeature,
- lexicalEditor,
-} from '@payloadcms/richtext-lexical'
-
-// Global editor config with full features
-export default buildConfig({
- editor: lexicalEditor({
- features: () => {
- return [
- UnderlineFeature(),
- BoldFeature(),
- ItalicFeature(),
- OrderedListFeature(),
- UnorderedListFeature(),
- LinkFeature({
- enabledCollections: ['pages'],
- fields: ({ defaultFields }) => {
- const defaultFieldsWithoutUrl = defaultFields.filter((field) => {
- if ('name' in field && field.name === 'url') return false
- return true
- })
-
- return [
- ...defaultFieldsWithoutUrl,
- {
- name: 'url',
- type: 'text',
- admin: {
- condition: ({ linkType }) => linkType !== 'internal',
- },
- label: ({ t }) => t('fields:enterURL'),
- required: true,
- },
- ]
- },
- }),
- IndentFeature(),
- EXPERIMENTAL_TableFeature(),
- ]
- },
- }),
-})
-
-// Field-specific editor with custom toolbar
-const richTextWithToolbars: RichTextField = {
- name: 'richText',
- type: 'richText',
- editor: lexicalEditor({
- features: ({ rootFeatures }) => {
- return [
- ...rootFeatures,
- HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
- FixedToolbarFeature(),
- InlineToolbarFeature(),
- ]
- },
- }),
- label: false,
-}
-```
-
-## Relationship
-
-```ts
-import type { RelationshipField } from 'payload'
-
-// Single relationship
-const singleRelationship: RelationshipField = {
- name: 'author',
- type: 'relationship',
- relationTo: 'users',
- required: true,
- maxDepth: 2,
-}
-
-// Multiple relationships (hasMany)
-const multipleRelationship: RelationshipField = {
- name: 'categories',
- type: 'relationship',
- relationTo: 'categories',
- hasMany: true,
- filterOptions: {
- active: { equals: true },
- },
-}
-
-// Polymorphic relationship
-const polymorphicRelationship: PolymorphicRelationshipField = {
- name: 'relatedContent',
- type: 'relationship',
- relationTo: ['posts', 'pages'],
- hasMany: true,
-}
-```
-
-## Array
-
-```ts
-import type { ArrayField } from 'payload'
-
-const arrayField: ArrayField = {
- name: 'slides',
- type: 'array',
- minRows: 2,
- maxRows: 10,
- labels: {
- singular: 'Slide',
- plural: 'Slides',
- },
- fields: [
- {
- name: 'title',
- type: 'text',
- required: true,
- },
- {
- name: 'image',
- type: 'upload',
- relationTo: 'media',
- },
- ],
- admin: {
- initCollapsed: true,
- },
-}
-```
-
-## Blocks
-
-```ts
-import type { BlocksField, Block } from 'payload'
-
-const HeroBlock: Block = {
- slug: 'hero',
- interfaceName: 'HeroBlock',
- fields: [
- {
- name: 'heading',
- type: 'text',
- required: true,
- },
- {
- name: 'background',
- type: 'upload',
- relationTo: 'media',
- },
- ],
-}
-
-const ContentBlock: Block = {
- slug: 'content',
- fields: [
- {
- name: 'text',
- type: 'richText',
- },
- ],
-}
-
-const blocksField: BlocksField = {
- name: 'layout',
- type: 'blocks',
- blocks: [HeroBlock, ContentBlock],
-}
-```
-
-## Select
-
-```ts
-import type { SelectField } from 'payload'
-
-const selectField: SelectField = {
- name: 'status',
- type: 'select',
- options: [
- { label: 'Draft', value: 'draft' },
- { label: 'Published', value: 'published' },
- ],
- defaultValue: 'draft',
- required: true,
-}
-
-// Multiple select
-const multiSelectField: SelectField = {
- name: 'tags',
- type: 'select',
- hasMany: true,
- options: ['tech', 'news', 'sports'],
-}
-```
-
-## Upload
-
-```ts
-import type { UploadField } from 'payload'
-
-const uploadField: UploadField = {
- name: 'featuredImage',
- type: 'upload',
- relationTo: 'media',
- required: true,
- filterOptions: {
- mimeType: { contains: 'image' },
- },
-}
-```
-
-## Point (Geolocation)
-
-Point fields store geographic coordinates with automatic 2dsphere indexing for geospatial queries.
-
-```ts
-import type { PointField } from 'payload'
-
-const locationField: PointField = {
- name: 'location',
- type: 'point',
- label: 'Location',
- required: true,
-}
-
-// Returns [longitude, latitude]
-// Example: [-122.4194, 37.7749] for San Francisco
-```
-
-### Geospatial Queries
-
-```ts
-// Query by distance (sorted by nearest first)
-const nearbyLocations = await payload.find({
- collection: 'stores',
- where: {
- location: {
- near: [10, 20], // [longitude, latitude]
- maxDistance: 5000, // in meters
- minDistance: 1000,
- },
- },
-})
-
-// Query within polygon area
-const polygon: Point[] = [
- [9.0, 19.0], // bottom-left
- [9.0, 21.0], // top-left
- [11.0, 21.0], // top-right
- [11.0, 19.0], // bottom-right
- [9.0, 19.0], // closing point
-]
-
-const withinArea = await payload.find({
- collection: 'stores',
- where: {
- location: {
- within: {
- type: 'Polygon',
- coordinates: [polygon],
- },
- },
- },
-})
-
-// Query intersecting area
-const intersecting = await payload.find({
- collection: 'stores',
- where: {
- location: {
- intersects: {
- type: 'Polygon',
- coordinates: [polygon],
- },
- },
- },
-})
-```
-
-**Note**: Point fields are not supported in SQLite.
-
-## Join Fields
-
-Join fields create reverse relationships, allowing you to access related documents from the "other side" of a relationship.
-
-```ts
-import type { JoinField } from 'payload'
-
-// From Users collection - show user's orders
-const ordersJoinField: JoinField = {
- name: 'orders',
- type: 'join',
- collection: 'orders',
- on: 'customer', // The field in 'orders' that references this user
- admin: {
- allowCreate: false,
- defaultColumns: ['id', 'createdAt', 'total', 'currency', 'items'],
- },
-}
-
-// From Users collection - show user's cart
-const cartJoinField: JoinField = {
- name: 'cart',
- type: 'join',
- collection: 'carts',
- on: 'customer',
- admin: {
- allowCreate: false,
- defaultColumns: ['id', 'createdAt', 'total', 'currency'],
- },
-}
-```
-
-## Virtual Fields
-
-```ts
-import type { TextField } from 'payload'
-
-// Computed from siblings
-const computedVirtualField: TextField = {
- name: 'fullName',
- type: 'text',
- virtual: true,
- hooks: {
- afterRead: [({ siblingData }) => `${siblingData.firstName} ${siblingData.lastName}`],
- },
-}
-
-// From relationship path
-const pathVirtualField: TextField = {
- name: 'authorName',
- type: 'text',
- virtual: 'author.name',
-}
-```
-
-## Conditional Fields
-
-```ts
-import type { UploadField, CheckboxField } from 'payload'
-
-// Simple boolean condition
-const enableFeatureField: CheckboxField = {
- name: 'enableFeature',
- type: 'checkbox',
-}
-
-const conditionalField: TextField = {
- name: 'featureText',
- type: 'text',
- admin: {
- condition: (data) => data.enableFeature === true,
- },
-}
-
-// Sibling data condition (from hero field pattern)
-const typeField: SelectField = {
- name: 'type',
- type: 'select',
- options: ['none', 'highImpact', 'mediumImpact', 'lowImpact'],
- defaultValue: 'lowImpact',
-}
-
-const mediaField: UploadField = {
- name: 'media',
- type: 'upload',
- relationTo: 'media',
- admin: {
- condition: (_, { type } = {}) => ['highImpact', 'mediumImpact'].includes(type),
- },
- required: true,
-}
-```
-
-## Radio
-
-Radio fields present options as radio buttons for single selection.
-
-```ts
-import type { RadioField } from 'payload'
-
-const radioField: RadioField = {
- name: 'priority',
- type: 'radio',
- options: [
- { label: 'Low', value: 'low' },
- { label: 'Medium', value: 'medium' },
- { label: 'High', value: 'high' },
- ],
- defaultValue: 'medium',
- admin: {
- layout: 'horizontal', // or 'vertical'
- },
-}
-```
-
-## Row (Layout)
-
-Row fields arrange fields horizontally in the admin panel (presentational only).
-
-```ts
-import type { RowField } from 'payload'
-
-const rowField: RowField = {
- type: 'row',
- fields: [
- {
- name: 'firstName',
- type: 'text',
- admin: { width: '50%' },
- },
- {
- name: 'lastName',
- type: 'text',
- admin: { width: '50%' },
- },
- ],
-}
-```
-
-## Collapsible (Layout)
-
-Collapsible fields group fields in an expandable/collapsible section.
-
-```ts
-import type { CollapsibleField } from 'payload'
-
-const collapsibleField: CollapsibleField = {
- label: ({ data }) => data?.title || 'Advanced Options',
- type: 'collapsible',
- admin: {
- initCollapsed: true,
- },
- fields: [
- { name: 'customCSS', type: 'textarea' },
- { name: 'customJS', type: 'code' },
- ],
-}
-```
-
-## UI (Custom Components)
-
-UI fields allow fully custom React components in the admin (no data stored).
-
-```ts
-import type { UIField } from 'payload'
-
-const uiField: UIField = {
- name: 'customMessage',
- type: 'ui',
- admin: {
- components: {
- Field: '/path/to/CustomFieldComponent',
- Cell: '/path/to/CustomCellComponent', // For list view
- },
- },
-}
-```
-
-## Tabs & Groups
-
-```ts
-import type { TabsField, GroupField } from 'payload'
-
-// Tabs
-const tabsField: TabsField = {
- type: 'tabs',
- tabs: [
- {
- label: 'Content',
- fields: [
- { name: 'title', type: 'text' },
- { name: 'body', type: 'richText' },
- ],
- },
- {
- label: 'SEO',
- fields: [
- { name: 'metaTitle', type: 'text' },
- { name: 'metaDescription', type: 'textarea' },
- ],
- },
- ],
-}
-
-// Group (named)
-const groupField: GroupField = {
- name: 'meta',
- type: 'group',
- fields: [
- { name: 'title', type: 'text' },
- { name: 'description', type: 'textarea' },
- ],
-}
-```
-
-## Reusable Field Factories
-
-Create composable field patterns that can be customized with overrides.
-
-```ts
-import type { Field, GroupField } from 'payload'
-
-// Utility for deep merging
-const deepMerge = (target: T, source: Partial): T => {
- // Implementation would deeply merge objects
- return { ...target, ...source }
-}
-
-// Reusable link field factory
-type LinkType = (options?: {
- appearances?: ('default' | 'outline')[] | false
- disableLabel?: boolean
- overrides?: Record
-}) => GroupField
-
-export const link: LinkType = ({ appearances, disableLabel = false, overrides = {} } = {}) => {
- const linkField: GroupField = {
- name: 'link',
- type: 'group',
- admin: {
- hideGutter: true,
- },
- fields: [
- {
- type: 'row',
- fields: [
- {
- name: 'type',
- type: 'radio',
- options: [
- { label: 'Internal link', value: 'reference' },
- { label: 'Custom URL', value: 'custom' },
- ],
- defaultValue: 'reference',
- admin: {
- layout: 'horizontal',
- width: '50%',
- },
- },
- {
- name: 'newTab',
- type: 'checkbox',
- label: 'Open in new tab',
- admin: {
- width: '50%',
- style: {
- alignSelf: 'flex-end',
- },
- },
- },
- ],
- },
- {
- name: 'reference',
- type: 'relationship',
- relationTo: ['pages'],
- required: true,
- maxDepth: 1,
- admin: {
- condition: (_, siblingData) => siblingData?.type === 'reference',
- },
- },
- {
- name: 'url',
- type: 'text',
- label: 'Custom URL',
- required: true,
- admin: {
- condition: (_, siblingData) => siblingData?.type === 'custom',
- },
- },
- ],
- }
-
- if (!disableLabel) {
- linkField.fields.push({
- name: 'label',
- type: 'text',
- required: true,
- })
- }
-
- if (appearances !== false) {
- linkField.fields.push({
- name: 'appearance',
- type: 'select',
- defaultValue: 'default',
- options: [
- { label: 'Default', value: 'default' },
- { label: 'Outline', value: 'outline' },
- ],
- })
- }
-
- return deepMerge(linkField, overrides) as GroupField
-}
-
-// Usage
-const navItem = link({ appearances: false })
-const ctaButton = link({
- overrides: {
- name: 'cta',
- admin: {
- description: 'Call to action button',
- },
- },
-})
-```
-
-## Field Type Guards
-
-Type guards for runtime field type checking and safe type narrowing.
-
-| Type Guard | Checks For | Use When |
-| --------------------------- | ----------------------------------------------------------- | ---------------------------------------- |
-| `fieldAffectsData` | Field stores data (has name, not UI-only) | Need to access field data or name |
-| `fieldHasSubFields` | Field contains nested fields (group/array/row/collapsible) | Need to recursively traverse fields |
-| `fieldIsArrayType` | Field is array type | Distinguish arrays from other containers |
-| `fieldIsBlockType` | Field is blocks type | Handle blocks-specific logic |
-| `fieldIsGroupType` | Field is group type | Handle group-specific logic |
-| `fieldSupportsMany` | Field can have multiple values (select/relationship/upload) | Check for `hasMany` support |
-| `fieldHasMaxDepth` | Field supports population depth control | Control relationship/upload/join depth |
-| `fieldIsPresentationalOnly` | Field is UI-only (no data storage) | Exclude from data operations |
-| `fieldIsSidebar` | Field positioned in sidebar | Separate sidebar rendering |
-| `fieldIsID` | Field name is 'id' | Special ID field handling |
-| `fieldIsHiddenOrDisabled` | Field is hidden or disabled | Filter from UI operations |
-| `fieldShouldBeLocalized` | Field needs localization handling | Proper locale table checks |
-| `fieldIsVirtual` | Field is virtual (computed/no DB column) | Skip in database transforms |
-| `tabHasName` | Tab is named (stores data) | Distinguish named vs unnamed tabs |
-| `groupHasName` | Group is named (stores data) | Distinguish named vs unnamed groups |
-| `optionIsObject` | Option is `{label, value}` format | Access option properties safely |
-| `optionsAreObjects` | All options are objects | Batch option processing |
-| `optionIsValue` | Option is string value | Handle string options |
-| `valueIsValueWithRelation` | Value is polymorphic relationship | Handle polymorphic relationships |
-
-```ts
-import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType } from 'payload'
-
-function processField(field: Field) {
- if (fieldAffectsData(field)) {
- // Safe to access field.name
- console.log(field.name)
- }
-
- if (fieldHasSubFields(field)) {
- // Safe to access field.fields
- field.fields.forEach(processField)
- }
-}
-```
-
-See [FIELD-TYPE-GUARDS.md](FIELD-TYPE-GUARDS.md) for detailed usage patterns.
diff --git a/.pi/skills/payload/reference/HOOKS.md b/.pi/skills/payload/reference/HOOKS.md
deleted file mode 100644
index b67556e..0000000
--- a/.pi/skills/payload/reference/HOOKS.md
+++ /dev/null
@@ -1,186 +0,0 @@
-# Payload Hooks Reference
-
-Complete reference for collection hooks, field hooks, and hook context patterns.
-
-## Collection Hooks
-
-```ts
-export const Posts: CollectionConfig = {
- slug: 'posts',
- hooks: {
- // Before validation
- beforeValidate: [
- async ({ data, operation }) => {
- if (operation === 'create') {
- data.slug = slugify(data.title)
- }
- return data
- },
- ],
-
- // Before save
- beforeChange: [
- async ({ data, req, operation, originalDoc }) => {
- if (operation === 'update' && data.status === 'published') {
- data.publishedAt = new Date()
- }
- return data
- },
- ],
-
- // After save
- afterChange: [
- async ({ doc, req, operation, previousDoc }) => {
- if (operation === 'create') {
- await sendNotification(doc)
- }
- return doc
- },
- ],
-
- // After read
- afterRead: [
- async ({ doc, req }) => {
- doc.viewCount = await getViewCount(doc.id)
- return doc
- },
- ],
-
- // Before delete
- beforeDelete: [
- async ({ req, id }) => {
- await cleanupRelatedData(id)
- },
- ],
- },
-}
-```
-
-## Field Hooks
-
-```ts
-import type { EmailField, FieldHook } from 'payload'
-
-const beforeValidateHook: FieldHook = ({ value }) => {
- return value.trim().toLowerCase()
-}
-
-const afterReadHook: FieldHook = ({ value, req }) => {
- // Hide email from non-admins
- if (!req.user?.roles?.includes('admin')) {
- return value.replace(/(.{2})(.*)(@.*)/, '$1***$3')
- }
- return value
-}
-
-const emailField: EmailField = {
- name: 'email',
- type: 'email',
- hooks: {
- beforeValidate: [beforeValidateHook],
- afterRead: [afterReadHook],
- },
-}
-```
-
-## Hook Context
-
-Share data between hooks or control hook behavior using request context:
-
-```ts
-import type { CollectionConfig } from 'payload'
-
-export const Posts: CollectionConfig = {
- slug: 'posts',
- hooks: {
- beforeChange: [
- async ({ context }) => {
- context.expensiveData = await fetchExpensiveData()
- },
- ],
- afterChange: [
- async ({ context, doc }) => {
- // Reuse from previous hook
- await processData(doc, context.expensiveData)
- },
- ],
- },
- fields: [{ name: 'title', type: 'text' }],
-}
-```
-
-## Next.js Revalidation with Context Control
-
-```ts
-import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
-import { revalidatePath } from 'next/cache'
-import type { Page } from '../payload-types'
-
-export const revalidatePage: CollectionAfterChangeHook = ({
- doc,
- previousDoc,
- req: { payload, context },
-}) => {
- if (!context.disableRevalidate) {
- if (doc._status === 'published') {
- const path = doc.slug === 'home' ? '/' : `/${doc.slug}`
- payload.logger.info(`Revalidating page at path: ${path}`)
- revalidatePath(path)
- }
-
- // Revalidate old path if unpublished
- if (previousDoc?._status === 'published' && doc._status !== 'published') {
- const oldPath = previousDoc.slug === 'home' ? '/' : `/${previousDoc.slug}`
- payload.logger.info(`Revalidating old page at path: ${oldPath}`)
- revalidatePath(oldPath)
- }
- }
- return doc
-}
-
-export const revalidateDelete: CollectionAfterDeleteHook = ({ doc, req: { context } }) => {
- if (!context.disableRevalidate) {
- const path = doc?.slug === 'home' ? '/' : `/${doc?.slug}`
- revalidatePath(path)
- }
- return doc
-}
-```
-
-## Date Field Auto-Set
-
-Automatically set date when document is published:
-
-```ts
-import type { DateField } from 'payload'
-
-const publishedOnField: DateField = {
- name: 'publishedOn',
- type: 'date',
- admin: {
- date: {
- pickerAppearance: 'dayAndTime',
- },
- position: 'sidebar',
- },
- hooks: {
- beforeChange: [
- ({ siblingData, value }) => {
- if (siblingData._status === 'published' && !value) {
- return new Date()
- }
- return value
- },
- ],
- },
-}
-```
-
-## Hook Patterns Best Practices
-
-- Use `beforeValidate` for data formatting
-- Use `beforeChange` for business logic
-- Use `afterChange` for side effects
-- Use `afterRead` for computed fields
-- Store expensive operations in `context`
-- Pass `req` to nested operations for transaction safety (see [ADAPTERS.md#threading-req-through-operations](ADAPTERS.md#threading-req-through-operations))
diff --git a/.pi/skills/payload/reference/PLUGIN-DEVELOPMENT.md b/.pi/skills/payload/reference/PLUGIN-DEVELOPMENT.md
deleted file mode 100644
index f852dd7..0000000
--- a/.pi/skills/payload/reference/PLUGIN-DEVELOPMENT.md
+++ /dev/null
@@ -1,1436 +0,0 @@
-# Payload Plugin Development
-
-Complete guide to creating Payload plugins with TypeScript patterns, package structure, and best practices from the official Payload plugin template.
-
-## Plugin Architecture
-
-Plugins are functions that receive configuration options and return a function that transforms the Payload config:
-
-```ts
-import type { Config, Plugin } from 'payload'
-
-interface MyPluginConfig {
- enabled?: boolean
- collections?: string[]
-}
-
-export const myPlugin =
- (options: MyPluginConfig): Plugin =>
- (config: Config): Config => ({
- ...config,
- // Transform config here
- })
-```
-
-**Key Pattern:** Double arrow function (currying)
-
-- First function: Accepts plugin options, returns plugin function
-- Second function: Accepts Payload config, returns modified config
-
-## Plugin Package Structure
-
-### Simple Structure
-
-```
-plugin-/
-├── package.json # Package metadata and dependencies
-├── README.md # Plugin documentation
-├── LICENSE.md # License file
-└── src/
- ├── index.ts # Entry point, re-exports plugin and config types
- ├── plugin.ts # Plugin implementation
- ├── types.ts # TypeScript type definitions
- └── exports/ # Additional entry points (optional)
- └── types.ts # Type-only exports
-```
-
-### Exhaustive Structure
-
-```
-plugin-/
-├── .swcrc # SWC compiler config
-├── package.json # Package metadata and dependencies
-├── tsconfig.json # TypeScript config
-├── README.md # Plugin documentation
-├── LICENSE.md # License file
-├── eslint.config.js # ESLint configuration (optional)
-├── vitest.config.js # Vitest test configuration (optional)
-├── playwright.config.js # Playwright e2e tests (optional)
-└── src/
- ├── index.ts # Entry point, re-exports plugin and config types
- ├── plugin.ts # Plugin implementation
- ├── types.ts # TypeScript type definitions
- ├── defaults.ts # Default configuration values (optional)
- ├── endpoints/ # Custom API endpoints (optional)
- │ └── handler.ts
- ├── components/ # React components (optional)
- │ ├── ClientComponent.tsx # 'use client' components
- │ └── ServerComponent.tsx # RSC components
- ├── fields/ # Custom field components (optional)
- │ ├── FieldName/
- │ │ ├── index.ts # Field config
- │ │ └── Component.tsx # Client component
- ├── exports/ # Additional entry points
- │ ├── types.ts # Type-only exports
- │ ├── fields.ts # Field-only exports
- │ ├── client.ts # Re-export client components
- │ └── rsc.ts # Re-export server components (RSC)
- ├── translations/ # i18n translations (optional)
- │ └── index.ts
- └── ui/ # Admin UI components (optional)
- └── Component.tsx
-```
-
-**Key additions from official template:**
-
-- **dev/** directory with complete Payload project for local testing
-- **src/exports/rsc.ts** for React Server Component exports
-- **src/components/** for organizing React components
-- **src/endpoints/** for custom API endpoint handlers
-- Test configuration files (vitest.config.js, playwright.config.js)
-
-## Package.json Configuration
-
-```json
-{
- "name": "payload-plugin-example",
- "version": "1.0.0",
- "description": "A Payload plugin",
- "type": "module",
- "main": "./dist/index.js",
- "types": "./dist/index.d.ts",
- "exports": {
- ".": {
- "import": "./dist/index.js",
- "types": "./dist/index.d.ts",
- "default": "./dist/index.js"
- },
- "./types": {
- "import": "./dist/exports/types.js",
- "types": "./dist/exports/types.d.ts"
- },
- "./client": {
- "import": "./dist/exports/client.js",
- "types": "./dist/exports/client.d.ts"
- },
- "./rsc": {
- "import": "./dist/exports/rsc.js",
- "types": "./dist/exports/rsc.d.ts"
- }
- },
- "files": ["dist"],
- "scripts": {
- "build": "npm run copyfiles && npm run build:types && npm run build:swc",
- "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
- "build:types": "tsc --emitDeclarationOnly --outDir dist",
- "clean": "rimraf dist *.tsbuildinfo",
- "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
- "dev": "next dev dev --turbo",
- "dev:generate-types": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload generate:types",
- "dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
- "test": "npm run test:int && npm run test:e2e",
- "test:int": "vitest",
- "test:e2e": "playwright test",
- "lint": "eslint",
- "lint:fix": "eslint ./src --fix",
- "prepublishOnly": "npm run clean && npm run build"
- },
- "dependencies": {
- "@payloadcms/translations": "^3.0.0",
- "@payloadcms/ui": "^3.0.0"
- },
- "devDependencies": {
- "@payloadcms/db-mongodb": "^3.0.0",
- "@payloadcms/next": "^3.0.0",
- "@payloadcms/richtext-lexical": "^3.0.0",
- "@playwright/test": "^1.40.0",
- "@swc/cli": "^0.1.62",
- "@swc/core": "^1.3.0",
- "copyfiles": "^2.4.1",
- "cross-env": "^7.0.3",
- "eslint": "^9.0.0",
- "next": "^15.4.10",
- "payload": "^3.0.0",
- "react": "^19.2.1",
- "react-dom": "^19.2.1",
- "rimraf": "^5.0.0",
- "typescript": "^5.0.0",
- "vitest": "^3.0.0"
- },
- "peerDependencies": {
- "payload": "^3.0.0"
- }
-}
-```
-
-**Key Points:**
-
-- `type: "module"` for ESM
-- Compiled output in `./dist`, source in `./src`
-- Payload as peer dependency (user installs it)
-- Multiple export entry points: main, `/types`, `/client`, `/rsc`
-- `/client` for client components, `/rsc` for React Server Components
-- SWC for fast compilation
-- Dev scripts for local development with Next.js
-- Test scripts for both integration (Vitest) and e2e (Playwright) tests
-- `prepublishOnly` ensures build before publish
-
-## Plugin Patterns
-
-### Adding Fields to Collections
-
-```ts
-import type { Config, Plugin, Field } from 'payload'
-
-export const seoPlugin =
- (options: { collections?: string[] }): Plugin =>
- (config: Config): Config => {
- const seoFields: Field[] = [
- {
- name: 'meta',
- type: 'group',
- fields: [
- { name: 'title', type: 'text' },
- { name: 'description', type: 'textarea' },
- ],
- },
- ]
-
- return {
- ...config,
- collections: config.collections?.map((collection) => {
- if (options.collections?.includes(collection.slug)) {
- return {
- ...collection,
- fields: [...(collection.fields || []), ...seoFields],
- }
- }
- return collection
- }),
- }
- }
-```
-
-### Adding New Collections
-
-```ts
-import type { Config, Plugin, CollectionConfig } from 'payload'
-
-export const redirectsPlugin =
- (options: { overrides?: Partial }): Plugin =>
- (config: Config): Config => {
- const redirectsCollection: CollectionConfig = {
- slug: 'redirects',
- access: { read: () => true },
- fields: [
- { name: 'from', type: 'text', required: true, unique: true },
- { name: 'to', type: 'text', required: true },
- ],
- ...options.overrides,
- }
-
- return {
- ...config,
- collections: [...(config.collections || []), redirectsCollection],
- }
- }
-```
-
-### Adding Hooks
-
-```ts
-import type { Config, Plugin, CollectionAfterChangeHook } from 'payload'
-
-const resaveChildrenHook: CollectionAfterChangeHook = async ({ doc, req, operation }) => {
- if (operation === 'update') {
- // Resave child documents
- const children = await req.payload.find({
- collection: 'pages',
- where: { parent: { equals: doc.id } },
- })
-
- for (const child of children.docs) {
- await req.payload.update({
- collection: 'pages',
- id: child.id,
- data: child,
- })
- }
- }
- return doc
-}
-
-export const nestedDocsPlugin =
- (options: { collections: string[] }): Plugin =>
- (config: Config): Config => ({
- ...config,
- collections: (config.collections || []).map((collection) => {
- if (options.collections.includes(collection.slug)) {
- return {
- ...collection,
- hooks: {
- ...(collection.hooks || {}),
- afterChange: [resaveChildrenHook, ...(collection.hooks?.afterChange || [])],
- },
- }
- }
- return collection
- }),
- })
-```
-
-### Adding Root-Level Endpoints
-
-Add endpoints at the root config level (accessible at `/api/`):
-
-```ts
-import type { Config, Plugin, Endpoint } from 'payload'
-
-export const seoPlugin =
- (options: { generateTitle?: (doc: any) => string }): Plugin =>
- (config: Config): Config => {
- const generateTitleEndpoint: Endpoint = {
- path: '/plugin-seo/generate-title',
- method: 'post',
- handler: async (req) => {
- const data = await req.json?.()
- const result = options.generateTitle ? options.generateTitle(data.doc) : ''
- return Response.json({ result })
- },
- }
-
- return {
- ...config,
- endpoints: [...(config.endpoints ?? []), generateTitleEndpoint],
- }
- }
-```
-
-**Example webhook endpoint:**
-
-```ts
-// Useful for integrations like Stripe
-const webhookEndpoint: Endpoint = {
- path: '/stripe/webhook',
- method: 'post',
- handler: async (req) => {
- const signature = req.headers.get('stripe-signature')
- const event = stripe.webhooks.constructEvent(
- await req.text(),
- signature,
- process.env.STRIPE_WEBHOOK_SECRET,
- )
- // Handle webhook
- return Response.json({ received: true })
- },
-}
-```
-
-### Field Overrides with Defaults
-
-```ts
-import type { Config, Plugin, Field } from 'payload'
-
-type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
-
-interface PluginConfig {
- collections?: string[]
- fields?: FieldsOverride
-}
-
-export const myPlugin =
- (options: PluginConfig): Plugin =>
- (config: Config): Config => {
- const defaultFields: Field[] = [
- { name: 'title', type: 'text' },
- { name: 'description', type: 'textarea' },
- ]
-
- const fields =
- options.fields && typeof options.fields === 'function'
- ? options.fields({ defaultFields })
- : defaultFields
-
- return {
- ...config,
- collections: config.collections?.map((collection) => {
- if (options.collections?.includes(collection.slug)) {
- return {
- ...collection,
- fields: [...(collection.fields || []), ...fields],
- }
- }
- return collection
- }),
- }
- }
-```
-
-### Tabs UI Pattern
-
-```ts
-import type { Config, Plugin, TabsField, GroupField } from 'payload'
-
-export const seoPlugin =
- (options: { tabbedUI?: boolean }): Plugin =>
- (config: Config): Config => {
- const seoFields: GroupField[] = [
- {
- name: 'meta',
- type: 'group',
- fields: [{ name: 'title', type: 'text' }],
- },
- ]
-
- return {
- ...config,
- collections: config.collections?.map((collection) => {
- if (options.tabbedUI) {
- const seoTabs: TabsField[] = [
- {
- type: 'tabs',
- tabs: [
- // If existing tabs, preserve them
- ...(collection.fields?.[0]?.type === 'tabs'
- ? collection.fields[0].tabs
- : [
- {
- label: 'Content',
- fields: collection.fields || [],
- },
- ]),
- // Add SEO tab
- {
- label: 'SEO',
- fields: seoFields,
- },
- ],
- },
- ]
-
- return {
- ...collection,
- fields: [
- ...seoTabs,
- ...(collection.fields?.[0]?.type === 'tabs' ? collection.fields.slice(1) : []),
- ],
- }
- }
-
- return {
- ...collection,
- fields: [...(collection.fields || []), ...seoFields],
- }
- }),
- }
- }
-```
-
-### Disable Plugin Pattern
-
-Allow users to disable plugin without removing it (important for database schema consistency):
-
-```ts
-import type { Config, Plugin } from 'payload'
-
-interface PluginConfig {
- disabled?: boolean
- collections?: string[]
-}
-
-export const myPlugin =
- (options: PluginConfig): Plugin =>
- (config: Config): Config => {
- // Always add collections/fields for database schema consistency
- if (!config.collections) {
- config.collections = []
- }
-
- config.collections.push({
- slug: 'plugin-collection',
- fields: [{ name: 'title', type: 'text' }],
- })
-
- // Add fields to specified collections
- if (options.collections) {
- for (const collectionSlug of options.collections) {
- const collection = config.collections.find((c) => c.slug === collectionSlug)
- if (collection) {
- collection.fields.push({
- name: 'addedByPlugin',
- type: 'text',
- })
- }
- }
- }
-
- // If disabled, return early but keep schema changes
- if (options.disabled) {
- return config
- }
-
- // Add endpoints, hooks, components only when enabled
- config.endpoints = [
- ...(config.endpoints ?? []),
- {
- path: '/my-endpoint',
- method: 'get',
- handler: async () => Response.json({ message: 'Hello' }),
- },
- ]
-
- return config
- }
-```
-
-### Admin Components
-
-Add custom UI components to the admin panel:
-
-```ts
-import type { Config, Plugin } from 'payload'
-
-export const myPlugin =
- (options: PluginConfig): Plugin =>
- (config: Config): Config => {
- if (!config.admin) config.admin = {}
- if (!config.admin.components) config.admin.components = {}
- if (!config.admin.components.beforeDashboard) {
- config.admin.components.beforeDashboard = []
- }
-
- // Add client component
- config.admin.components.beforeDashboard.push('my-plugin-name/client#BeforeDashboardClient')
-
- // Add server component (RSC)
- config.admin.components.beforeDashboard.push('my-plugin-name/rsc#BeforeDashboardServer')
-
- return config
- }
-```
-
-**Component file structure:**
-
-```tsx
-// src/components/BeforeDashboardClient.tsx
-'use client'
-import { useConfig } from '@payloadcms/ui'
-import { useEffect, useState } from 'react'
-import { formatAdminURL } from 'payload/shared'
-
-export const BeforeDashboardClient = () => {
- const { config } = useConfig()
- const [data, setData] = useState('')
-
- useEffect(() => {
- fetch(
- formatAdminURL({
- apiRoute: config.routes.api,
- path: '/my-endpoint',
- }),
- )
- .then((res) => res.json())
- .then(setData)
- }, [config.serverURL, config.routes.api])
-
- return Client Component: {data}
-}
-
-// src/components/BeforeDashboardServer.tsx
-export const BeforeDashboardServer = () => {
- return Server Component
-}
-
-// src/exports/client.ts
-export { BeforeDashboardClient } from '../components/BeforeDashboardClient.js'
-
-// src/exports/rsc.ts
-export { BeforeDashboardServer } from '../components/BeforeDashboardServer.js'
-```
-
-### Translations (i18n)
-
-```ts
-// src/translations/index.ts
-export const translations = {
- en: {
- 'plugin-name:fieldLabel': 'Field Label',
- 'plugin-name:fieldDescription': 'Field description',
- },
- es: {
- 'plugin-name:fieldLabel': 'Etiqueta del campo',
- 'plugin-name:fieldDescription': 'Descripción del campo',
- },
-}
-
-// src/plugin.ts
-import { deepMergeSimple } from 'payload/shared'
-import { translations } from './translations/index.js'
-
-export const myPlugin =
- (options: PluginConfig): Plugin =>
- (config: Config): Config => ({
- ...config,
- i18n: {
- ...config.i18n,
- translations: deepMergeSimple(translations, config.i18n?.translations ?? {}),
- },
- })
-```
-
-### onInit Hook
-
-```ts
-export const myPlugin =
- (options: PluginConfig): Plugin =>
- (config: Config): Config => {
- const incomingOnInit = config.onInit
-
- config.onInit = async (payload) => {
- // IMPORTANT: Call existing onInit first
- if (incomingOnInit) await incomingOnInit(payload)
-
- // Plugin initialization
- payload.logger.info('Plugin initialized')
-
- // Example: Seed data
- const { totalDocs } = await payload.count({
- collection: 'plugin-collection',
- where: { id: { equals: 'seeded-by-plugin' } },
- })
-
- if (totalDocs === 0) {
- await payload.create({
- collection: 'plugin-collection',
- data: { id: 'seeded-by-plugin' },
- })
- }
- }
-
- return config
- }
-```
-
-## TypeScript Patterns
-
-### Plugin Config Types
-
-```ts
-import type { CollectionSlug, GlobalSlug, Field, CollectionConfig } from 'payload'
-
-export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
-
-export interface MyPluginConfig {
- /**
- * Collections to enable this plugin for
- */
- collections?: CollectionSlug[]
- /**
- * Globals to enable this plugin for
- */
- globals?: GlobalSlug[]
- /**
- * Override default fields
- */
- fields?: FieldsOverride
- /**
- * Enable tabbed UI
- */
- tabbedUI?: boolean
- /**
- * Override collection config
- */
- overrides?: Partial
-}
-```
-
-### Export Types
-
-```ts
-// src/exports/types.ts
-export type { MyPluginConfig, FieldsOverride } from '../types.js'
-
-// Usage
-import type { MyPluginConfig } from '@payloadcms/plugin-example/types'
-```
-
-## Client Components
-
-### Custom Field Component
-
-```tsx
-// src/fields/CustomField/Component.tsx
-'use client'
-import { useField } from '@payloadcms/ui'
-import type { TextFieldClientComponent } from 'payload'
-
-export const CustomFieldComponent: TextFieldClientComponent = ({ field, path }) => {
- const { value, setValue } = useField({ path })
-
- return (
-
-
- setValue(e.target.value)} />
-
- )
-}
-```
-
-```ts
-// src/fields/CustomField/index.ts
-import type { Field } from 'payload'
-
-export const CustomField = (overrides?: Partial): Field => ({
- name: 'customField',
- type: 'text',
- admin: {
- components: {
- Field: '/fields/CustomField/Component#CustomFieldComponent',
- },
- },
- ...overrides,
-})
-```
-
-## Best Practices
-
-### Preserve Existing Config
-
-Always spread existing config and add to arrays:
-
-```ts
-// ✅ Good
-collections: [...(config.collections || []), newCollection]
-
-// ❌ Bad
-collections: [newCollection]
-```
-
-### Respect User Overrides
-
-Allow users to override plugin defaults:
-
-```ts
-const collection: CollectionConfig = {
- slug: 'redirects',
- fields: defaultFields,
- ...options.overrides, // User overrides last
-}
-```
-
-### Conditional Logic
-
-Check if collections/globals are enabled:
-
-```ts
-collections: config.collections?.map((collection) => {
- const isEnabled = options.collections?.includes(collection.slug)
- if (isEnabled) {
- // Transform collection
- }
- return collection
-})
-```
-
-### Hook Composition
-
-Preserve existing hooks:
-
-```ts
-hooks: {
- ...collection.hooks,
- afterChange: [
- myHook,
- ...(collection.hooks?.afterChange || []),
- ],
-}
-```
-
-### Type Safety
-
-Use Payload's exported types:
-
-```ts
-import type { Config, Plugin, CollectionConfig, Field, CollectionSlug, GlobalSlug } from 'payload'
-```
-
-### Field Path Imports
-
-Use absolute paths for client components:
-
-```ts
-admin: {
- components: {
- Field: '/fields/CustomField/Component#CustomFieldComponent',
- },
-}
-```
-
-### onInit Pattern
-
-Always call existing `onInit` before your initialization. See [onInit Hook](#oninit-hook) pattern for full example.
-
-## Advanced Patterns
-
-These patterns are extracted from official Payload plugins and represent production-ready techniques for complex plugin development.
-
-### Advanced Configuration
-
-#### Async Plugin Function
-
-Allow plugin function to be async for awaiting collection overrides or async operations:
-
-```ts
-export const myPlugin =
- (pluginConfig?: PluginConfig) =>
- async (incomingConfig: Config): Promise => {
- // Can await async operations during initialization
- const customCollection = await pluginConfig.collectionOverride?.({
- defaultCollection,
- })
-
- return {
- ...incomingConfig,
- collections: [...incomingConfig.collections, customCollection],
- }
- }
-```
-
-#### Collection Override with Async Support
-
-Allow users to override entire collections with async functions:
-
-```ts
-type CollectionOverride = (args: {
- defaultCollection: CollectionConfig
-}) => CollectionConfig | Promise
-
-interface PluginConfig {
- products?: {
- collectionOverride?: CollectionOverride
- }
-}
-
-// In plugin
-const defaultCollection = createProductsCollection(config)
-const finalCollection = config.products?.collectionOverride
- ? await config.products.collectionOverride({ defaultCollection })
- : defaultCollection
-```
-
-#### Config Sanitization Pattern
-
-Normalize plugin configuration with defaults:
-
-```ts
-export const sanitizePluginConfig = ({ pluginConfig }: Props): SanitizedPluginConfig => {
- const config = { ...pluginConfig } as Partial
-
- // Normalize boolean|object configs
- if (typeof config.addresses === 'undefined' || config.addresses === true) {
- config.addresses = { addressFields: defaultAddressFields() }
- } else if (config.addresses === false) {
- config.addresses = null
- }
-
- // Validate required fields
- if (!config.stripeSecretKey) {
- throw new Error('Stripe secret key is required')
- }
-
- return config as SanitizedPluginConfig
-}
-
-// Use at plugin start
-export const myPlugin =
- (pluginConfig: PluginConfig): Plugin =>
- (config) => {
- const sanitized = sanitizePluginConfig({ pluginConfig })
- // Use sanitized config throughout
- }
-```
-
-#### Collection Slug Mapping
-
-Track collection slugs when users can override them:
-
-```ts
-type CollectionSlugMap = {
- products: string
- variants: string
- orders: string
-}
-
-const getCollectionSlugMap = ({ config }: { config: PluginConfig }): CollectionSlugMap => ({
- products: config.products?.slug || 'products',
- variants: config.variants?.slug || 'variants',
- orders: config.orders?.slug || 'orders',
-})
-
-// Use throughout plugin
-const collectionSlugMap = getCollectionSlugMap({ config: pluginConfig })
-
-// When creating relationship fields
-{
- name: 'product',
- type: 'relationship',
- relationTo: collectionSlugMap.products,
-}
-```
-
-#### Multi-Collection Configuration
-
-Plugin operates on multiple collections with collection-specific config:
-
-```ts
-interface PluginConfig {
- sync: Array<{
- collection: string
- fields?: string[]
- onSync?: (doc: any) => Promise
- }>
-}
-
-// In plugin
-for (const collection of config.collections!) {
- const syncConfig = pluginConfig.sync?.find((s) => s.collection === collection.slug)
- if (!syncConfig) continue
-
- collection.hooks.afterChange = [
- ...(collection.hooks?.afterChange || []),
- async ({ doc, operation }) => {
- if (operation === 'create' || operation === 'update') {
- await syncConfig.onSync?.(doc)
- }
- },
- ]
-}
-```
-
-### TypeScript Extensions
-
-#### TypeScript Schema Extension
-
-Add custom properties to generated TypeScript schema:
-
-```ts
-incomingConfig.typescript = incomingConfig.typescript || {}
-incomingConfig.typescript.schema = incomingConfig.typescript.schema || []
-
-incomingConfig.typescript.schema.push((args) => {
- const { jsonSchema } = args
-
- jsonSchema.properties.ecommerce = {
- type: 'object',
- properties: {
- collections: {
- type: 'object',
- properties: {
- products: { type: 'string' },
- orders: { type: 'string' },
- },
- },
- },
- }
-
- return jsonSchema
-})
-```
-
-#### Module Declaration Augmentation
-
-Extend Payload types for plugin-specific field properties:
-
-```ts
-// In plugin types file
-declare module 'payload' {
- export interface FieldCustom {
- 'plugin-import-export'?: {
- disabled?: boolean
- toCSV?: (value: any) => string
- fromCSV?: (value: string) => any
- }
- }
-}
-
-// Usage with TypeScript support
-{
- name: 'price',
- type: 'number',
- custom: {
- 'plugin-import-export': {
- toCSV: (value) => `$${value.toFixed(2)}`,
- fromCSV: (value) => parseFloat(value.replace('$', '')),
- },
- },
-}
-```
-
-### Advanced Hooks
-
-#### Global Error Hooks
-
-Add global error handling:
-
-```ts
-return {
- ...config,
- hooks: {
- afterError: [
- ...(config.hooks?.afterError ?? []),
- async (args) => {
- const { error } = args
- const status = (error as APIError).status ?? 500
-
- if (status >= 500 || captureErrors.includes(status)) {
- captureException(error, {
- tags: {
- collection: args.collection?.slug,
- operation: args.operation,
- },
- user: args.req?.user ? { id: args.req.user.id } : undefined,
- })
- }
- },
- ],
- },
-}
-```
-
-#### Multiple Hook Types on Same Collection
-
-Coordinate multiple lifecycle hooks together for complex workflows (e.g., validation → sync → cache → cleanup):
-
-```ts
-collection.hooks = {
- ...collection.hooks,
-
- beforeValidate: [
- ...(collection.hooks?.beforeValidate || []),
- async ({ data }) => {
- // Normalize before validation
- return data
- },
- ],
-
- beforeChange: [
- ...(collection.hooks?.beforeChange || []),
- async ({ data, operation }) => {
- // Sync to external service
- if (operation === 'create') {
- data.externalId = await externalService.create(data)
- }
- return data
- },
- ],
-
- afterChange: [
- ...(collection.hooks?.afterChange || []),
- async ({ doc }) => {
- // Invalidate cache
- await cache.invalidate(`doc:${doc.id}`)
- },
- ],
-
- afterDelete: [
- ...(collection.hooks?.afterDelete || []),
- async ({ doc }) => {
- // Cleanup external resources
- await externalService.delete(doc.externalId)
- },
- ],
-}
-```
-
-### Access Control & Filtering
-
-#### Access Control Wrapper Pattern
-
-Wrap existing access control with plugin-specific logic:
-
-```ts
-// From plugin-multi-tenant
-export const multiTenantPlugin =
- (pluginOptions: PluginOptions) =>
- (config: Config): Config => ({
- ...config,
- collections: (config.collections || []).map((collection) => {
- if (!pluginOptions.collections.includes(collection.slug)) {
- return collection
- }
-
- return {
- ...collection,
- access: {
- ...collection.access,
- read: ({ req }) => {
- // Inject tenant filter
- return {
- and: [
- collection.access?.read ? collection.access.read({ req }) : {},
- { tenant: { equals: req.user?.tenant } },
- ],
- }
- },
- },
- }
- }),
- })
-```
-
-#### BaseFilter Composition
-
-Combine plugin filters with existing baseListFilter:
-
-```ts
-// From plugin-multi-tenant
-const existingBaseFilter = collection.admin?.baseListFilter
-const tenantFilter = { tenant: { equals: req.user?.tenant } }
-
-collection.admin = {
- ...collection.admin,
- baseListFilter: existingBaseFilter ? { and: [existingBaseFilter, tenantFilter] } : tenantFilter,
-}
-```
-
-#### Relationship FilterOptions Modification
-
-Add filters to relationship field options:
-
-```ts
-// From plugin-multi-tenant
-collection.fields = collection.fields.map((field) => {
- if (field.type === 'relationship') {
- return {
- ...field,
- filterOptions: ({ relationTo }) => {
- return {
- and: [field.filterOptions?.(relationTo) || {}, { tenant: { equals: req.user?.tenant } }],
- }
- },
- }
- }
- return field
-})
-```
-
-### Admin UI Customization
-
-#### Metadata Storage Pattern
-
-Use admin.meta for plugin-specific UI state without database fields:
-
-```ts
-// From plugin-nested-docs
-export const nestedDocsPlugin =
- (pluginOptions: PluginOptions) =>
- (config: Config): Config => ({
- ...config,
- collections: config.collections?.map((collection) => ({
- ...collection,
- admin: {
- ...collection.admin,
- meta: {
- ...collection.admin?.meta,
- nestedDocs: {
- breadcrumbsFieldSlug: pluginOptions.breadcrumbsFieldSlug || 'breadcrumbs',
- parentFieldSlug: pluginOptions.parentFieldSlug || 'parent',
- },
- },
- },
- })),
- })
-```
-
-#### Conditional Component Rendering
-
-Add components based on plugin configuration:
-
-```ts
-// From plugin-seo
-const beforeFields = collection.admin?.components?.beforeFields || []
-
-if (pluginOptions.uploadsCollection === collection.slug) {
- beforeFields.push('/path/to/ImagePreview#ImagePreview')
-}
-
-collection.admin = {
- ...collection.admin,
- components: {
- ...collection.admin?.components,
- beforeFields,
- },
-}
-```
-
-#### Custom Provider Pattern
-
-Inject context providers for shared state:
-
-```ts
-// From plugin-nested-docs
-collection.admin = {
- ...collection.admin,
- components: {
- ...collection.admin?.components,
- providers: [
- ...(collection.admin?.components?.providers || []),
- '/components/NestedDocsProvider#NestedDocsProvider',
- ],
- },
-}
-```
-
-#### Custom Actions
-
-Add collection-level action buttons:
-
-```ts
-// From plugin-import-export
-collection.admin = {
- ...collection.admin,
- components: {
- ...collection.admin?.components,
- actions: [
- ...(collection.admin?.components?.actions || []),
- '/components/ImportButton#ImportButton',
- '/components/ExportButton#ExportButton',
- ],
- },
-}
-```
-
-#### Custom List Item Views
-
-Modify how items appear in collection lists:
-
-```ts
-// From plugin-ecommerce
-collection.admin = {
- ...collection.admin,
- components: {
- ...collection.admin?.components,
- views: {
- ...collection.admin?.components?.views,
- list: {
- ...collection.admin?.components?.views?.list,
- Component: '/views/ProductList#ProductList',
- },
- },
- },
-}
-```
-
-#### Custom Collection Endpoints
-
-Add collection-scoped endpoints (accessible at `/api//`):
-
-```ts
-// From plugin-import-export
-collection.endpoints = [
- ...(collection.endpoints || []),
- {
- path: '/import',
- method: 'post',
- handler: async (req) => {
- // Import logic accessible at /api/posts/import
- return Response.json({ success: true })
- },
- },
- {
- path: '/export',
- method: 'get',
- handler: async (req) => {
- // Export logic accessible at /api/posts/export
- return Response.json({ data: exportedData })
- },
- },
-]
-```
-
-### Field & Collection Modifications
-
-#### Admin Folders Override
-
-Control admin UI organization:
-
-```ts
-// From plugin-redirects
-collection.admin = {
- ...collection.admin,
- group: pluginOptions.group || 'Settings',
- hidden: pluginOptions.hidden,
- defaultColumns: pluginOptions.defaultColumns || ['from', 'to', 'updatedAt'],
-}
-```
-
-### Background Jobs & Async Operations
-
-#### Jobs Registration
-
-Register plugin background tasks:
-
-```ts
-// From plugin-stripe
-export const stripePlugin =
- (pluginOptions: PluginOptions) =>
- (config: Config): Config => ({
- ...config,
- jobs: {
- ...config.jobs,
- tasks: [
- ...(config.jobs?.tasks || []),
- {
- slug: 'syncStripeProducts',
- handler: async ({ req }) => {
- const products = await stripe.products.list()
- // Sync to Payload
- return { output: { synced: products.data.length } }
- },
- },
- ],
- },
- })
-```
-
-## Testing Plugins
-
-### Local Development with dev/ Directory (optional)
-
-Include a `dev/` directory with a complete Payload project for local development:
-
-1. Create `dev/.env` from `.env.example`:
-
-```bash
-DATABASE_URL=mongodb://127.0.0.1/plugin-dev
-PAYLOAD_SECRET=your-secret-here
-```
-
-2. Configure `dev/payload.config.ts`:
-
-```ts
-import { buildConfig } from 'payload'
-import { mongooseAdapter } from '@payloadcms/db-mongodb'
-import { myPlugin } from '../src/index.js'
-
-export default buildConfig({
- secret: process.env.PAYLOAD_SECRET!,
- db: mongooseAdapter({ url: process.env.DATABASE_URL! }),
- plugins: [
- myPlugin({
- collections: ['posts'],
- }),
- ],
- collections: [
- {
- slug: 'posts',
- fields: [{ name: 'title', type: 'text' }],
- },
- ],
-})
-```
-
-3. Run development server:
-
-```bash
-npm run dev # Starts Next.js on http://localhost:3000
-```
-
-### Integration Tests (Vitest) (optional)
-
-Create `dev/int.spec.ts`:
-
-```ts
-import type { Payload } from 'payload'
-import config from '@payload-config'
-import { createPayloadRequest, getPayload } from 'payload'
-import { afterAll, beforeAll, describe, expect, test } from 'vitest'
-import { customEndpointHandler } from '../src/endpoints/handler.js'
-
-let payload: Payload
-
-beforeAll(async () => {
- payload = await getPayload({ config })
-})
-
-afterAll(async () => {
- await payload.destroy()
-})
-
-describe('Plugin integration tests', () => {
- test('should add field to collection', async () => {
- const post = await payload.create({
- collection: 'posts',
- data: {
- title: 'Test',
- addedByPlugin: 'plugin value',
- },
- })
- expect(post.addedByPlugin).toBe('plugin value')
- })
-
- test('should create plugin collection', async () => {
- expect(payload.collections['plugin-collection']).toBeDefined()
- const { docs } = await payload.find({ collection: 'plugin-collection' })
- expect(docs.length).toBeGreaterThan(0)
- })
-
- test('should query custom endpoint', async () => {
- const request = new Request('http://localhost:3000/api/my-endpoint')
- const payloadRequest = await createPayloadRequest({ config, request })
- const response = await customEndpointHandler(payloadRequest)
- const data = await response.json()
- expect(data).toMatchObject({ message: 'Hello' })
- })
-})
-```
-
-Run: `npm run test:int`
-
-### End-to-End Tests (Playwright)
-
-Create `dev/e2e.spec.ts`:
-
-```ts
-import { test, expect } from '@playwright/test'
-
-test.describe('Plugin e2e tests', () => {
- test('should render custom admin component', async ({ page }) => {
- await page.goto('http://localhost:3000/admin')
- await expect(page.getByText('Added by the plugin')).toBeVisible()
- })
-})
-```
-
-Run: `npm run test:e2e`
-
-## Common Plugin Types
-
-### Field Enhancer
-
-Adds fields to existing collections (SEO, timestamps, audit logs)
-
-### Collection Provider
-
-Adds new collections (redirects, forms, logs)
-
-### Hook Injector
-
-Adds hooks to collections (nested docs, cache invalidation)
-
-### UI Enhancer
-
-Adds custom components (dashboards, field types)
-
-### Integration
-
-Connects external services (Stripe, Sentry, storage adapters)
-
-### Adapter
-
-Provides infrastructure (database, storage, email)
-
-## Resources
-
-- [Plugin Examples](https://github.com/payloadcms/payload/tree/main/packages/) - Official plugins source code, payload-\* prefix
-- [Plugin Template](https://github.com/payloadcms/payload/tree/main/templates/plugin) - Starter template for new plugins
diff --git a/.pi/skills/payload/reference/QUERIES.md b/.pi/skills/payload/reference/QUERIES.md
deleted file mode 100644
index 89cfff4..0000000
--- a/.pi/skills/payload/reference/QUERIES.md
+++ /dev/null
@@ -1,274 +0,0 @@
-# Payload Querying Reference
-
-Complete reference for querying data across Local API, REST, and GraphQL.
-
-## Query Operators
-
-```ts
-import type { Where } from 'payload'
-
-// Equals
-const equalsQuery: Where = { color: { equals: 'blue' } }
-
-// Not equals
-const notEqualsQuery: Where = { status: { not_equals: 'draft' } }
-
-// Greater/less than
-const greaterThanQuery: Where = { price: { greater_than: 100 } }
-const lessThanEqualQuery: Where = { age: { less_than_equal: 65 } }
-
-// Contains (case-insensitive)
-const containsQuery: Where = { title: { contains: 'payload' } }
-
-// Like (all words present)
-const likeQuery: Where = { description: { like: 'cms headless' } }
-
-// In/not in
-const inQuery: Where = { category: { in: ['tech', 'news'] } }
-
-// Exists
-const existsQuery: Where = { image: { exists: true } }
-
-// Near (point fields)
-const nearQuery: Where = { location: { near: '-122.4194,37.7749,10000' } }
-```
-
-## AND/OR Logic
-
-```ts
-import type { Where } from 'payload'
-
-const complexQuery: Where = {
- or: [
- { color: { equals: 'mint' } },
- {
- and: [{ color: { equals: 'white' } }, { featured: { equals: false } }],
- },
- ],
-}
-```
-
-## Nested Properties
-
-```ts
-import type { Where } from 'payload'
-
-const nestedQuery: Where = {
- 'author.role': { equals: 'editor' },
- 'meta.featured': { exists: true },
-}
-```
-
-## Local API
-
-```ts
-// Find documents
-const posts = await payload.find({
- collection: 'posts',
- where: {
- status: { equals: 'published' },
- 'author.name': { contains: 'john' },
- },
- depth: 2,
- limit: 10,
- page: 1,
- sort: '-createdAt',
- locale: 'en',
- select: {
- title: true,
- author: true,
- },
-})
-
-// Find by ID
-const post = await payload.findByID({
- collection: 'posts',
- id: '123',
- depth: 2,
-})
-
-// Create
-const post = await payload.create({
- collection: 'posts',
- data: {
- title: 'New Post',
- status: 'draft',
- },
-})
-
-// Update
-await payload.update({
- collection: 'posts',
- id: '123',
- data: {
- status: 'published',
- },
-})
-
-// Delete
-await payload.delete({
- collection: 'posts',
- id: '123',
-})
-
-// Count
-const count = await payload.count({
- collection: 'posts',
- where: {
- status: { equals: 'published' },
- },
-})
-```
-
-### Threading req Parameter
-
-When performing operations in hooks or nested operations, pass the `req` parameter to maintain transaction context:
-
-```ts
-// ✅ CORRECT: Pass req for transaction safety
-const afterChange: CollectionAfterChangeHook = async ({ doc, req }) => {
- await req.payload.create({
- collection: 'audit-log',
- data: { action: 'created', docId: doc.id },
- req, // Maintains transaction atomicity
- })
-}
-
-// ❌ WRONG: Missing req breaks transaction
-const afterChange: CollectionAfterChangeHook = async ({ doc, req }) => {
- await req.payload.create({
- collection: 'audit-log',
- data: { action: 'created', docId: doc.id },
- // Missing req - runs in separate transaction
- })
-}
-```
-
-This is critical for MongoDB replica sets and Postgres. See [ADAPTERS.md#threading-req-through-operations](ADAPTERS.md#threading-req-through-operations) for details.
-
-### Access Control in Local API
-
-**Important**: Local API bypasses access control by default (`overrideAccess: true`). When passing a `user` parameter, you must explicitly set `overrideAccess: false` to respect that user's permissions.
-
-```ts
-// ❌ WRONG: User is passed but access control is bypassed
-const posts = await payload.find({
- collection: 'posts',
- user: currentUser,
- // Missing: overrideAccess: false
- // Result: Operation runs with ADMIN privileges, ignoring user's permissions
-})
-
-// ✅ CORRECT: Respects user's access control permissions
-const posts = await payload.find({
- collection: 'posts',
- user: currentUser,
- overrideAccess: false, // Required to enforce access control
- // Result: User only sees posts they have permission to read
-})
-
-// Administrative operation (intentionally bypass access control)
-const allPosts = await payload.find({
- collection: 'posts',
- // No user parameter
- // overrideAccess defaults to true
- // Result: Returns all posts regardless of access control
-})
-```
-
-**When to use `overrideAccess: false`:**
-
-- Performing operations on behalf of a user
-- Testing access control logic
-- API routes that should respect user permissions
-- Any operation where `user` parameter is provided
-
-**When `overrideAccess: true` is appropriate:**
-
-- Administrative operations (migrations, seeds, cron jobs)
-- Internal system operations
-- Operations explicitly intended to bypass access control
-
-See [ACCESS-CONTROL.md#important-notes](ACCESS-CONTROL.md#important-notes) for more details.
-
-## REST API
-
-```ts
-import { stringify } from 'qs-esm'
-
-const query = {
- status: { equals: 'published' },
-}
-
-const queryString = stringify(
- {
- where: query,
- depth: 2,
- limit: 10,
- },
- { addQueryPrefix: true },
-)
-
-const response = await fetch(`https://api.example.com/api/posts${queryString}`)
-const data = await response.json()
-```
-
-### REST Endpoints
-
-```txt
-GET /api/{collection} - Find documents
-GET /api/{collection}/{id} - Find by ID
-POST /api/{collection} - Create
-PATCH /api/{collection}/{id} - Update
-DELETE /api/{collection}/{id} - Delete
-GET /api/{collection}/count - Count documents
-
-GET /api/globals/{slug} - Get global
-POST /api/globals/{slug} - Update global
-```
-
-## GraphQL
-
-```graphql
-query {
- Posts(where: { status: { equals: published } }, limit: 10, sort: "-createdAt") {
- docs {
- id
- title
- author {
- name
- }
- }
- totalDocs
- hasNextPage
- }
-}
-
-mutation {
- createPost(data: { title: "New Post", status: draft }) {
- id
- title
- }
-}
-
-mutation {
- updatePost(id: "123", data: { status: published }) {
- id
- status
- }
-}
-
-mutation {
- deletePost(id: "123") {
- id
- }
-}
-```
-
-## Performance Best Practices
-
-- Set `maxDepth` on relationships to prevent over-fetching
-- Use `select` to limit returned fields
-- Index frequently queried fields
-- Use `virtual` fields for computed data
-- Cache expensive operations in hook `context`
diff --git a/.pi/trust.json b/.pi/trust.json
deleted file mode 100644
index d19131f..0000000
--- a/.pi/trust.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "/Users/syntaxbullet": true
-}