From 57f2d58a8f44ce74cfdf718c193aad3316d3f33b Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 9 Jul 2026 11:52:36 +0200 Subject: [PATCH] Remove outdated documentation files for Payload plugin development and querying reference; delete trust.json file. --- .claude/launch.json | 11 - .pi/auth.json | 9 - .pi/settings.json | 6 - .pi/skills/cms-migration/SKILL.md | 149 -- .../reference/PAYLOAD-FIELD-REFERENCE.md | 1252 -------------- .pi/skills/payload/README.md | 57 - .pi/skills/payload/SKILL.md | 409 ----- .../reference/ACCESS-CONTROL-ADVANCED.md | 704 -------- .../payload/reference/ACCESS-CONTROL.md | 697 -------- .pi/skills/payload/reference/ADAPTERS.md | 326 ---- .pi/skills/payload/reference/ADVANCED.md | 386 ----- .pi/skills/payload/reference/COLLECTIONS.md | 303 ---- .pi/skills/payload/reference/ENDPOINTS.md | 634 -------- .../payload/reference/FIELD-TYPE-GUARDS.md | 553 ------- .pi/skills/payload/reference/FIELDS.md | 744 --------- .pi/skills/payload/reference/HOOKS.md | 186 --- .../payload/reference/PLUGIN-DEVELOPMENT.md | 1436 ----------------- .pi/skills/payload/reference/QUERIES.md | 274 ---- .pi/trust.json | 3 - 19 files changed, 8139 deletions(-) delete mode 100644 .claude/launch.json delete mode 100644 .pi/auth.json delete mode 100644 .pi/settings.json delete mode 100644 .pi/skills/cms-migration/SKILL.md delete mode 100644 .pi/skills/cms-migration/reference/PAYLOAD-FIELD-REFERENCE.md delete mode 100644 .pi/skills/payload/README.md delete mode 100644 .pi/skills/payload/SKILL.md delete mode 100644 .pi/skills/payload/reference/ACCESS-CONTROL-ADVANCED.md delete mode 100644 .pi/skills/payload/reference/ACCESS-CONTROL.md delete mode 100644 .pi/skills/payload/reference/ADAPTERS.md delete mode 100644 .pi/skills/payload/reference/ADVANCED.md delete mode 100644 .pi/skills/payload/reference/COLLECTIONS.md delete mode 100644 .pi/skills/payload/reference/ENDPOINTS.md delete mode 100644 .pi/skills/payload/reference/FIELD-TYPE-GUARDS.md delete mode 100644 .pi/skills/payload/reference/FIELDS.md delete mode 100644 .pi/skills/payload/reference/HOOKS.md delete mode 100644 .pi/skills/payload/reference/PLUGIN-DEVELOPMENT.md delete mode 100644 .pi/skills/payload/reference/QUERIES.md delete mode 100644 .pi/trust.json 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 -}