Initial commit for project Spoon!
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"payload@payload-marketplace": true,
|
||||||
|
"frontend-design@claude-plugins-official": true,
|
||||||
|
"superpowers@claude-plugins-official": true,
|
||||||
|
"context7@claude-plugins-official": true,
|
||||||
|
"claude-md-management@claude-plugins-official": true,
|
||||||
|
"waynesutton-convex-skills@cpd-waynesutton-convex-skills": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
---
|
||||||
|
name: convex-best-practices
|
||||||
|
description: Guidelines for building production-ready Convex apps covering function organization, query patterns, validation, TypeScript usage, error handling, and the Zen of Convex design philosophy
|
||||||
|
---
|
||||||
|
|
||||||
|
# Convex Best Practices
|
||||||
|
|
||||||
|
Build production-ready Convex applications by following established patterns for function organization, query optimization, validation, TypeScript usage, and error handling.
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
All patterns in this skill comply with `@convex-dev/eslint-plugin`. Install it for build-time validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i @convex-dev/eslint-plugin --save-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import convexPlugin from '@convex-dev/eslint-plugin';
|
||||||
|
import { defineConfig } from 'eslint/config';
|
||||||
|
|
||||||
|
export default defineConfig([...convexPlugin.configs.recommended]);
|
||||||
|
```
|
||||||
|
|
||||||
|
The plugin enforces four rules:
|
||||||
|
|
||||||
|
| Rule | What it enforces |
|
||||||
|
| ----------------------------------- | --------------------------------- |
|
||||||
|
| `no-old-registered-function-syntax` | Object syntax with `handler` |
|
||||||
|
| `require-argument-validators` | `args: {}` on all functions |
|
||||||
|
| `explicit-table-ids` | Table name in db operations |
|
||||||
|
| `import-wrong-runtime` | No Node imports in Convex runtime |
|
||||||
|
|
||||||
|
Docs: https://docs.convex.dev/eslint
|
||||||
|
|
||||||
|
## Documentation Sources
|
||||||
|
|
||||||
|
Before implementing, do not assume; fetch the latest documentation:
|
||||||
|
|
||||||
|
- Primary: https://docs.convex.dev/understanding/best-practices/
|
||||||
|
- Error Handling: https://docs.convex.dev/functions/error-handling
|
||||||
|
- Write Conflicts: https://docs.convex.dev/error#1
|
||||||
|
- For broader context: https://docs.convex.dev/llms.txt
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### The Zen of Convex
|
||||||
|
|
||||||
|
1. **Convex manages the hard parts** - Let Convex handle caching, real-time sync, and consistency
|
||||||
|
2. **Functions are the API** - Design your functions as your application's interface
|
||||||
|
3. **Schema is truth** - Define your data model explicitly in schema.ts
|
||||||
|
4. **TypeScript everywhere** - Leverage end-to-end type safety
|
||||||
|
5. **Queries are reactive** - Think in terms of subscriptions, not requests
|
||||||
|
|
||||||
|
### Function Organization
|
||||||
|
|
||||||
|
Organize your Convex functions by domain:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/users.ts - User-related functions
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
|
||||||
|
export const get = query({
|
||||||
|
args: { userId: v.id('users') },
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('users'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
}),
|
||||||
|
v.null(),
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db.get('users', args.userId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Argument and Return Validation
|
||||||
|
|
||||||
|
Always define validators for arguments AND return types:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const createTask = mutation({
|
||||||
|
args: {
|
||||||
|
title: v.string(),
|
||||||
|
description: v.optional(v.string()),
|
||||||
|
priority: v.union(v.literal('low'), v.literal('medium'), v.literal('high')),
|
||||||
|
},
|
||||||
|
returns: v.id('tasks'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db.insert('tasks', {
|
||||||
|
title: args.title,
|
||||||
|
description: args.description,
|
||||||
|
priority: args.priority,
|
||||||
|
completed: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Patterns
|
||||||
|
|
||||||
|
Use indexes instead of filters for efficient queries:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Schema with index
|
||||||
|
export default defineSchema({
|
||||||
|
tasks: defineTable({
|
||||||
|
userId: v.id('users'),
|
||||||
|
status: v.string(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_user', ['userId'])
|
||||||
|
.index('by_user_and_status', ['userId', 'status']),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query using index
|
||||||
|
export const getTasksByUser = query({
|
||||||
|
args: { userId: v.id('users') },
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('tasks'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
userId: v.id('users'),
|
||||||
|
status: v.string(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query('tasks')
|
||||||
|
.withIndex('by_user', (q) => q.eq('userId', args.userId))
|
||||||
|
.order('desc')
|
||||||
|
.collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Use ConvexError for user-facing errors:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ConvexError } from 'convex/values';
|
||||||
|
|
||||||
|
export const updateTask = mutation({
|
||||||
|
args: {
|
||||||
|
taskId: v.id('tasks'),
|
||||||
|
title: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const task = await ctx.db.get('tasks', args.taskId);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new ConvexError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Task not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch('tasks', args.taskId, { title: args.title });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avoiding Write Conflicts (Optimistic Concurrency Control)
|
||||||
|
|
||||||
|
Convex uses OCC. Follow these patterns to minimize conflicts:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// GOOD: Make mutations idempotent
|
||||||
|
export const completeTask = mutation({
|
||||||
|
args: { taskId: v.id('tasks') },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const task = await ctx.db.get('tasks', args.taskId);
|
||||||
|
|
||||||
|
// Early return if already complete (idempotent)
|
||||||
|
if (!task || task.status === 'completed') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch('tasks', args.taskId, {
|
||||||
|
status: 'completed',
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// GOOD: Patch directly without reading first when possible
|
||||||
|
export const updateNote = mutation({
|
||||||
|
args: { id: v.id('notes'), content: v.string() },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Patch directly - ctx.db.patch throws if document doesn't exist
|
||||||
|
await ctx.db.patch('notes', args.id, { content: args.content });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// GOOD: Use Promise.all for parallel independent updates
|
||||||
|
export const reorderItems = mutation({
|
||||||
|
args: { itemIds: v.array(v.id('items')) },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const updates = args.itemIds.map((id, index) =>
|
||||||
|
ctx.db.patch('items', id, { order: index }),
|
||||||
|
);
|
||||||
|
await Promise.all(updates);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript Best Practices
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Doc, Id } from './_generated/dataModel';
|
||||||
|
|
||||||
|
// Use Id type for document references
|
||||||
|
type UserId = Id<'users'>;
|
||||||
|
|
||||||
|
// Use Doc type for full documents
|
||||||
|
type User = Doc<'users'>;
|
||||||
|
|
||||||
|
// Define Record types properly
|
||||||
|
const userScores: Record<Id<'users'>, number> = {};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Internal vs Public Functions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Public function - exposed to clients
|
||||||
|
export const getUser = query({
|
||||||
|
args: { userId: v.id('users') },
|
||||||
|
returns: v.union(
|
||||||
|
v.null(),
|
||||||
|
v.object({
|
||||||
|
/* ... */
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Internal function - only callable from other Convex functions
|
||||||
|
export const _updateUserStats = internalMutation({
|
||||||
|
args: { userId: v.id('users') },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Complete CRUD Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/tasks.ts
|
||||||
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
|
||||||
|
const taskValidator = v.object({
|
||||||
|
_id: v.id('tasks'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
title: v.string(),
|
||||||
|
completed: v.boolean(),
|
||||||
|
userId: v.id('users'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const list = query({
|
||||||
|
args: { userId: v.id('users') },
|
||||||
|
returns: v.array(taskValidator),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query('tasks')
|
||||||
|
.withIndex('by_user', (q) => q.eq('userId', args.userId))
|
||||||
|
.collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const create = mutation({
|
||||||
|
args: {
|
||||||
|
title: v.string(),
|
||||||
|
userId: v.id('users'),
|
||||||
|
},
|
||||||
|
returns: v.id('tasks'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db.insert('tasks', {
|
||||||
|
title: args.title,
|
||||||
|
completed: false,
|
||||||
|
userId: args.userId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const update = mutation({
|
||||||
|
args: {
|
||||||
|
taskId: v.id('tasks'),
|
||||||
|
title: v.optional(v.string()),
|
||||||
|
completed: v.optional(v.boolean()),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const { taskId, ...updates } = args;
|
||||||
|
|
||||||
|
// Remove undefined values
|
||||||
|
const cleanUpdates = Object.fromEntries(
|
||||||
|
Object.entries(updates).filter(([_, v]) => v !== undefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Object.keys(cleanUpdates).length > 0) {
|
||||||
|
await ctx.db.patch('tasks', taskId, cleanUpdates);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remove = mutation({
|
||||||
|
args: { taskId: v.id('tasks') },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.delete('tasks', args.taskId);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Never run `npx convex deploy` unless explicitly instructed
|
||||||
|
- Never run any git commands unless explicitly instructed
|
||||||
|
- Always define return validators for functions
|
||||||
|
- Use indexes for all queries that filter data
|
||||||
|
- Make mutations idempotent to handle retries gracefully
|
||||||
|
- Use ConvexError for user-facing error messages
|
||||||
|
- Organize functions by domain (users.ts, tasks.ts, etc.)
|
||||||
|
- Use internal functions for sensitive operations
|
||||||
|
- Leverage TypeScript's Id and Doc types
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Using filter instead of withIndex** - Always define indexes and use withIndex
|
||||||
|
2. **Missing return validators** - Always specify the returns field
|
||||||
|
3. **Non-idempotent mutations** - Check current state before updating
|
||||||
|
4. **Reading before patching unnecessarily** - Patch directly when possible
|
||||||
|
5. **Not handling null returns** - Document IDs might not exist
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Convex Documentation: https://docs.convex.dev/
|
||||||
|
- Convex LLMs.txt: https://docs.convex.dev/llms.txt
|
||||||
|
- Best Practices: https://docs.convex.dev/understanding/best-practices/
|
||||||
|
- Error Handling: https://docs.convex.dev/functions/error-handling
|
||||||
|
- Write Conflicts: https://docs.convex.dev/error#1
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
---
|
||||||
|
name: convex-component-authoring
|
||||||
|
displayName: Convex Component Authoring
|
||||||
|
description: How to create, structure, and publish self-contained Convex components with proper isolation, exports, and dependency management
|
||||||
|
version: 1.0.0
|
||||||
|
author: Convex
|
||||||
|
tags: [convex, components, reusable, packages, npm]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Convex Component Authoring
|
||||||
|
|
||||||
|
Create self-contained, reusable Convex components with proper isolation, exports, and dependency management for sharing across projects.
|
||||||
|
|
||||||
|
## Documentation Sources
|
||||||
|
|
||||||
|
Before implementing, do not assume; fetch the latest documentation:
|
||||||
|
|
||||||
|
- Primary: https://docs.convex.dev/components
|
||||||
|
- Component Authoring: https://docs.convex.dev/components/authoring
|
||||||
|
- For broader context: https://docs.convex.dev/llms.txt
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### What Are Convex Components?
|
||||||
|
|
||||||
|
Convex components are self-contained packages that include:
|
||||||
|
|
||||||
|
- Database tables (isolated from the main app)
|
||||||
|
- Functions (queries, mutations, actions)
|
||||||
|
- TypeScript types and validators
|
||||||
|
- Optional frontend hooks
|
||||||
|
|
||||||
|
### Component Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
my-convex-component/
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── README.md
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts # Main exports
|
||||||
|
│ ├── component.ts # Component definition
|
||||||
|
│ ├── schema.ts # Component schema
|
||||||
|
│ └── functions/
|
||||||
|
│ ├── queries.ts
|
||||||
|
│ ├── mutations.ts
|
||||||
|
│ └── actions.ts
|
||||||
|
└── convex.config.ts # Component configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating a Component
|
||||||
|
|
||||||
|
#### 1. Component Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex.config.ts
|
||||||
|
import { defineComponent } from 'convex/server';
|
||||||
|
|
||||||
|
export default defineComponent('myComponent');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Component Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/schema.ts
|
||||||
|
import { defineSchema, defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export default defineSchema({
|
||||||
|
// Tables are isolated to this component
|
||||||
|
items: defineTable({
|
||||||
|
name: v.string(),
|
||||||
|
data: v.any(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
}).index('by_name', ['name']),
|
||||||
|
|
||||||
|
config: defineTable({
|
||||||
|
key: v.string(),
|
||||||
|
value: v.any(),
|
||||||
|
}).index('by_key', ['key']),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Component Definition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/component.ts
|
||||||
|
import { ComponentDefinition, defineComponent } from 'convex/server';
|
||||||
|
|
||||||
|
import * as mutations from './functions/mutations';
|
||||||
|
import * as queries from './functions/queries';
|
||||||
|
import schema from './schema';
|
||||||
|
|
||||||
|
const component = defineComponent('myComponent', {
|
||||||
|
schema,
|
||||||
|
functions: {
|
||||||
|
...queries,
|
||||||
|
...mutations,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default component;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Component Functions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/functions/queries.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { query } from '../_generated/server';
|
||||||
|
|
||||||
|
export const list = query({
|
||||||
|
args: {
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('items'),
|
||||||
|
name: v.string(),
|
||||||
|
data: v.any(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query('items')
|
||||||
|
.order('desc')
|
||||||
|
.take(args.limit ?? 10);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const get = query({
|
||||||
|
args: { name: v.string() },
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('items'),
|
||||||
|
name: v.string(),
|
||||||
|
data: v.any(),
|
||||||
|
}),
|
||||||
|
v.null(),
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query('items')
|
||||||
|
.withIndex('by_name', (q) => q.eq('name', args.name))
|
||||||
|
.unique();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/functions/mutations.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { mutation } from '../_generated/server';
|
||||||
|
|
||||||
|
export const create = mutation({
|
||||||
|
args: {
|
||||||
|
name: v.string(),
|
||||||
|
data: v.any(),
|
||||||
|
},
|
||||||
|
returns: v.id('items'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db.insert('items', {
|
||||||
|
name: args.name,
|
||||||
|
data: args.data,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const update = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id('items'),
|
||||||
|
data: v.any(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.patch(args.id, { data: args.data });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remove = mutation({
|
||||||
|
args: { id: v.id('items') },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.delete(args.id);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Main Exports
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/index.ts
|
||||||
|
export { default as component } from './component';
|
||||||
|
export * from './functions/queries';
|
||||||
|
export * from './functions/mutations';
|
||||||
|
|
||||||
|
// Export types for consumers
|
||||||
|
export type { Id } from './_generated/dataModel';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using a Component
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In the consuming app's convex/convex.config.ts
|
||||||
|
import { defineApp } from 'convex/server';
|
||||||
|
import myComponent from 'my-convex-component';
|
||||||
|
|
||||||
|
const app = defineApp();
|
||||||
|
|
||||||
|
app.use(myComponent, { name: 'myComponent' });
|
||||||
|
|
||||||
|
export default app;
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In the consuming app's code
|
||||||
|
import { useQuery, useMutation } from "convex/react";
|
||||||
|
import { api } from "../convex/_generated/api";
|
||||||
|
|
||||||
|
function MyApp() {
|
||||||
|
// Access component functions through the app's API
|
||||||
|
const items = useQuery(api.myComponent.list, { limit: 10 });
|
||||||
|
const createItem = useMutation(api.myComponent.create);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{items?.map((item) => (
|
||||||
|
<div key={item._id}>{item.name}</div>
|
||||||
|
))}
|
||||||
|
<button onClick={() => createItem({ name: "New", data: {} })}>
|
||||||
|
Add Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Configuration Options
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/convex.config.ts
|
||||||
|
import { defineApp } from 'convex/server';
|
||||||
|
import myComponent from 'my-convex-component';
|
||||||
|
|
||||||
|
const app = defineApp();
|
||||||
|
|
||||||
|
// Basic usage
|
||||||
|
app.use(myComponent);
|
||||||
|
|
||||||
|
// With custom name
|
||||||
|
app.use(myComponent, { name: 'customName' });
|
||||||
|
|
||||||
|
// Multiple instances
|
||||||
|
app.use(myComponent, { name: 'instance1' });
|
||||||
|
app.use(myComponent, { name: 'instance2' });
|
||||||
|
|
||||||
|
export default app;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Providing Component Hooks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/hooks.ts
|
||||||
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
|
import { FunctionReference } from 'convex/server';
|
||||||
|
|
||||||
|
// Type-safe hooks for component consumers
|
||||||
|
export function useMyComponent(api: {
|
||||||
|
list: FunctionReference<'query'>;
|
||||||
|
create: FunctionReference<'mutation'>;
|
||||||
|
}) {
|
||||||
|
const items = useQuery(api.list, {});
|
||||||
|
const createItem = useMutation(api.create);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
createItem,
|
||||||
|
isLoading: items === undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Publishing a Component
|
||||||
|
|
||||||
|
#### package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-convex-component",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A reusable Convex component",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"files": ["dist", "convex.config.ts"],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"convex": "^1.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"convex": "^1.17.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
|
"keywords": ["convex", "component"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### tsconfig.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Rate Limiter Component
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// rate-limiter/src/schema.ts
|
||||||
|
import { defineSchema, defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export default defineSchema({
|
||||||
|
requests: defineTable({
|
||||||
|
key: v.string(),
|
||||||
|
timestamp: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_key', ['key'])
|
||||||
|
.index('by_key_and_time', ['key', 'timestamp']),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// rate-limiter/src/functions/mutations.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { mutation } from '../_generated/server';
|
||||||
|
|
||||||
|
export const checkLimit = mutation({
|
||||||
|
args: {
|
||||||
|
key: v.string(),
|
||||||
|
limit: v.number(),
|
||||||
|
windowMs: v.number(),
|
||||||
|
},
|
||||||
|
returns: v.object({
|
||||||
|
allowed: v.boolean(),
|
||||||
|
remaining: v.number(),
|
||||||
|
resetAt: v.number(),
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = now - args.windowMs;
|
||||||
|
|
||||||
|
// Clean old entries
|
||||||
|
const oldEntries = await ctx.db
|
||||||
|
.query('requests')
|
||||||
|
.withIndex('by_key_and_time', (q) =>
|
||||||
|
q.eq('key', args.key).lt('timestamp', windowStart),
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const entry of oldEntries) {
|
||||||
|
await ctx.db.delete(entry._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count current window
|
||||||
|
const currentRequests = await ctx.db
|
||||||
|
.query('requests')
|
||||||
|
.withIndex('by_key', (q) => q.eq('key', args.key))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const remaining = Math.max(0, args.limit - currentRequests.length);
|
||||||
|
const allowed = remaining > 0;
|
||||||
|
|
||||||
|
if (allowed) {
|
||||||
|
await ctx.db.insert('requests', {
|
||||||
|
key: args.key,
|
||||||
|
timestamp: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldestRequest = currentRequests[0];
|
||||||
|
const resetAt = oldestRequest
|
||||||
|
? oldestRequest.timestamp + args.windowMs
|
||||||
|
: now + args.windowMs;
|
||||||
|
|
||||||
|
return { allowed, remaining: remaining - (allowed ? 1 : 0), resetAt };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Usage in consuming app
|
||||||
|
import { useMutation } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '../convex/_generated/api';
|
||||||
|
|
||||||
|
function useRateLimitedAction() {
|
||||||
|
const checkLimit = useMutation(api.rateLimiter.checkLimit);
|
||||||
|
|
||||||
|
return async (action: () => Promise<void>) => {
|
||||||
|
const result = await checkLimit({
|
||||||
|
key: 'user-action',
|
||||||
|
limit: 10,
|
||||||
|
windowMs: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.allowed) {
|
||||||
|
throw new Error(`Rate limited. Try again at ${new Date(result.resetAt)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await action();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Never run `npx convex deploy` unless explicitly instructed
|
||||||
|
- Never run any git commands unless explicitly instructed
|
||||||
|
- Keep component tables isolated (don't reference main app tables)
|
||||||
|
- Export clear TypeScript types for consumers
|
||||||
|
- Document all public functions and their arguments
|
||||||
|
- Use semantic versioning for component releases
|
||||||
|
- Include comprehensive README with examples
|
||||||
|
- Test components in isolation before publishing
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Cross-referencing tables** - Component tables should be self-contained
|
||||||
|
2. **Missing type exports** - Export all necessary types
|
||||||
|
3. **Hardcoded configuration** - Use component options for customization
|
||||||
|
4. **No versioning** - Follow semantic versioning
|
||||||
|
5. **Poor documentation** - Document all public APIs
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Convex Documentation: https://docs.convex.dev/
|
||||||
|
- Convex LLMs.txt: https://docs.convex.dev/llms.txt
|
||||||
|
- Components: https://docs.convex.dev/components
|
||||||
|
- Component Authoring: https://docs.convex.dev/components/authoring
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
---
|
||||||
|
name: convex-cron-jobs
|
||||||
|
displayName: Convex Cron Jobs
|
||||||
|
description: Scheduled function patterns for background tasks including interval scheduling, cron expressions, job monitoring, retry strategies, and best practices for long-running tasks
|
||||||
|
version: 1.0.0
|
||||||
|
author: Convex
|
||||||
|
tags: [convex, cron, scheduling, background-jobs, automation]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Convex Cron Jobs
|
||||||
|
|
||||||
|
Schedule recurring functions for background tasks, cleanup jobs, data syncing, and automated workflows in Convex applications.
|
||||||
|
|
||||||
|
## Documentation Sources
|
||||||
|
|
||||||
|
Before implementing, do not assume; fetch the latest documentation:
|
||||||
|
|
||||||
|
- Primary: https://docs.convex.dev/scheduling/cron-jobs
|
||||||
|
- Scheduling Overview: https://docs.convex.dev/scheduling
|
||||||
|
- Scheduled Functions: https://docs.convex.dev/scheduling/scheduled-functions
|
||||||
|
- For broader context: https://docs.convex.dev/llms.txt
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### Cron Jobs Overview
|
||||||
|
|
||||||
|
Convex cron jobs allow you to schedule functions to run at regular intervals or specific times. Key features:
|
||||||
|
|
||||||
|
- Run functions on a fixed schedule
|
||||||
|
- Support for interval-based and cron expression scheduling
|
||||||
|
- Automatic retries on failure
|
||||||
|
- Monitoring via the Convex dashboard
|
||||||
|
|
||||||
|
### Basic Cron Setup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/crons.ts
|
||||||
|
import { cronJobs } from 'convex/server';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
|
||||||
|
const crons = cronJobs();
|
||||||
|
|
||||||
|
// Run every hour
|
||||||
|
crons.interval(
|
||||||
|
'cleanup expired sessions',
|
||||||
|
{ hours: 1 },
|
||||||
|
internal.tasks.cleanupExpiredSessions,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run every day at midnight UTC
|
||||||
|
crons.cron(
|
||||||
|
'daily report',
|
||||||
|
'0 0 * * *',
|
||||||
|
internal.reports.generateDailyReport,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default crons;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interval-Based Scheduling
|
||||||
|
|
||||||
|
Use `crons.interval` for simple recurring tasks:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/crons.ts
|
||||||
|
import { cronJobs } from 'convex/server';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
|
||||||
|
const crons = cronJobs();
|
||||||
|
|
||||||
|
// Every 5 minutes
|
||||||
|
crons.interval(
|
||||||
|
'sync external data',
|
||||||
|
{ minutes: 5 },
|
||||||
|
internal.sync.fetchExternalData,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every 2 hours
|
||||||
|
crons.interval(
|
||||||
|
'cleanup temp files',
|
||||||
|
{ hours: 2 },
|
||||||
|
internal.files.cleanupTempFiles,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every 30 seconds (minimum interval)
|
||||||
|
crons.interval(
|
||||||
|
'health check',
|
||||||
|
{ seconds: 30 },
|
||||||
|
internal.monitoring.healthCheck,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default crons;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Expression Scheduling
|
||||||
|
|
||||||
|
Use `crons.cron` for precise scheduling with cron expressions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/crons.ts
|
||||||
|
import { cronJobs } from 'convex/server';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
|
||||||
|
const crons = cronJobs();
|
||||||
|
|
||||||
|
// Every day at 9 AM UTC
|
||||||
|
crons.cron(
|
||||||
|
'morning notifications',
|
||||||
|
'0 9 * * *',
|
||||||
|
internal.notifications.sendMorningDigest,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every Monday at 8 AM UTC
|
||||||
|
crons.cron(
|
||||||
|
'weekly summary',
|
||||||
|
'0 8 * * 1',
|
||||||
|
internal.reports.generateWeeklySummary,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
// First day of every month at midnight
|
||||||
|
crons.cron(
|
||||||
|
'monthly billing',
|
||||||
|
'0 0 1 * *',
|
||||||
|
internal.billing.processMonthlyBilling,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every 15 minutes
|
||||||
|
crons.cron('frequent sync', '*/15 * * * *', internal.sync.syncData, {});
|
||||||
|
|
||||||
|
export default crons;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Expression Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────── minute (0-59)
|
||||||
|
│ ┌───────────── hour (0-23)
|
||||||
|
│ │ ┌───────────── day of month (1-31)
|
||||||
|
│ │ │ ┌───────────── month (1-12)
|
||||||
|
│ │ │ │ ┌───────────── day of week (0-6, Sunday=0)
|
||||||
|
│ │ │ │ │
|
||||||
|
* * * * *
|
||||||
|
```
|
||||||
|
|
||||||
|
Common patterns:
|
||||||
|
|
||||||
|
- `* * * * *` - Every minute
|
||||||
|
- `0 * * * *` - Every hour
|
||||||
|
- `0 0 * * *` - Every day at midnight
|
||||||
|
- `0 0 * * 0` - Every Sunday at midnight
|
||||||
|
- `0 0 1 * *` - First day of every month
|
||||||
|
- `*/5 * * * *` - Every 5 minutes
|
||||||
|
- `0 9-17 * * 1-5` - Every hour from 9 AM to 5 PM, Monday through Friday
|
||||||
|
|
||||||
|
### Internal Functions for Crons
|
||||||
|
|
||||||
|
Cron jobs should call internal functions for security:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/tasks.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { internalMutation, internalQuery } from './_generated/server';
|
||||||
|
|
||||||
|
// Cleanup expired sessions
|
||||||
|
export const cleanupExpiredSessions = internalMutation({
|
||||||
|
args: {},
|
||||||
|
returns: v.number(),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const expiredSessions = await ctx.db
|
||||||
|
.query('sessions')
|
||||||
|
.withIndex('by_lastActive')
|
||||||
|
.filter((q) => q.lt(q.field('lastActive'), oneHourAgo))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const session of expiredSessions) {
|
||||||
|
await ctx.db.delete(session._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return expiredSessions.length;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process pending tasks
|
||||||
|
export const processPendingTasks = internalMutation({
|
||||||
|
args: {},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const pendingTasks = await ctx.db
|
||||||
|
.query('tasks')
|
||||||
|
.withIndex('by_status', (q) => q.eq('status', 'pending'))
|
||||||
|
.take(100);
|
||||||
|
|
||||||
|
for (const task of pendingTasks) {
|
||||||
|
await ctx.db.patch(task._id, {
|
||||||
|
status: 'processing',
|
||||||
|
startedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schedule the actual processing
|
||||||
|
await ctx.scheduler.runAfter(0, internal.tasks.processTask, {
|
||||||
|
taskId: task._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Jobs with Arguments
|
||||||
|
|
||||||
|
Pass static arguments to cron jobs:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/crons.ts
|
||||||
|
import { cronJobs } from 'convex/server';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
|
||||||
|
const crons = cronJobs();
|
||||||
|
|
||||||
|
// Different cleanup intervals for different types
|
||||||
|
crons.interval(
|
||||||
|
'cleanup temp files',
|
||||||
|
{ hours: 1 },
|
||||||
|
internal.cleanup.cleanupByType,
|
||||||
|
{ fileType: 'temp', maxAge: 3600000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
crons.interval(
|
||||||
|
'cleanup cache files',
|
||||||
|
{ hours: 24 },
|
||||||
|
internal.cleanup.cleanupByType,
|
||||||
|
{ fileType: 'cache', maxAge: 86400000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
export default crons;
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/cleanup.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { internalMutation } from './_generated/server';
|
||||||
|
|
||||||
|
export const cleanupByType = internalMutation({
|
||||||
|
args: {
|
||||||
|
fileType: v.string(),
|
||||||
|
maxAge: v.number(),
|
||||||
|
},
|
||||||
|
returns: v.number(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const cutoff = Date.now() - args.maxAge;
|
||||||
|
|
||||||
|
const oldFiles = await ctx.db
|
||||||
|
.query('files')
|
||||||
|
.withIndex('by_type_and_created', (q) =>
|
||||||
|
q.eq('type', args.fileType).lt('createdAt', cutoff),
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const file of oldFiles) {
|
||||||
|
await ctx.storage.delete(file.storageId);
|
||||||
|
await ctx.db.delete(file._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return oldFiles.length;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring and Logging
|
||||||
|
|
||||||
|
Add logging to track cron job execution:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/tasks.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { internalMutation } from './_generated/server';
|
||||||
|
|
||||||
|
export const cleanupWithLogging = internalMutation({
|
||||||
|
args: {},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let processedCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expiredItems = await ctx.db
|
||||||
|
.query('items')
|
||||||
|
.withIndex('by_expiresAt')
|
||||||
|
.filter((q) => q.lt(q.field('expiresAt'), Date.now()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const item of expiredItems) {
|
||||||
|
try {
|
||||||
|
await ctx.db.delete(item._id);
|
||||||
|
processedCount++;
|
||||||
|
} catch (error) {
|
||||||
|
errorCount++;
|
||||||
|
console.error(`Failed to delete item ${item._id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log job completion
|
||||||
|
await ctx.db.insert('cronLogs', {
|
||||||
|
jobName: 'cleanup',
|
||||||
|
startTime,
|
||||||
|
endTime: Date.now(),
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
processedCount,
|
||||||
|
errorCount,
|
||||||
|
status: errorCount === 0 ? 'success' : 'partial',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Log job failure
|
||||||
|
await ctx.db.insert('cronLogs', {
|
||||||
|
jobName: 'cleanup',
|
||||||
|
startTime,
|
||||||
|
endTime: Date.now(),
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
processedCount,
|
||||||
|
errorCount,
|
||||||
|
status: 'failed',
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batching for Large Datasets
|
||||||
|
|
||||||
|
Handle large datasets in batches to avoid timeouts:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/tasks.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { internalMutation } from './_generated/server';
|
||||||
|
|
||||||
|
const BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
export const processBatch = internalMutation({
|
||||||
|
args: {
|
||||||
|
cursor: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const result = await ctx.db
|
||||||
|
.query('items')
|
||||||
|
.withIndex('by_status', (q) => q.eq('status', 'pending'))
|
||||||
|
.paginate({ numItems: BATCH_SIZE, cursor: args.cursor ?? null });
|
||||||
|
|
||||||
|
for (const item of result.page) {
|
||||||
|
await ctx.db.patch(item._id, {
|
||||||
|
status: 'processed',
|
||||||
|
processedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule next batch if there are more items
|
||||||
|
if (!result.isDone) {
|
||||||
|
await ctx.scheduler.runAfter(0, internal.tasks.processBatch, {
|
||||||
|
cursor: result.continueCursor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### External API Calls in Crons
|
||||||
|
|
||||||
|
Use actions for external API calls:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/sync.ts
|
||||||
|
'use node';
|
||||||
|
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { internalAction } from './_generated/server';
|
||||||
|
|
||||||
|
export const syncExternalData = internalAction({
|
||||||
|
args: {},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
// Fetch from external API
|
||||||
|
const response = await fetch('https://api.example.com/data', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.API_KEY}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Store the data using a mutation
|
||||||
|
await ctx.runMutation(internal.sync.storeExternalData, {
|
||||||
|
data,
|
||||||
|
syncedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const storeExternalData = internalMutation({
|
||||||
|
args: {
|
||||||
|
data: v.any(),
|
||||||
|
syncedAt: v.number(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.insert('externalData', {
|
||||||
|
data: args.data,
|
||||||
|
syncedAt: args.syncedAt,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/crons.ts
|
||||||
|
import { cronJobs } from 'convex/server';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
|
||||||
|
const crons = cronJobs();
|
||||||
|
|
||||||
|
crons.interval(
|
||||||
|
'sync external data',
|
||||||
|
{ minutes: 15 },
|
||||||
|
internal.sync.syncExternalData,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default crons;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Schema for Cron Job Logging
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/schema.ts
|
||||||
|
import { defineSchema, defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export default defineSchema({
|
||||||
|
cronLogs: defineTable({
|
||||||
|
jobName: v.string(),
|
||||||
|
startTime: v.number(),
|
||||||
|
endTime: v.number(),
|
||||||
|
duration: v.number(),
|
||||||
|
processedCount: v.number(),
|
||||||
|
errorCount: v.number(),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('success'),
|
||||||
|
v.literal('partial'),
|
||||||
|
v.literal('failed'),
|
||||||
|
),
|
||||||
|
error: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
.index('by_job', ['jobName'])
|
||||||
|
.index('by_status', ['status'])
|
||||||
|
.index('by_startTime', ['startTime']),
|
||||||
|
|
||||||
|
sessions: defineTable({
|
||||||
|
userId: v.id('users'),
|
||||||
|
token: v.string(),
|
||||||
|
lastActive: v.number(),
|
||||||
|
expiresAt: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_user', ['userId'])
|
||||||
|
.index('by_lastActive', ['lastActive'])
|
||||||
|
.index('by_expiresAt', ['expiresAt']),
|
||||||
|
|
||||||
|
tasks: defineTable({
|
||||||
|
type: v.string(),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('pending'),
|
||||||
|
v.literal('processing'),
|
||||||
|
v.literal('completed'),
|
||||||
|
v.literal('failed'),
|
||||||
|
),
|
||||||
|
data: v.any(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
startedAt: v.optional(v.number()),
|
||||||
|
completedAt: v.optional(v.number()),
|
||||||
|
})
|
||||||
|
.index('by_status', ['status'])
|
||||||
|
.index('by_type_and_status', ['type', 'status']),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complete Cron Configuration Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/crons.ts
|
||||||
|
import { cronJobs } from 'convex/server';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
|
||||||
|
const crons = cronJobs();
|
||||||
|
|
||||||
|
// Cleanup jobs
|
||||||
|
crons.interval(
|
||||||
|
'cleanup expired sessions',
|
||||||
|
{ hours: 1 },
|
||||||
|
internal.cleanup.expiredSessions,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
crons.interval('cleanup old logs', { hours: 24 }, internal.cleanup.oldLogs, {
|
||||||
|
maxAgeDays: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync jobs
|
||||||
|
crons.interval('sync user data', { minutes: 15 }, internal.sync.userData, {});
|
||||||
|
|
||||||
|
// Report jobs
|
||||||
|
crons.cron('daily analytics', '0 1 * * *', internal.reports.dailyAnalytics, {});
|
||||||
|
|
||||||
|
crons.cron('weekly summary', '0 9 * * 1', internal.reports.weeklySummary, {});
|
||||||
|
|
||||||
|
// Health checks
|
||||||
|
crons.interval(
|
||||||
|
'service health check',
|
||||||
|
{ minutes: 5 },
|
||||||
|
internal.monitoring.healthCheck,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default crons;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Never run `npx convex deploy` unless explicitly instructed
|
||||||
|
- Never run any git commands unless explicitly instructed
|
||||||
|
- Only use `crons.interval` or `crons.cron` methods, not deprecated helpers
|
||||||
|
- Always call internal functions from cron jobs for security
|
||||||
|
- Import `internal` from `_generated/api` even for functions in the same file
|
||||||
|
- Add logging and monitoring for production cron jobs
|
||||||
|
- Use batching for operations that process large datasets
|
||||||
|
- Handle errors gracefully to prevent job failures
|
||||||
|
- Use meaningful job names for dashboard visibility
|
||||||
|
- Consider timezone when using cron expressions (Convex uses UTC)
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Using public functions** - Cron jobs should call internal functions only
|
||||||
|
2. **Long-running mutations** - Break large operations into batches
|
||||||
|
3. **Missing error handling** - Unhandled errors will fail the entire job
|
||||||
|
4. **Forgetting timezone** - All cron expressions use UTC
|
||||||
|
5. **Using deprecated helpers** - Avoid `crons.hourly`, `crons.daily`, etc.
|
||||||
|
6. **Not logging execution** - Makes debugging production issues difficult
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Convex Documentation: https://docs.convex.dev/
|
||||||
|
- Convex LLMs.txt: https://docs.convex.dev/llms.txt
|
||||||
|
- Cron Jobs: https://docs.convex.dev/scheduling/cron-jobs
|
||||||
|
- Scheduling Overview: https://docs.convex.dev/scheduling
|
||||||
|
- Scheduled Functions: https://docs.convex.dev/scheduling/scheduled-functions
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
---
|
||||||
|
name: convex-file-storage
|
||||||
|
displayName: Convex File Storage
|
||||||
|
description: Complete file handling including upload flows, serving files via URL, storing generated files from actions, deletion, and accessing file metadata from system tables
|
||||||
|
version: 1.0.0
|
||||||
|
author: Convex
|
||||||
|
tags: [convex, file-storage, uploads, images, files]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Convex File Storage
|
||||||
|
|
||||||
|
Handle file uploads, storage, serving, and management in Convex applications with proper patterns for images, documents, and generated files.
|
||||||
|
|
||||||
|
## Documentation Sources
|
||||||
|
|
||||||
|
Before implementing, do not assume; fetch the latest documentation:
|
||||||
|
|
||||||
|
- Primary: https://docs.convex.dev/file-storage
|
||||||
|
- Upload Files: https://docs.convex.dev/file-storage/upload-files
|
||||||
|
- Serve Files: https://docs.convex.dev/file-storage/serve-files
|
||||||
|
- For broader context: https://docs.convex.dev/llms.txt
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### File Storage Overview
|
||||||
|
|
||||||
|
Convex provides built-in file storage with:
|
||||||
|
|
||||||
|
- Automatic URL generation for serving files
|
||||||
|
- Support for any file type (images, PDFs, videos, etc.)
|
||||||
|
- File metadata via the `_storage` system table
|
||||||
|
- Integration with mutations and actions
|
||||||
|
|
||||||
|
### Generating Upload URLs
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/files.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { mutation } from './_generated/server';
|
||||||
|
|
||||||
|
export const generateUploadUrl = mutation({
|
||||||
|
args: {},
|
||||||
|
returns: v.string(),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
return await ctx.storage.generateUploadUrl();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client-Side Upload
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// React component
|
||||||
|
import { useMutation } from "convex/react";
|
||||||
|
import { api } from "../convex/_generated/api";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
function FileUploader() {
|
||||||
|
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
||||||
|
const saveFile = useMutation(api.files.saveFile);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
// Step 1: Get upload URL
|
||||||
|
const uploadUrl = await generateUploadUrl();
|
||||||
|
|
||||||
|
// Step 2: Upload file to storage
|
||||||
|
const result = await fetch(uploadUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": file.type },
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { storageId } = await result.json();
|
||||||
|
|
||||||
|
// Step 3: Save file reference to database
|
||||||
|
await saveFile({
|
||||||
|
storageId,
|
||||||
|
fileName: file.name,
|
||||||
|
fileType: file.type,
|
||||||
|
fileSize: file.size,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
onChange={handleUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
{uploading && <p>Uploading...</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Saving File References
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/files.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
|
||||||
|
export const saveFile = mutation({
|
||||||
|
args: {
|
||||||
|
storageId: v.id('_storage'),
|
||||||
|
fileName: v.string(),
|
||||||
|
fileType: v.string(),
|
||||||
|
fileSize: v.number(),
|
||||||
|
},
|
||||||
|
returns: v.id('files'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db.insert('files', {
|
||||||
|
storageId: args.storageId,
|
||||||
|
fileName: args.fileName,
|
||||||
|
fileType: args.fileType,
|
||||||
|
fileSize: args.fileSize,
|
||||||
|
uploadedAt: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serving Files via URL
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/files.ts
|
||||||
|
export const getFileUrl = query({
|
||||||
|
args: { storageId: v.id('_storage') },
|
||||||
|
returns: v.union(v.string(), v.null()),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.storage.getUrl(args.storageId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get file with URL
|
||||||
|
export const getFile = query({
|
||||||
|
args: { fileId: v.id('files') },
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('files'),
|
||||||
|
fileName: v.string(),
|
||||||
|
fileType: v.string(),
|
||||||
|
fileSize: v.number(),
|
||||||
|
url: v.union(v.string(), v.null()),
|
||||||
|
}),
|
||||||
|
v.null(),
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const file = await ctx.db.get(args.fileId);
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
const url = await ctx.storage.getUrl(file.storageId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: file._id,
|
||||||
|
fileName: file.fileName,
|
||||||
|
fileType: file.fileType,
|
||||||
|
fileSize: file.fileSize,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Displaying Files in React
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useQuery } from "convex/react";
|
||||||
|
import { api } from "../convex/_generated/api";
|
||||||
|
|
||||||
|
function FileDisplay({ fileId }: { fileId: Id<"files"> }) {
|
||||||
|
const file = useQuery(api.files.getFile, { fileId });
|
||||||
|
|
||||||
|
if (!file) return <div>Loading...</div>;
|
||||||
|
if (!file.url) return <div>File not found</div>;
|
||||||
|
|
||||||
|
// Handle different file types
|
||||||
|
if (file.fileType.startsWith("image/")) {
|
||||||
|
return <img src={file.url} alt={file.fileName} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.fileType === "application/pdf") {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={file.url}
|
||||||
|
title={file.fileName}
|
||||||
|
width="100%"
|
||||||
|
height="600px"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={file.url} download={file.fileName}>
|
||||||
|
Download {file.fileName}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storing Generated Files from Actions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/generate.ts
|
||||||
|
'use node';
|
||||||
|
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { api } from './_generated/api';
|
||||||
|
import { action } from './_generated/server';
|
||||||
|
|
||||||
|
export const generatePDF = action({
|
||||||
|
args: { content: v.string() },
|
||||||
|
returns: v.id('_storage'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Generate PDF (example using a library)
|
||||||
|
const pdfBuffer = await generatePDFFromContent(args.content);
|
||||||
|
|
||||||
|
// Convert to Blob
|
||||||
|
const blob = new Blob([pdfBuffer], { type: 'application/pdf' });
|
||||||
|
|
||||||
|
// Store in Convex
|
||||||
|
const storageId = await ctx.storage.store(blob);
|
||||||
|
|
||||||
|
return storageId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate and save image
|
||||||
|
export const generateImage = action({
|
||||||
|
args: { prompt: v.string() },
|
||||||
|
returns: v.id('_storage'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Call external API to generate image
|
||||||
|
const response = await fetch('https://api.example.com/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ prompt: args.prompt }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageBuffer = await response.arrayBuffer();
|
||||||
|
const blob = new Blob([imageBuffer], { type: 'image/png' });
|
||||||
|
|
||||||
|
return await ctx.storage.store(blob);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing File Metadata
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/files.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { Id } from './_generated/dataModel';
|
||||||
|
import { query } from './_generated/server';
|
||||||
|
|
||||||
|
type FileMetadata = {
|
||||||
|
_id: Id<'_storage'>;
|
||||||
|
_creationTime: number;
|
||||||
|
contentType?: string;
|
||||||
|
sha256: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFileMetadata = query({
|
||||||
|
args: { storageId: v.id('_storage') },
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('_storage'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
contentType: v.optional(v.string()),
|
||||||
|
sha256: v.string(),
|
||||||
|
size: v.number(),
|
||||||
|
}),
|
||||||
|
v.null(),
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const metadata = await ctx.db.system.get(args.storageId);
|
||||||
|
return metadata as FileMetadata | null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deleting Files
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/files.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { mutation } from './_generated/server';
|
||||||
|
|
||||||
|
export const deleteFile = mutation({
|
||||||
|
args: { fileId: v.id('files') },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const file = await ctx.db.get(args.fileId);
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
// Delete from storage
|
||||||
|
await ctx.storage.delete(file.storageId);
|
||||||
|
|
||||||
|
// Delete database record
|
||||||
|
await ctx.db.delete(args.fileId);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Image Upload with Preview
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useMutation } from "convex/react";
|
||||||
|
import { api } from "../convex/_generated/api";
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
|
||||||
|
function ImageUploader({ onUpload }: { onUpload: (id: Id<"files">) => void }) {
|
||||||
|
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
||||||
|
const saveFile = useMutation(api.files.saveFile);
|
||||||
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
alert("Please select an image file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (max 10MB)
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
alert("File size must be less than 10MB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show preview
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => setPreview(e.target?.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const uploadUrl = await generateUploadUrl();
|
||||||
|
const result = await fetch(uploadUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": file.type },
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { storageId } = await result.json();
|
||||||
|
const fileId = await saveFile({
|
||||||
|
storageId,
|
||||||
|
fileName: file.name,
|
||||||
|
fileType: file.type,
|
||||||
|
fileSize: file.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
onUpload(fileId);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
{uploading ? "Uploading..." : "Select Image"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<img
|
||||||
|
src={preview}
|
||||||
|
alt="Preview"
|
||||||
|
style={{ maxWidth: 200, marginTop: 10 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Schema for File Storage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/schema.ts
|
||||||
|
import { defineSchema, defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export default defineSchema({
|
||||||
|
files: defineTable({
|
||||||
|
storageId: v.id('_storage'),
|
||||||
|
fileName: v.string(),
|
||||||
|
fileType: v.string(),
|
||||||
|
fileSize: v.number(),
|
||||||
|
uploadedBy: v.id('users'),
|
||||||
|
uploadedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_user', ['uploadedBy'])
|
||||||
|
.index('by_type', ['fileType']),
|
||||||
|
|
||||||
|
// User avatars
|
||||||
|
users: defineTable({
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
avatarStorageId: v.optional(v.id('_storage')),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Posts with images
|
||||||
|
posts: defineTable({
|
||||||
|
authorId: v.id('users'),
|
||||||
|
content: v.string(),
|
||||||
|
imageStorageIds: v.array(v.id('_storage')),
|
||||||
|
createdAt: v.number(),
|
||||||
|
}).index('by_author', ['authorId']),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Never run `npx convex deploy` unless explicitly instructed
|
||||||
|
- Never run any git commands unless explicitly instructed
|
||||||
|
- Validate file types and sizes on the client before uploading
|
||||||
|
- Store file metadata (name, type, size) in your own table
|
||||||
|
- Use the `_storage` system table only for Convex metadata
|
||||||
|
- Delete storage files when deleting database references
|
||||||
|
- Use appropriate Content-Type headers when uploading
|
||||||
|
- Consider image optimization for large images
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Not setting Content-Type header** - Files may not serve correctly
|
||||||
|
2. **Forgetting to delete storage** - Orphaned files waste storage
|
||||||
|
3. **Not validating file types** - Security risk for malicious uploads
|
||||||
|
4. **Large file uploads without progress** - Poor UX for users
|
||||||
|
5. **Using deprecated getMetadata** - Use ctx.db.system.get instead
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Convex Documentation: https://docs.convex.dev/
|
||||||
|
- Convex LLMs.txt: https://docs.convex.dev/llms.txt
|
||||||
|
- File Storage: https://docs.convex.dev/file-storage
|
||||||
|
- Upload Files: https://docs.convex.dev/file-storage/upload-files
|
||||||
|
- Serve Files: https://docs.convex.dev/file-storage/serve-files
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
---
|
||||||
|
name: convex-functions
|
||||||
|
displayName: Convex Functions
|
||||||
|
description: Writing queries, mutations, actions, and HTTP actions with proper argument validation, error handling, internal functions, and runtime considerations
|
||||||
|
version: 1.0.0
|
||||||
|
author: Convex
|
||||||
|
tags: [convex, functions, queries, mutations, actions, http]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Convex Functions
|
||||||
|
|
||||||
|
Master Convex functions including queries, mutations, actions, and HTTP endpoints with proper validation, error handling, and runtime considerations.
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
All examples in this skill comply with @convex-dev/eslint-plugin rules:
|
||||||
|
|
||||||
|
- Object syntax with `handler` property
|
||||||
|
- Argument validators on all functions
|
||||||
|
- Explicit table names in database operations
|
||||||
|
|
||||||
|
See the Code Quality section in [convex-best-practices](../convex-best-practices/SKILL.md) for linting setup.
|
||||||
|
|
||||||
|
## Documentation Sources
|
||||||
|
|
||||||
|
Before implementing, do not assume; fetch the latest documentation:
|
||||||
|
|
||||||
|
- Primary: https://docs.convex.dev/functions
|
||||||
|
- Query Functions: https://docs.convex.dev/functions/query-functions
|
||||||
|
- Mutation Functions: https://docs.convex.dev/functions/mutation-functions
|
||||||
|
- Actions: https://docs.convex.dev/functions/actions
|
||||||
|
- HTTP Actions: https://docs.convex.dev/functions/http-actions
|
||||||
|
- For broader context: https://docs.convex.dev/llms.txt
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### Function Types Overview
|
||||||
|
|
||||||
|
| Type | Database Access | External APIs | Caching | Use Case |
|
||||||
|
| ----------- | ------------------------ | ------------- | ------------- | --------------------- |
|
||||||
|
| Query | Read-only | No | Yes, reactive | Fetching data |
|
||||||
|
| Mutation | Read/Write | No | No | Modifying data |
|
||||||
|
| Action | Via runQuery/runMutation | Yes | No | External integrations |
|
||||||
|
| HTTP Action | Via runQuery/runMutation | Yes | No | Webhooks, APIs |
|
||||||
|
|
||||||
|
### Queries
|
||||||
|
|
||||||
|
Queries are reactive, cached, and read-only:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { query } from './_generated/server';
|
||||||
|
|
||||||
|
export const getUser = query({
|
||||||
|
args: { userId: v.id('users') },
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('users'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
}),
|
||||||
|
v.null(),
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db.get('users', args.userId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query with index
|
||||||
|
export const listUserTasks = query({
|
||||||
|
args: { userId: v.id('users') },
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('tasks'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
title: v.string(),
|
||||||
|
completed: v.boolean(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query('tasks')
|
||||||
|
.withIndex('by_user', (q) => q.eq('userId', args.userId))
|
||||||
|
.order('desc')
|
||||||
|
.collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mutations
|
||||||
|
|
||||||
|
Mutations modify the database and are transactional:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
|
import { mutation } from './_generated/server';
|
||||||
|
|
||||||
|
export const createTask = mutation({
|
||||||
|
args: {
|
||||||
|
title: v.string(),
|
||||||
|
userId: v.id('users'),
|
||||||
|
},
|
||||||
|
returns: v.id('tasks'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Validate user exists
|
||||||
|
const user = await ctx.db.get('users', args.userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new ConvexError('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ctx.db.insert('tasks', {
|
||||||
|
title: args.title,
|
||||||
|
userId: args.userId,
|
||||||
|
completed: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteTask = mutation({
|
||||||
|
args: { taskId: v.id('tasks') },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.delete('tasks', args.taskId);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
Actions can call external APIs but have no direct database access:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use node';
|
||||||
|
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { api, internal } from './_generated/api';
|
||||||
|
import { action } from './_generated/server';
|
||||||
|
|
||||||
|
export const sendEmail = action({
|
||||||
|
args: {
|
||||||
|
to: v.string(),
|
||||||
|
subject: v.string(),
|
||||||
|
body: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.object({ success: v.boolean() }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Call external API
|
||||||
|
const response = await fetch('https://api.email.com/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(args),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: response.ok };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Action calling queries and mutations
|
||||||
|
export const processOrder = action({
|
||||||
|
args: { orderId: v.id('orders') },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Read data via query
|
||||||
|
const order = await ctx.runQuery(api.orders.get, { orderId: args.orderId });
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new Error('Order not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call external payment API
|
||||||
|
const paymentResult = await processPayment(order);
|
||||||
|
|
||||||
|
// Update database via mutation
|
||||||
|
await ctx.runMutation(internal.orders.updateStatus, {
|
||||||
|
orderId: args.orderId,
|
||||||
|
status: paymentResult.success ? 'paid' : 'failed',
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTP Actions
|
||||||
|
|
||||||
|
HTTP actions handle webhooks and external requests:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/http.ts
|
||||||
|
import { httpRouter } from 'convex/server';
|
||||||
|
|
||||||
|
import { api, internal } from './_generated/api';
|
||||||
|
import { httpAction } from './_generated/server';
|
||||||
|
|
||||||
|
const http = httpRouter();
|
||||||
|
|
||||||
|
// Webhook endpoint
|
||||||
|
http.route({
|
||||||
|
path: '/webhooks/stripe',
|
||||||
|
method: 'POST',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
const signature = request.headers.get('stripe-signature');
|
||||||
|
const body = await request.text();
|
||||||
|
|
||||||
|
// Verify webhook signature
|
||||||
|
if (!verifyStripeSignature(body, signature)) {
|
||||||
|
return new Response('Invalid signature', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = JSON.parse(body);
|
||||||
|
|
||||||
|
// Process webhook
|
||||||
|
await ctx.runMutation(internal.payments.handleWebhook, {
|
||||||
|
eventType: event.type,
|
||||||
|
data: event.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response('OK', { status: 200 });
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// API endpoint
|
||||||
|
http.route({
|
||||||
|
path: '/api/users/:userId',
|
||||||
|
method: 'GET',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const userId = url.pathname.split('/').pop();
|
||||||
|
|
||||||
|
const user = await ctx.runQuery(api.users.get, {
|
||||||
|
userId: userId as Id<'users'>,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(user);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default http;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Internal Functions
|
||||||
|
|
||||||
|
Use internal functions for sensitive operations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import {
|
||||||
|
internalAction,
|
||||||
|
internalMutation,
|
||||||
|
internalQuery,
|
||||||
|
} from './_generated/server';
|
||||||
|
|
||||||
|
// Only callable from other Convex functions
|
||||||
|
export const _updateUserCredits = internalMutation({
|
||||||
|
args: {
|
||||||
|
userId: v.id('users'),
|
||||||
|
amount: v.number(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await ctx.db.get('users', args.userId);
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
await ctx.db.patch('users', args.userId, {
|
||||||
|
credits: (user.credits || 0) + args.amount,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call internal function from action
|
||||||
|
export const purchaseCredits = action({
|
||||||
|
args: { userId: v.id('users'), amount: v.number() },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Process payment externally
|
||||||
|
await processPayment(args.amount);
|
||||||
|
|
||||||
|
// Update credits via internal mutation
|
||||||
|
await ctx.runMutation(internal.users._updateUserCredits, {
|
||||||
|
userId: args.userId,
|
||||||
|
amount: args.amount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scheduling Functions
|
||||||
|
|
||||||
|
Schedule functions to run later:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { internalMutation, mutation } from './_generated/server';
|
||||||
|
|
||||||
|
export const scheduleReminder = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.id('users'),
|
||||||
|
message: v.string(),
|
||||||
|
delayMs: v.number(),
|
||||||
|
},
|
||||||
|
returns: v.id('_scheduled_functions'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.scheduler.runAfter(
|
||||||
|
args.delayMs,
|
||||||
|
internal.notifications.sendReminder,
|
||||||
|
{ userId: args.userId, message: args.message },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sendReminder = internalMutation({
|
||||||
|
args: {
|
||||||
|
userId: v.id('users'),
|
||||||
|
message: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.insert('notifications', {
|
||||||
|
userId: args.userId,
|
||||||
|
message: args.message,
|
||||||
|
sentAt: Date.now(),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Complete Function File
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/messages.ts
|
||||||
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { internalMutation, mutation, query } from './_generated/server';
|
||||||
|
|
||||||
|
const messageValidator = v.object({
|
||||||
|
_id: v.id('messages'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
channelId: v.id('channels'),
|
||||||
|
authorId: v.id('users'),
|
||||||
|
content: v.string(),
|
||||||
|
editedAt: v.optional(v.number()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Public query
|
||||||
|
export const list = query({
|
||||||
|
args: {
|
||||||
|
channelId: v.id('channels'),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
returns: v.array(messageValidator),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const limit = args.limit ?? 50;
|
||||||
|
return await ctx.db
|
||||||
|
.query('messages')
|
||||||
|
.withIndex('by_channel', (q) => q.eq('channelId', args.channelId))
|
||||||
|
.order('desc')
|
||||||
|
.take(limit);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Public mutation
|
||||||
|
export const send = mutation({
|
||||||
|
args: {
|
||||||
|
channelId: v.id('channels'),
|
||||||
|
authorId: v.id('users'),
|
||||||
|
content: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.id('messages'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
if (args.content.trim().length === 0) {
|
||||||
|
throw new ConvexError('Message cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = await ctx.db.insert('messages', {
|
||||||
|
channelId: args.channelId,
|
||||||
|
authorId: args.authorId,
|
||||||
|
content: args.content.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schedule notification
|
||||||
|
await ctx.scheduler.runAfter(0, internal.messages.notifySubscribers, {
|
||||||
|
channelId: args.channelId,
|
||||||
|
messageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return messageId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Internal mutation
|
||||||
|
export const notifySubscribers = internalMutation({
|
||||||
|
args: {
|
||||||
|
channelId: v.id('channels'),
|
||||||
|
messageId: v.id('messages'),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Get channel subscribers and notify them
|
||||||
|
const subscribers = await ctx.db
|
||||||
|
.query('subscriptions')
|
||||||
|
.withIndex('by_channel', (q) => q.eq('channelId', args.channelId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const sub of subscribers) {
|
||||||
|
await ctx.db.insert('notifications', {
|
||||||
|
userId: sub.userId,
|
||||||
|
messageId: args.messageId,
|
||||||
|
read: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Never run `npx convex deploy` unless explicitly instructed
|
||||||
|
- Never run any git commands unless explicitly instructed
|
||||||
|
- Always define args and returns validators
|
||||||
|
- Use queries for read operations (they are cached and reactive)
|
||||||
|
- Use mutations for write operations (they are transactional)
|
||||||
|
- Use actions only when calling external APIs
|
||||||
|
- Use internal functions for sensitive operations
|
||||||
|
- Add `"use node";` at the top of action files using Node.js APIs
|
||||||
|
- Handle errors with ConvexError for user-facing messages
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Using actions for database operations** - Use queries/mutations instead
|
||||||
|
2. **Calling external APIs from queries/mutations** - Use actions
|
||||||
|
3. **Forgetting to add "use node"** - Required for Node.js APIs in actions
|
||||||
|
4. **Missing return validators** - Always specify returns
|
||||||
|
5. **Not using internal functions for sensitive logic** - Protect with internalMutation
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Convex Documentation: https://docs.convex.dev/
|
||||||
|
- Convex LLMs.txt: https://docs.convex.dev/llms.txt
|
||||||
|
- Functions Overview: https://docs.convex.dev/functions
|
||||||
|
- Query Functions: https://docs.convex.dev/functions/query-functions
|
||||||
|
- Mutation Functions: https://docs.convex.dev/functions/mutation-functions
|
||||||
|
- Actions: https://docs.convex.dev/functions/actions
|
||||||
@@ -0,0 +1,735 @@
|
|||||||
|
---
|
||||||
|
name: convex-http-actions
|
||||||
|
displayName: Convex HTTP Actions
|
||||||
|
description: External API integration and webhook handling including HTTP endpoint routing, request/response handling, authentication, CORS configuration, and webhook signature validation
|
||||||
|
version: 1.0.0
|
||||||
|
author: Convex
|
||||||
|
tags: [convex, http, actions, webhooks, api, endpoints]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Convex HTTP Actions
|
||||||
|
|
||||||
|
Build HTTP endpoints for webhooks, external API integrations, and custom routes in Convex applications.
|
||||||
|
|
||||||
|
## Documentation Sources
|
||||||
|
|
||||||
|
Before implementing, do not assume; fetch the latest documentation:
|
||||||
|
|
||||||
|
- Primary: https://docs.convex.dev/functions/http-actions
|
||||||
|
- Actions Overview: https://docs.convex.dev/functions/actions
|
||||||
|
- Authentication: https://docs.convex.dev/auth
|
||||||
|
- For broader context: https://docs.convex.dev/llms.txt
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### HTTP Actions Overview
|
||||||
|
|
||||||
|
HTTP actions allow you to define HTTP endpoints in Convex that can:
|
||||||
|
|
||||||
|
- Receive webhooks from third-party services
|
||||||
|
- Create custom API routes
|
||||||
|
- Handle file uploads
|
||||||
|
- Integrate with external services
|
||||||
|
- Serve dynamic content
|
||||||
|
|
||||||
|
### Basic HTTP Router Setup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/http.ts
|
||||||
|
import { httpRouter } from 'convex/server';
|
||||||
|
|
||||||
|
import { httpAction } from './_generated/server';
|
||||||
|
|
||||||
|
const http = httpRouter();
|
||||||
|
|
||||||
|
// Simple GET endpoint
|
||||||
|
http.route({
|
||||||
|
path: '/health',
|
||||||
|
method: 'GET',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
return new Response(JSON.stringify({ status: 'ok' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default http;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/http.ts
|
||||||
|
import { httpRouter } from 'convex/server';
|
||||||
|
|
||||||
|
import { httpAction } from './_generated/server';
|
||||||
|
|
||||||
|
const http = httpRouter();
|
||||||
|
|
||||||
|
// Handle JSON body
|
||||||
|
http.route({
|
||||||
|
path: '/api/data',
|
||||||
|
method: 'POST',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
// Parse JSON body
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Access headers
|
||||||
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
|
||||||
|
// Access URL parameters
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const queryParam = url.searchParams.get('filter');
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ received: body, filter: queryParam }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle form data
|
||||||
|
http.route({
|
||||||
|
path: '/api/form',
|
||||||
|
method: 'POST',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = formData.get('name');
|
||||||
|
const email = formData.get('email');
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ name, email }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle raw bytes
|
||||||
|
http.route({
|
||||||
|
path: '/api/upload',
|
||||||
|
method: 'POST',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
const bytes = await request.bytes();
|
||||||
|
const contentType =
|
||||||
|
request.headers.get('Content-Type') ?? 'application/octet-stream';
|
||||||
|
|
||||||
|
// Store in Convex storage
|
||||||
|
const blob = new Blob([bytes], { type: contentType });
|
||||||
|
const storageId = await ctx.storage.store(blob);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ storageId }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default http;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
|
||||||
|
Use path prefix matching for dynamic routes:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/http.ts
|
||||||
|
import { httpRouter } from 'convex/server';
|
||||||
|
|
||||||
|
import { httpAction } from './_generated/server';
|
||||||
|
|
||||||
|
const http = httpRouter();
|
||||||
|
|
||||||
|
// Match /api/users/* with pathPrefix
|
||||||
|
http.route({
|
||||||
|
pathPrefix: '/api/users/',
|
||||||
|
method: 'GET',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
// Extract user ID from path: /api/users/123 -> "123"
|
||||||
|
const userId = url.pathname.replace('/api/users/', '');
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ userId }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default http;
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORS Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/http.ts
|
||||||
|
import { httpRouter } from 'convex/server';
|
||||||
|
|
||||||
|
import { httpAction } from './_generated/server';
|
||||||
|
|
||||||
|
const http = httpRouter();
|
||||||
|
|
||||||
|
// CORS headers helper
|
||||||
|
const corsHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
'Access-Control-Max-Age': '86400',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle preflight requests
|
||||||
|
http.route({
|
||||||
|
path: '/api/data',
|
||||||
|
method: 'OPTIONS',
|
||||||
|
handler: httpAction(async () => {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: corsHeaders,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Actual endpoint with CORS
|
||||||
|
http.route({
|
||||||
|
path: '/api/data',
|
||||||
|
method: 'POST',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ success: true, data: body }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...corsHeaders,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default http;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webhook Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/http.ts
|
||||||
|
import { httpRouter } from 'convex/server';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { httpAction } from './_generated/server';
|
||||||
|
|
||||||
|
const http = httpRouter();
|
||||||
|
|
||||||
|
// Stripe webhook
|
||||||
|
http.route({
|
||||||
|
path: '/webhooks/stripe',
|
||||||
|
method: 'POST',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
const signature = request.headers.get('stripe-signature');
|
||||||
|
if (!signature) {
|
||||||
|
return new Response('Missing signature', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.text();
|
||||||
|
|
||||||
|
// Verify webhook signature (in action with Node.js)
|
||||||
|
try {
|
||||||
|
await ctx.runAction(internal.stripe.verifyAndProcessWebhook, {
|
||||||
|
body,
|
||||||
|
signature,
|
||||||
|
});
|
||||||
|
return new Response('OK', { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Webhook error:', error);
|
||||||
|
return new Response('Webhook error', { status: 400 });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// GitHub webhook
|
||||||
|
http.route({
|
||||||
|
path: '/webhooks/github',
|
||||||
|
method: 'POST',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
const event = request.headers.get('X-GitHub-Event');
|
||||||
|
const signature = request.headers.get('X-Hub-Signature-256');
|
||||||
|
|
||||||
|
if (!signature) {
|
||||||
|
return new Response('Missing signature', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.text();
|
||||||
|
|
||||||
|
await ctx.runAction(internal.github.processWebhook, {
|
||||||
|
event: event ?? 'unknown',
|
||||||
|
body,
|
||||||
|
signature,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response('OK', { status: 200 });
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default http;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webhook Signature Verification
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/stripe.ts
|
||||||
|
'use node';
|
||||||
|
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { internalAction, internalMutation } from './_generated/server';
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||||||
|
|
||||||
|
export const verifyAndProcessWebhook = internalAction({
|
||||||
|
args: {
|
||||||
|
body: v.string(),
|
||||||
|
signature: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
const event = stripe.webhooks.constructEvent(
|
||||||
|
args.body,
|
||||||
|
args.signature,
|
||||||
|
webhookSecret,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process based on event type
|
||||||
|
switch (event.type) {
|
||||||
|
case 'checkout.session.completed':
|
||||||
|
await ctx.runMutation(internal.payments.handleCheckoutComplete, {
|
||||||
|
sessionId: event.data.object.id,
|
||||||
|
customerId: event.data.object.customer as string,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'customer.subscription.updated':
|
||||||
|
await ctx.runMutation(internal.subscriptions.handleUpdate, {
|
||||||
|
subscriptionId: event.data.object.id,
|
||||||
|
status: event.data.object.status,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication in HTTP Actions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/http.ts
|
||||||
|
import { httpRouter } from 'convex/server';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { httpAction } from './_generated/server';
|
||||||
|
|
||||||
|
const http = httpRouter();
|
||||||
|
|
||||||
|
// API key authentication
|
||||||
|
http.route({
|
||||||
|
path: '/api/protected',
|
||||||
|
method: 'GET',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
const apiKey = request.headers.get('X-API-Key');
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Missing API key' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate API key
|
||||||
|
const isValid = await ctx.runQuery(internal.auth.validateApiKey, {
|
||||||
|
apiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Invalid API key' }), {
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process authenticated request
|
||||||
|
const data = await ctx.runQuery(internal.data.getProtectedData, {});
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bearer token authentication
|
||||||
|
http.route({
|
||||||
|
path: '/api/user',
|
||||||
|
method: 'GET',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing or invalid Authorization header' }),
|
||||||
|
{ status: 401, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
|
||||||
|
// Validate token and get user
|
||||||
|
const user = await ctx.runQuery(internal.auth.validateToken, { token });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Invalid token' }), {
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(user), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default http;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Calling Mutations and Queries
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/http.ts
|
||||||
|
import { httpRouter } from 'convex/server';
|
||||||
|
|
||||||
|
import { api, internal } from './_generated/api';
|
||||||
|
import { httpAction } from './_generated/server';
|
||||||
|
|
||||||
|
const http = httpRouter();
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
path: '/api/items',
|
||||||
|
method: 'POST',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Call a mutation
|
||||||
|
const itemId = await ctx.runMutation(internal.items.create, {
|
||||||
|
name: body.name,
|
||||||
|
description: body.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query the created item
|
||||||
|
const item = await ctx.runQuery(internal.items.get, { id: itemId });
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(item), {
|
||||||
|
status: 201,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
path: '/api/items',
|
||||||
|
method: 'GET',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const limit = parseInt(url.searchParams.get('limit') ?? '10');
|
||||||
|
|
||||||
|
const items = await ctx.runQuery(internal.items.list, { limit });
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(items), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default http;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/http.ts
|
||||||
|
import { httpRouter } from 'convex/server';
|
||||||
|
|
||||||
|
import { httpAction } from './_generated/server';
|
||||||
|
|
||||||
|
const http = httpRouter();
|
||||||
|
|
||||||
|
// Helper for JSON responses
|
||||||
|
function jsonResponse(data: unknown, status = 200) {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for error responses
|
||||||
|
function errorResponse(message: string, status: number) {
|
||||||
|
return jsonResponse({ error: message }, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
path: '/api/process',
|
||||||
|
method: 'POST',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
try {
|
||||||
|
// Validate content type
|
||||||
|
const contentType = request.headers.get('Content-Type');
|
||||||
|
if (!contentType?.includes('application/json')) {
|
||||||
|
return errorResponse('Content-Type must be application/json', 415);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse body
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON body', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!body.data) {
|
||||||
|
return errorResponse('Missing required field: data', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process request
|
||||||
|
const result = await ctx.runMutation(internal.process.handle, {
|
||||||
|
data: body.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, result }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Processing error:', error);
|
||||||
|
return errorResponse('Internal server error', 500);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default http;
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Downloads
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/http.ts
|
||||||
|
import { httpRouter } from 'convex/server';
|
||||||
|
|
||||||
|
import { Id } from './_generated/dataModel';
|
||||||
|
import { httpAction } from './_generated/server';
|
||||||
|
|
||||||
|
const http = httpRouter();
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
pathPrefix: '/files/',
|
||||||
|
method: 'GET',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const fileId = url.pathname.replace('/files/', '') as Id<'_storage'>;
|
||||||
|
|
||||||
|
// Get file URL from storage
|
||||||
|
const fileUrl = await ctx.storage.getUrl(fileId);
|
||||||
|
|
||||||
|
if (!fileUrl) {
|
||||||
|
return new Response('File not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to the file URL
|
||||||
|
return Response.redirect(fileUrl, 302);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default http;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Complete Webhook Integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/http.ts
|
||||||
|
import { httpRouter } from 'convex/server';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { httpAction } from './_generated/server';
|
||||||
|
|
||||||
|
const http = httpRouter();
|
||||||
|
|
||||||
|
// Clerk webhook for user sync
|
||||||
|
http.route({
|
||||||
|
path: '/webhooks/clerk',
|
||||||
|
method: 'POST',
|
||||||
|
handler: httpAction(async (ctx, request) => {
|
||||||
|
const svixId = request.headers.get('svix-id');
|
||||||
|
const svixTimestamp = request.headers.get('svix-timestamp');
|
||||||
|
const svixSignature = request.headers.get('svix-signature');
|
||||||
|
|
||||||
|
if (!svixId || !svixTimestamp || !svixSignature) {
|
||||||
|
return new Response('Missing Svix headers', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.text();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ctx.runAction(internal.clerk.verifyAndProcess, {
|
||||||
|
body,
|
||||||
|
svixId,
|
||||||
|
svixTimestamp,
|
||||||
|
svixSignature,
|
||||||
|
});
|
||||||
|
return new Response('OK', { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Clerk webhook error:', error);
|
||||||
|
return new Response('Webhook verification failed', { status: 400 });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default http;
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/clerk.ts
|
||||||
|
'use node';
|
||||||
|
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
import { Webhook } from 'svix';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { internalAction, internalMutation } from './_generated/server';
|
||||||
|
|
||||||
|
export const verifyAndProcess = internalAction({
|
||||||
|
args: {
|
||||||
|
body: v.string(),
|
||||||
|
svixId: v.string(),
|
||||||
|
svixTimestamp: v.string(),
|
||||||
|
svixSignature: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const webhookSecret = process.env.CLERK_WEBHOOK_SECRET!;
|
||||||
|
const wh = new Webhook(webhookSecret);
|
||||||
|
|
||||||
|
const event = wh.verify(args.body, {
|
||||||
|
'svix-id': args.svixId,
|
||||||
|
'svix-timestamp': args.svixTimestamp,
|
||||||
|
'svix-signature': args.svixSignature,
|
||||||
|
}) as { type: string; data: Record<string, unknown> };
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'user.created':
|
||||||
|
await ctx.runMutation(internal.users.create, {
|
||||||
|
clerkId: event.data.id as string,
|
||||||
|
email: (
|
||||||
|
event.data.email_addresses as Array<{ email_address: string }>
|
||||||
|
)[0]?.email_address,
|
||||||
|
name: `${event.data.first_name} ${event.data.last_name}`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'user.updated':
|
||||||
|
await ctx.runMutation(internal.users.update, {
|
||||||
|
clerkId: event.data.id as string,
|
||||||
|
email: (
|
||||||
|
event.data.email_addresses as Array<{ email_address: string }>
|
||||||
|
)[0]?.email_address,
|
||||||
|
name: `${event.data.first_name} ${event.data.last_name}`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'user.deleted':
|
||||||
|
await ctx.runMutation(internal.users.remove, {
|
||||||
|
clerkId: event.data.id as string,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema for HTTP API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/schema.ts
|
||||||
|
import { defineSchema, defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export default defineSchema({
|
||||||
|
apiKeys: defineTable({
|
||||||
|
key: v.string(),
|
||||||
|
userId: v.id('users'),
|
||||||
|
name: v.string(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
lastUsedAt: v.optional(v.number()),
|
||||||
|
revokedAt: v.optional(v.number()),
|
||||||
|
})
|
||||||
|
.index('by_key', ['key'])
|
||||||
|
.index('by_user', ['userId']),
|
||||||
|
|
||||||
|
webhookEvents: defineTable({
|
||||||
|
source: v.string(),
|
||||||
|
eventType: v.string(),
|
||||||
|
payload: v.any(),
|
||||||
|
processedAt: v.number(),
|
||||||
|
status: v.union(v.literal('success'), v.literal('failed')),
|
||||||
|
error: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
.index('by_source', ['source'])
|
||||||
|
.index('by_status', ['status']),
|
||||||
|
|
||||||
|
users: defineTable({
|
||||||
|
clerkId: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
name: v.string(),
|
||||||
|
}).index('by_clerk_id', ['clerkId']),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Never run `npx convex deploy` unless explicitly instructed
|
||||||
|
- Never run any git commands unless explicitly instructed
|
||||||
|
- Always validate and sanitize incoming request data
|
||||||
|
- Use internal functions for database operations
|
||||||
|
- Implement proper error handling with appropriate status codes
|
||||||
|
- Add CORS headers for browser-accessible endpoints
|
||||||
|
- Verify webhook signatures before processing
|
||||||
|
- Log webhook events for debugging
|
||||||
|
- Use environment variables for secrets
|
||||||
|
- Handle timeouts gracefully
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Missing CORS preflight handler** - Browsers send OPTIONS requests first
|
||||||
|
2. **Not validating webhook signatures** - Security vulnerability
|
||||||
|
3. **Exposing internal functions** - Use internal functions from HTTP actions
|
||||||
|
4. **Forgetting Content-Type headers** - Clients may not parse responses correctly
|
||||||
|
5. **Not handling request body errors** - Invalid JSON will throw
|
||||||
|
6. **Blocking on long operations** - Use scheduled functions for heavy processing
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Convex Documentation: https://docs.convex.dev/
|
||||||
|
- Convex LLMs.txt: https://docs.convex.dev/llms.txt
|
||||||
|
- HTTP Actions: https://docs.convex.dev/functions/http-actions
|
||||||
|
- Actions: https://docs.convex.dev/functions/actions
|
||||||
|
- Authentication: https://docs.convex.dev/auth
|
||||||
@@ -0,0 +1,732 @@
|
|||||||
|
---
|
||||||
|
name: convex-migrations
|
||||||
|
displayName: Convex Migrations
|
||||||
|
description: Schema migration strategies for evolving applications including adding new fields, backfilling data, removing deprecated fields, index migrations, and zero-downtime migration patterns
|
||||||
|
version: 1.0.0
|
||||||
|
author: Convex
|
||||||
|
tags: [convex, migrations, schema, database, data-modeling]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Convex Migrations
|
||||||
|
|
||||||
|
Evolve your Convex database schema safely with patterns for adding fields, backfilling data, removing deprecated fields, and maintaining zero-downtime deployments.
|
||||||
|
|
||||||
|
## Documentation Sources
|
||||||
|
|
||||||
|
Before implementing, do not assume; fetch the latest documentation:
|
||||||
|
|
||||||
|
- Primary: https://docs.convex.dev/database/schemas
|
||||||
|
- Schema Overview: https://docs.convex.dev/database
|
||||||
|
- Migration Patterns: https://stack.convex.dev/migrate-data-postgres-to-convex
|
||||||
|
- For broader context: https://docs.convex.dev/llms.txt
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### Migration Philosophy
|
||||||
|
|
||||||
|
Convex handles schema evolution differently than traditional databases:
|
||||||
|
|
||||||
|
- No explicit migration files or commands
|
||||||
|
- Schema changes deploy instantly with `npx convex dev`
|
||||||
|
- Existing data is not automatically transformed
|
||||||
|
- Use optional fields and backfill mutations for safe migrations
|
||||||
|
|
||||||
|
### Adding New Fields
|
||||||
|
|
||||||
|
Start with optional fields, then backfill:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Step 1: Add optional field to schema
|
||||||
|
// convex/schema.ts
|
||||||
|
import { defineSchema, defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export default defineSchema({
|
||||||
|
users: defineTable({
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
// New field - start as optional
|
||||||
|
avatarUrl: v.optional(v.string()),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Step 2: Update code to handle both cases
|
||||||
|
// convex/users.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { query } from './_generated/server';
|
||||||
|
|
||||||
|
export const getUser = query({
|
||||||
|
args: { userId: v.id('users') },
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('users'),
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
avatarUrl: v.union(v.string(), v.null()),
|
||||||
|
}),
|
||||||
|
v.null(),
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await ctx.db.get(args.userId);
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: user._id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
// Handle missing field gracefully
|
||||||
|
avatarUrl: user.avatarUrl ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Step 3: Backfill existing documents
|
||||||
|
// convex/migrations.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { internalMutation } from './_generated/server';
|
||||||
|
|
||||||
|
const BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
export const backfillAvatarUrl = internalMutation({
|
||||||
|
args: {
|
||||||
|
cursor: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
returns: v.object({
|
||||||
|
processed: v.number(),
|
||||||
|
hasMore: v.boolean(),
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const result = await ctx.db
|
||||||
|
.query('users')
|
||||||
|
.paginate({ numItems: BATCH_SIZE, cursor: args.cursor ?? null });
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
for (const user of result.page) {
|
||||||
|
// Only update if field is missing
|
||||||
|
if (user.avatarUrl === undefined) {
|
||||||
|
await ctx.db.patch(user._id, {
|
||||||
|
avatarUrl: generateDefaultAvatar(user.name),
|
||||||
|
});
|
||||||
|
processed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule next batch if needed
|
||||||
|
if (!result.isDone) {
|
||||||
|
await ctx.scheduler.runAfter(0, internal.migrations.backfillAvatarUrl, {
|
||||||
|
cursor: result.continueCursor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
processed,
|
||||||
|
hasMore: !result.isDone,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function generateDefaultAvatar(name: string): string {
|
||||||
|
return `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(name)}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Step 4: After backfill completes, make field required
|
||||||
|
// convex/schema.ts
|
||||||
|
export default defineSchema({
|
||||||
|
users: defineTable({
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
avatarUrl: v.string(), // Now required
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Removing Fields
|
||||||
|
|
||||||
|
Remove field usage before removing from schema:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Step 1: Stop using the field in queries and mutations
|
||||||
|
// Mark as deprecated in code comments
|
||||||
|
|
||||||
|
// Step 2: Remove field from schema (make optional first if needed)
|
||||||
|
// convex/schema.ts
|
||||||
|
export default defineSchema({
|
||||||
|
posts: defineTable({
|
||||||
|
title: v.string(),
|
||||||
|
content: v.string(),
|
||||||
|
authorId: v.id('users'),
|
||||||
|
// legacyField: v.optional(v.string()), // Remove this line
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Optionally clean up existing data
|
||||||
|
// convex/migrations.ts
|
||||||
|
export const removeDeprecatedField = internalMutation({
|
||||||
|
args: {
|
||||||
|
cursor: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const result = await ctx.db
|
||||||
|
.query('posts')
|
||||||
|
.paginate({ numItems: 100, cursor: args.cursor ?? null });
|
||||||
|
|
||||||
|
for (const post of result.page) {
|
||||||
|
// Use replace to remove the field entirely
|
||||||
|
const { legacyField, ...rest } = post as typeof post & {
|
||||||
|
legacyField?: string;
|
||||||
|
};
|
||||||
|
if (legacyField !== undefined) {
|
||||||
|
await ctx.db.replace(post._id, rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.isDone) {
|
||||||
|
await ctx.scheduler.runAfter(
|
||||||
|
0,
|
||||||
|
internal.migrations.removeDeprecatedField,
|
||||||
|
{
|
||||||
|
cursor: result.continueCursor,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Renaming Fields
|
||||||
|
|
||||||
|
Renaming requires copying data to new field, then removing old:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Step 1: Add new field as optional
|
||||||
|
// convex/schema.ts
|
||||||
|
export default defineSchema({
|
||||||
|
users: defineTable({
|
||||||
|
userName: v.string(), // Old field
|
||||||
|
displayName: v.optional(v.string()), // New field
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Update code to read from new field with fallback
|
||||||
|
export const getUser = query({
|
||||||
|
args: { userId: v.id('users') },
|
||||||
|
returns: v.object({
|
||||||
|
_id: v.id('users'),
|
||||||
|
displayName: v.string(),
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await ctx.db.get(args.userId);
|
||||||
|
if (!user) throw new Error('User not found');
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: user._id,
|
||||||
|
// Read new field, fall back to old
|
||||||
|
displayName: user.displayName ?? user.userName,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Backfill to copy data
|
||||||
|
export const backfillDisplayName = internalMutation({
|
||||||
|
args: { cursor: v.optional(v.string()) },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const result = await ctx.db
|
||||||
|
.query('users')
|
||||||
|
.paginate({ numItems: 100, cursor: args.cursor ?? null });
|
||||||
|
|
||||||
|
for (const user of result.page) {
|
||||||
|
if (user.displayName === undefined) {
|
||||||
|
await ctx.db.patch(user._id, {
|
||||||
|
displayName: user.userName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.isDone) {
|
||||||
|
await ctx.scheduler.runAfter(0, internal.migrations.backfillDisplayName, {
|
||||||
|
cursor: result.continueCursor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: After backfill, update schema to make new field required
|
||||||
|
// and remove old field
|
||||||
|
export default defineSchema({
|
||||||
|
users: defineTable({
|
||||||
|
// userName removed
|
||||||
|
displayName: v.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Indexes
|
||||||
|
|
||||||
|
Add indexes before using them in queries:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Step 1: Add index to schema
|
||||||
|
// convex/schema.ts
|
||||||
|
export default defineSchema({
|
||||||
|
posts: defineTable({
|
||||||
|
title: v.string(),
|
||||||
|
authorId: v.id('users'),
|
||||||
|
publishedAt: v.optional(v.number()),
|
||||||
|
status: v.string(),
|
||||||
|
})
|
||||||
|
.index('by_author', ['authorId'])
|
||||||
|
// New index
|
||||||
|
.index('by_status_and_published', ['status', 'publishedAt']),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Deploy schema change
|
||||||
|
// Run: npx convex dev
|
||||||
|
|
||||||
|
// Step 3: Now use the index in queries
|
||||||
|
export const getPublishedPosts = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('posts'),
|
||||||
|
title: v.string(),
|
||||||
|
publishedAt: v.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const posts = await ctx.db
|
||||||
|
.query('posts')
|
||||||
|
.withIndex('by_status_and_published', (q) => q.eq('status', 'published'))
|
||||||
|
.order('desc')
|
||||||
|
.take(10);
|
||||||
|
|
||||||
|
return posts
|
||||||
|
.filter((p) => p.publishedAt !== undefined)
|
||||||
|
.map((p) => ({
|
||||||
|
_id: p._id,
|
||||||
|
title: p.title,
|
||||||
|
publishedAt: p.publishedAt!,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changing Field Types
|
||||||
|
|
||||||
|
Type changes require careful migration:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example: Change from string to number for a "priority" field
|
||||||
|
|
||||||
|
// Step 1: Add new field with new type
|
||||||
|
// convex/schema.ts
|
||||||
|
export default defineSchema({
|
||||||
|
tasks: defineTable({
|
||||||
|
title: v.string(),
|
||||||
|
priority: v.string(), // Old: "low", "medium", "high"
|
||||||
|
priorityLevel: v.optional(v.number()), // New: 1, 2, 3
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Backfill with type conversion
|
||||||
|
export const migratePriorityToNumber = internalMutation({
|
||||||
|
args: { cursor: v.optional(v.string()) },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const result = await ctx.db
|
||||||
|
.query('tasks')
|
||||||
|
.paginate({ numItems: 100, cursor: args.cursor ?? null });
|
||||||
|
|
||||||
|
const priorityMap: Record<string, number> = {
|
||||||
|
low: 1,
|
||||||
|
medium: 2,
|
||||||
|
high: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const task of result.page) {
|
||||||
|
if (task.priorityLevel === undefined) {
|
||||||
|
await ctx.db.patch(task._id, {
|
||||||
|
priorityLevel: priorityMap[task.priority] ?? 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.isDone) {
|
||||||
|
await ctx.scheduler.runAfter(
|
||||||
|
0,
|
||||||
|
internal.migrations.migratePriorityToNumber,
|
||||||
|
{
|
||||||
|
cursor: result.continueCursor,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Update code to use new field
|
||||||
|
export const getTask = query({
|
||||||
|
args: { taskId: v.id('tasks') },
|
||||||
|
returns: v.object({
|
||||||
|
_id: v.id('tasks'),
|
||||||
|
title: v.string(),
|
||||||
|
priorityLevel: v.number(),
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const task = await ctx.db.get(args.taskId);
|
||||||
|
if (!task) throw new Error('Task not found');
|
||||||
|
|
||||||
|
const priorityMap: Record<string, number> = {
|
||||||
|
low: 1,
|
||||||
|
medium: 2,
|
||||||
|
high: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: task._id,
|
||||||
|
title: task.title,
|
||||||
|
priorityLevel: task.priorityLevel ?? priorityMap[task.priority] ?? 1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: After backfill, update schema
|
||||||
|
export default defineSchema({
|
||||||
|
tasks: defineTable({
|
||||||
|
title: v.string(),
|
||||||
|
// priority field removed
|
||||||
|
priorityLevel: v.number(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Runner Pattern
|
||||||
|
|
||||||
|
Create a reusable migration system:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/schema.ts
|
||||||
|
import { defineSchema, defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export default defineSchema({
|
||||||
|
migrations: defineTable({
|
||||||
|
name: v.string(),
|
||||||
|
startedAt: v.number(),
|
||||||
|
completedAt: v.optional(v.number()),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('running'),
|
||||||
|
v.literal('completed'),
|
||||||
|
v.literal('failed'),
|
||||||
|
),
|
||||||
|
error: v.optional(v.string()),
|
||||||
|
processed: v.number(),
|
||||||
|
}).index('by_name', ['name']),
|
||||||
|
|
||||||
|
// Your other tables...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/migrations.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { internalMutation, internalQuery } from './_generated/server';
|
||||||
|
|
||||||
|
// Check if migration has run
|
||||||
|
export const hasMigrationRun = internalQuery({
|
||||||
|
args: { name: v.string() },
|
||||||
|
returns: v.boolean(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const migration = await ctx.db
|
||||||
|
.query('migrations')
|
||||||
|
.withIndex('by_name', (q) => q.eq('name', args.name))
|
||||||
|
.first();
|
||||||
|
return migration?.status === 'completed';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start a migration
|
||||||
|
export const startMigration = internalMutation({
|
||||||
|
args: { name: v.string() },
|
||||||
|
returns: v.id('migrations'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Check if already exists
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query('migrations')
|
||||||
|
.withIndex('by_name', (q) => q.eq('name', args.name))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === 'completed') {
|
||||||
|
throw new Error(`Migration ${args.name} already completed`);
|
||||||
|
}
|
||||||
|
if (existing.status === 'running') {
|
||||||
|
throw new Error(`Migration ${args.name} already running`);
|
||||||
|
}
|
||||||
|
// Reset failed migration
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
status: 'running',
|
||||||
|
startedAt: Date.now(),
|
||||||
|
error: undefined,
|
||||||
|
processed: 0,
|
||||||
|
});
|
||||||
|
return existing._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ctx.db.insert('migrations', {
|
||||||
|
name: args.name,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
status: 'running',
|
||||||
|
processed: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update migration progress
|
||||||
|
export const updateMigrationProgress = internalMutation({
|
||||||
|
args: {
|
||||||
|
migrationId: v.id('migrations'),
|
||||||
|
processed: v.number(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const migration = await ctx.db.get(args.migrationId);
|
||||||
|
if (!migration) return null;
|
||||||
|
|
||||||
|
await ctx.db.patch(args.migrationId, {
|
||||||
|
processed: migration.processed + args.processed,
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Complete a migration
|
||||||
|
export const completeMigration = internalMutation({
|
||||||
|
args: { migrationId: v.id('migrations') },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.patch(args.migrationId, {
|
||||||
|
status: 'completed',
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fail a migration
|
||||||
|
export const failMigration = internalMutation({
|
||||||
|
args: {
|
||||||
|
migrationId: v.id('migrations'),
|
||||||
|
error: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.patch(args.migrationId, {
|
||||||
|
status: 'failed',
|
||||||
|
error: args.error,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/migrations/addUserTimestamps.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { internal } from '../_generated/api';
|
||||||
|
import { internalMutation } from '../_generated/server';
|
||||||
|
|
||||||
|
const MIGRATION_NAME = 'add_user_timestamps_v1';
|
||||||
|
const BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
export const run = internalMutation({
|
||||||
|
args: {
|
||||||
|
migrationId: v.optional(v.id('migrations')),
|
||||||
|
cursor: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Initialize migration on first run
|
||||||
|
let migrationId = args.migrationId;
|
||||||
|
if (!migrationId) {
|
||||||
|
const hasRun = await ctx.runQuery(internal.migrations.hasMigrationRun, {
|
||||||
|
name: MIGRATION_NAME,
|
||||||
|
});
|
||||||
|
if (hasRun) {
|
||||||
|
console.log(`Migration ${MIGRATION_NAME} already completed`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
migrationId = await ctx.runMutation(internal.migrations.startMigration, {
|
||||||
|
name: MIGRATION_NAME,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await ctx.db
|
||||||
|
.query('users')
|
||||||
|
.paginate({ numItems: BATCH_SIZE, cursor: args.cursor ?? null });
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
for (const user of result.page) {
|
||||||
|
if (user.createdAt === undefined) {
|
||||||
|
await ctx.db.patch(user._id, {
|
||||||
|
createdAt: user._creationTime,
|
||||||
|
updatedAt: user._creationTime,
|
||||||
|
});
|
||||||
|
processed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
await ctx.runMutation(internal.migrations.updateMigrationProgress, {
|
||||||
|
migrationId,
|
||||||
|
processed,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Continue or complete
|
||||||
|
if (!result.isDone) {
|
||||||
|
await ctx.scheduler.runAfter(
|
||||||
|
0,
|
||||||
|
internal.migrations.addUserTimestamps.run,
|
||||||
|
{
|
||||||
|
migrationId,
|
||||||
|
cursor: result.continueCursor,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await ctx.runMutation(internal.migrations.completeMigration, {
|
||||||
|
migrationId,
|
||||||
|
});
|
||||||
|
console.log(`Migration ${MIGRATION_NAME} completed`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await ctx.runMutation(internal.migrations.failMigration, {
|
||||||
|
migrationId,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Schema with Migration Support
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/schema.ts
|
||||||
|
import { defineSchema, defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export default defineSchema({
|
||||||
|
// Migration tracking
|
||||||
|
migrations: defineTable({
|
||||||
|
name: v.string(),
|
||||||
|
startedAt: v.number(),
|
||||||
|
completedAt: v.optional(v.number()),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('running'),
|
||||||
|
v.literal('completed'),
|
||||||
|
v.literal('failed'),
|
||||||
|
),
|
||||||
|
error: v.optional(v.string()),
|
||||||
|
processed: v.number(),
|
||||||
|
}).index('by_name', ['name']),
|
||||||
|
|
||||||
|
// Users table with evolved schema
|
||||||
|
users: defineTable({
|
||||||
|
// Original fields
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
|
||||||
|
// Added in migration v1
|
||||||
|
createdAt: v.optional(v.number()),
|
||||||
|
updatedAt: v.optional(v.number()),
|
||||||
|
|
||||||
|
// Added in migration v2
|
||||||
|
avatarUrl: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Added in migration v3
|
||||||
|
settings: v.optional(
|
||||||
|
v.object({
|
||||||
|
theme: v.string(),
|
||||||
|
notifications: v.boolean(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.index('by_email', ['email'])
|
||||||
|
.index('by_createdAt', ['createdAt']),
|
||||||
|
|
||||||
|
// Posts table with indexes for common queries
|
||||||
|
posts: defineTable({
|
||||||
|
title: v.string(),
|
||||||
|
content: v.string(),
|
||||||
|
authorId: v.id('users'),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('draft'),
|
||||||
|
v.literal('published'),
|
||||||
|
v.literal('archived'),
|
||||||
|
),
|
||||||
|
publishedAt: v.optional(v.number()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_author', ['authorId'])
|
||||||
|
.index('by_status', ['status'])
|
||||||
|
.index('by_author_and_status', ['authorId', 'status'])
|
||||||
|
.index('by_publishedAt', ['publishedAt']),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Never run `npx convex deploy` unless explicitly instructed
|
||||||
|
- Never run any git commands unless explicitly instructed
|
||||||
|
- Always start with optional fields when adding new data
|
||||||
|
- Backfill data in batches to avoid timeouts
|
||||||
|
- Test migrations on development before production
|
||||||
|
- Keep track of completed migrations to avoid re-running
|
||||||
|
- Update code to handle both old and new data during transition
|
||||||
|
- Remove deprecated fields only after all code stops using them
|
||||||
|
- Use pagination for large datasets
|
||||||
|
- Add appropriate indexes before running queries on new fields
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Making new fields required immediately** - Breaks existing documents
|
||||||
|
2. **Not handling undefined values** - Causes runtime errors
|
||||||
|
3. **Large batch sizes** - Causes function timeouts
|
||||||
|
4. **Forgetting to update indexes** - Queries fail or perform poorly
|
||||||
|
5. **Running migrations without tracking** - May run multiple times
|
||||||
|
6. **Removing fields before code update** - Breaks existing functionality
|
||||||
|
7. **Not testing on development** - Production data issues
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Convex Documentation: https://docs.convex.dev/
|
||||||
|
- Convex LLMs.txt: https://docs.convex.dev/llms.txt
|
||||||
|
- Schemas: https://docs.convex.dev/database/schemas
|
||||||
|
- Database Overview: https://docs.convex.dev/database
|
||||||
|
- Migration Patterns: https://stack.convex.dev/migrate-data-postgres-to-convex
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
---
|
||||||
|
name: convex-realtime
|
||||||
|
displayName: Convex Realtime
|
||||||
|
description: Patterns for building reactive apps including subscription management, optimistic updates, cache behavior, and paginated queries with cursor-based loading
|
||||||
|
version: 1.0.0
|
||||||
|
author: Convex
|
||||||
|
tags: [convex, realtime, subscriptions, optimistic-updates, pagination]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Convex Realtime
|
||||||
|
|
||||||
|
Build reactive applications with Convex's real-time subscriptions, optimistic updates, intelligent caching, and cursor-based pagination.
|
||||||
|
|
||||||
|
## Documentation Sources
|
||||||
|
|
||||||
|
Before implementing, do not assume; fetch the latest documentation:
|
||||||
|
|
||||||
|
- Primary: https://docs.convex.dev/client/react
|
||||||
|
- Optimistic Updates: https://docs.convex.dev/client/react/optimistic-updates
|
||||||
|
- Pagination: https://docs.convex.dev/database/pagination
|
||||||
|
- For broader context: https://docs.convex.dev/llms.txt
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### How Convex Realtime Works
|
||||||
|
|
||||||
|
1. **Automatic Subscriptions** - useQuery creates a subscription that updates automatically
|
||||||
|
2. **Smart Caching** - Query results are cached and shared across components
|
||||||
|
3. **Consistency** - All subscriptions see a consistent view of the database
|
||||||
|
4. **Efficient Updates** - Only re-renders when relevant data changes
|
||||||
|
|
||||||
|
### Basic Subscriptions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// React component with real-time data
|
||||||
|
import { useQuery } from "convex/react";
|
||||||
|
import { api } from "../convex/_generated/api";
|
||||||
|
|
||||||
|
function TaskList({ userId }: { userId: Id<"users"> }) {
|
||||||
|
// Automatically subscribes and updates in real-time
|
||||||
|
const tasks = useQuery(api.tasks.list, { userId });
|
||||||
|
|
||||||
|
if (tasks === undefined) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<li key={task._id}>{task.title}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Queries
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useQuery } from "convex/react";
|
||||||
|
import { api } from "../convex/_generated/api";
|
||||||
|
|
||||||
|
function UserProfile({ userId }: { userId: Id<"users"> | null }) {
|
||||||
|
// Skip query when userId is null
|
||||||
|
const user = useQuery(
|
||||||
|
api.users.get,
|
||||||
|
userId ? { userId } : "skip"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userId === null) {
|
||||||
|
return <div>Select a user</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user === undefined) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>{user.name}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mutations with Real-time Updates
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useMutation, useQuery } from "convex/react";
|
||||||
|
import { api } from "../convex/_generated/api";
|
||||||
|
|
||||||
|
function TaskManager({ userId }: { userId: Id<"users"> }) {
|
||||||
|
const tasks = useQuery(api.tasks.list, { userId });
|
||||||
|
const createTask = useMutation(api.tasks.create);
|
||||||
|
const toggleTask = useMutation(api.tasks.toggle);
|
||||||
|
|
||||||
|
const handleCreate = async (title: string) => {
|
||||||
|
// Mutation triggers automatic re-render when data changes
|
||||||
|
await createTask({ title, userId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (taskId: Id<"tasks">) => {
|
||||||
|
await toggleTask({ taskId });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => handleCreate("New Task")}>Add Task</button>
|
||||||
|
<ul>
|
||||||
|
{tasks?.map((task) => (
|
||||||
|
<li key={task._id} onClick={() => handleToggle(task._id)}>
|
||||||
|
{task.completed ? "✓" : "○"} {task.title}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optimistic Updates
|
||||||
|
|
||||||
|
Show changes immediately before server confirmation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useMutation, useQuery } from "convex/react";
|
||||||
|
import { api } from "../convex/_generated/api";
|
||||||
|
import { Id } from "../convex/_generated/dataModel";
|
||||||
|
|
||||||
|
function TaskItem({ task }: { task: Task }) {
|
||||||
|
const toggleTask = useMutation(api.tasks.toggle).withOptimisticUpdate(
|
||||||
|
(localStore, args) => {
|
||||||
|
const { taskId } = args;
|
||||||
|
const currentValue = localStore.getQuery(api.tasks.get, { taskId });
|
||||||
|
|
||||||
|
if (currentValue !== undefined) {
|
||||||
|
localStore.setQuery(api.tasks.get, { taskId }, {
|
||||||
|
...currentValue,
|
||||||
|
completed: !currentValue.completed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div onClick={() => toggleTask({ taskId: task._id })}>
|
||||||
|
{task.completed ? "✓" : "○"} {task.title}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optimistic Updates for Lists
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useMutation } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '../convex/_generated/api';
|
||||||
|
|
||||||
|
function useCreateTask(userId: Id<'users'>) {
|
||||||
|
return useMutation(api.tasks.create).withOptimisticUpdate(
|
||||||
|
(localStore, args) => {
|
||||||
|
const { title, userId } = args;
|
||||||
|
const currentTasks = localStore.getQuery(api.tasks.list, { userId });
|
||||||
|
|
||||||
|
if (currentTasks !== undefined) {
|
||||||
|
// Add optimistic task to the list
|
||||||
|
const optimisticTask = {
|
||||||
|
_id: crypto.randomUUID() as Id<'tasks'>,
|
||||||
|
_creationTime: Date.now(),
|
||||||
|
title,
|
||||||
|
userId,
|
||||||
|
completed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStore.setQuery(api.tasks.list, { userId }, [
|
||||||
|
optimisticTask,
|
||||||
|
...currentTasks,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cursor-Based Pagination
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/messages.ts
|
||||||
|
import { paginationOptsValidator } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { query } from './_generated/server';
|
||||||
|
|
||||||
|
export const listPaginated = query({
|
||||||
|
args: {
|
||||||
|
channelId: v.id('channels'),
|
||||||
|
paginationOpts: paginationOptsValidator,
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query('messages')
|
||||||
|
.withIndex('by_channel', (q) => q.eq('channelId', args.channelId))
|
||||||
|
.order('desc')
|
||||||
|
.paginate(args.paginationOpts);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// React component with pagination
|
||||||
|
import { usePaginatedQuery } from "convex/react";
|
||||||
|
import { api } from "../convex/_generated/api";
|
||||||
|
|
||||||
|
function MessageList({ channelId }: { channelId: Id<"channels"> }) {
|
||||||
|
const { results, status, loadMore } = usePaginatedQuery(
|
||||||
|
api.messages.listPaginated,
|
||||||
|
{ channelId },
|
||||||
|
{ initialNumItems: 20 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{results.map((message) => (
|
||||||
|
<div key={message._id}>{message.content}</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{status === "CanLoadMore" && (
|
||||||
|
<button onClick={() => loadMore(20)}>Load More</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "LoadingMore" && <div>Loading...</div>}
|
||||||
|
|
||||||
|
{status === "Exhausted" && <div>No more messages</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Infinite Scroll Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { usePaginatedQuery } from "convex/react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { api } from "../convex/_generated/api";
|
||||||
|
|
||||||
|
function InfiniteMessageList({ channelId }: { channelId: Id<"channels"> }) {
|
||||||
|
const { results, status, loadMore } = usePaginatedQuery(
|
||||||
|
api.messages.listPaginated,
|
||||||
|
{ channelId },
|
||||||
|
{ initialNumItems: 20 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const observerRef = useRef<IntersectionObserver>();
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (observerRef.current) {
|
||||||
|
observerRef.current.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
observerRef.current = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting && status === "CanLoadMore") {
|
||||||
|
loadMore(20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loadMoreRef.current) {
|
||||||
|
observerRef.current.observe(loadMoreRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observerRef.current?.disconnect();
|
||||||
|
}, [status, loadMore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{results.map((message) => (
|
||||||
|
<div key={message._id}>{message.content}</div>
|
||||||
|
))}
|
||||||
|
<div ref={loadMoreRef} style={{ height: 1 }} />
|
||||||
|
{status === "LoadingMore" && <div>Loading...</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Subscriptions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useQuery } from "convex/react";
|
||||||
|
import { api } from "../convex/_generated/api";
|
||||||
|
|
||||||
|
function Dashboard({ userId }: { userId: Id<"users"> }) {
|
||||||
|
// Multiple subscriptions update independently
|
||||||
|
const user = useQuery(api.users.get, { userId });
|
||||||
|
const tasks = useQuery(api.tasks.list, { userId });
|
||||||
|
const notifications = useQuery(api.notifications.unread, { userId });
|
||||||
|
|
||||||
|
const isLoading = user === undefined ||
|
||||||
|
tasks === undefined ||
|
||||||
|
notifications === undefined;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome, {user.name}</h1>
|
||||||
|
<p>You have {tasks.length} tasks</p>
|
||||||
|
<p>{notifications.length} unread notifications</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Real-time Chat Application
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/messages.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
|
||||||
|
export const list = query({
|
||||||
|
args: { channelId: v.id('channels') },
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('messages'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
content: v.string(),
|
||||||
|
authorId: v.id('users'),
|
||||||
|
authorName: v.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const messages = await ctx.db
|
||||||
|
.query('messages')
|
||||||
|
.withIndex('by_channel', (q) => q.eq('channelId', args.channelId))
|
||||||
|
.order('desc')
|
||||||
|
.take(100);
|
||||||
|
|
||||||
|
// Enrich with author names
|
||||||
|
return Promise.all(
|
||||||
|
messages.map(async (msg) => {
|
||||||
|
const author = await ctx.db.get(msg.authorId);
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
authorName: author?.name ?? 'Unknown',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const send = mutation({
|
||||||
|
args: {
|
||||||
|
channelId: v.id('channels'),
|
||||||
|
authorId: v.id('users'),
|
||||||
|
content: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.id('messages'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db.insert('messages', {
|
||||||
|
channelId: args.channelId,
|
||||||
|
authorId: args.authorId,
|
||||||
|
content: args.content,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ChatRoom.tsx
|
||||||
|
import { useQuery, useMutation } from "convex/react";
|
||||||
|
import { api } from "../convex/_generated/api";
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
function ChatRoom({ channelId, userId }: Props) {
|
||||||
|
const messages = useQuery(api.messages.list, { channelId });
|
||||||
|
const sendMessage = useMutation(api.messages.send);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom on new messages
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleSend = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!input.trim()) return;
|
||||||
|
|
||||||
|
await sendMessage({
|
||||||
|
channelId,
|
||||||
|
authorId: userId,
|
||||||
|
content: input.trim(),
|
||||||
|
});
|
||||||
|
setInput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-room">
|
||||||
|
<div className="messages">
|
||||||
|
{messages?.map((msg) => (
|
||||||
|
<div key={msg._id} className="message">
|
||||||
|
<strong>{msg.authorName}:</strong> {msg.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSend}>
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
/>
|
||||||
|
<button type="submit">Send</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Never run `npx convex deploy` unless explicitly instructed
|
||||||
|
- Never run any git commands unless explicitly instructed
|
||||||
|
- Use "skip" for conditional queries instead of conditionally calling hooks
|
||||||
|
- Implement optimistic updates for better perceived performance
|
||||||
|
- Use usePaginatedQuery for large datasets
|
||||||
|
- Handle undefined state (loading) explicitly
|
||||||
|
- Avoid unnecessary re-renders by memoizing derived data
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Conditional hook calls** - Use "skip" instead of if statements
|
||||||
|
2. **Not handling loading state** - Always check for undefined
|
||||||
|
3. **Missing optimistic update rollback** - Optimistic updates auto-rollback on error
|
||||||
|
4. **Over-fetching with pagination** - Use appropriate page sizes
|
||||||
|
5. **Ignoring subscription cleanup** - React handles this automatically
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Convex Documentation: https://docs.convex.dev/
|
||||||
|
- Convex LLMs.txt: https://docs.convex.dev/llms.txt
|
||||||
|
- React Client: https://docs.convex.dev/client/react
|
||||||
|
- Optimistic Updates: https://docs.convex.dev/client/react/optimistic-updates
|
||||||
|
- Pagination: https://docs.convex.dev/database/pagination
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
---
|
||||||
|
name: convex-schema-validator
|
||||||
|
displayName: Convex Schema Validator
|
||||||
|
description: Defining and validating database schemas with proper typing, index configuration, optional fields, unions, and migration strategies for schema changes
|
||||||
|
version: 1.0.0
|
||||||
|
author: Convex
|
||||||
|
tags: [convex, schema, validation, typescript, indexes, migrations]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Convex Schema Validator
|
||||||
|
|
||||||
|
Define and validate database schemas in Convex with proper typing, index configuration, optional fields, unions, and strategies for schema migrations.
|
||||||
|
|
||||||
|
## Documentation Sources
|
||||||
|
|
||||||
|
Before implementing, do not assume; fetch the latest documentation:
|
||||||
|
|
||||||
|
- Primary: https://docs.convex.dev/database/schemas
|
||||||
|
- Indexes: https://docs.convex.dev/database/indexes
|
||||||
|
- Data Types: https://docs.convex.dev/database/types
|
||||||
|
- For broader context: https://docs.convex.dev/llms.txt
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### Basic Schema Definition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/schema.ts
|
||||||
|
import { defineSchema, defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export default defineSchema({
|
||||||
|
users: defineTable({
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
avatarUrl: v.optional(v.string()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
tasks: defineTable({
|
||||||
|
title: v.string(),
|
||||||
|
description: v.optional(v.string()),
|
||||||
|
completed: v.boolean(),
|
||||||
|
userId: v.id('users'),
|
||||||
|
priority: v.union(v.literal('low'), v.literal('medium'), v.literal('high')),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validator Types
|
||||||
|
|
||||||
|
| Validator | TypeScript Type | Example |
|
||||||
|
| ---------------- | ---------------- | ------------------- |
|
||||||
|
| `v.string()` | `string` | `"hello"` |
|
||||||
|
| `v.number()` | `number` | `42`, `3.14` |
|
||||||
|
| `v.boolean()` | `boolean` | `true`, `false` |
|
||||||
|
| `v.null()` | `null` | `null` |
|
||||||
|
| `v.int64()` | `bigint` | `9007199254740993n` |
|
||||||
|
| `v.bytes()` | `ArrayBuffer` | Binary data |
|
||||||
|
| `v.id("table")` | `Id<"table">` | Document reference |
|
||||||
|
| `v.array(v)` | `T[]` | `[1, 2, 3]` |
|
||||||
|
| `v.object({})` | `{ ... }` | `{ name: "..." }` |
|
||||||
|
| `v.optional(v)` | `T \| undefined` | Optional field |
|
||||||
|
| `v.union(...)` | `T1 \| T2` | Multiple types |
|
||||||
|
| `v.literal(x)` | `"x"` | Exact value |
|
||||||
|
| `v.any()` | `any` | Any value |
|
||||||
|
| `v.record(k, v)` | `Record<K, V>` | Dynamic keys |
|
||||||
|
|
||||||
|
### Index Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default defineSchema({
|
||||||
|
messages: defineTable({
|
||||||
|
channelId: v.id('channels'),
|
||||||
|
authorId: v.id('users'),
|
||||||
|
content: v.string(),
|
||||||
|
sentAt: v.number(),
|
||||||
|
})
|
||||||
|
// Single field index
|
||||||
|
.index('by_channel', ['channelId'])
|
||||||
|
// Compound index
|
||||||
|
.index('by_channel_and_author', ['channelId', 'authorId'])
|
||||||
|
// Index for sorting
|
||||||
|
.index('by_channel_and_time', ['channelId', 'sentAt']),
|
||||||
|
|
||||||
|
// Full-text search index
|
||||||
|
articles: defineTable({
|
||||||
|
title: v.string(),
|
||||||
|
body: v.string(),
|
||||||
|
category: v.string(),
|
||||||
|
}).searchIndex('search_content', {
|
||||||
|
searchField: 'body',
|
||||||
|
filterFields: ['category'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complex Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default defineSchema({
|
||||||
|
// Nested objects
|
||||||
|
profiles: defineTable({
|
||||||
|
userId: v.id('users'),
|
||||||
|
settings: v.object({
|
||||||
|
theme: v.union(v.literal('light'), v.literal('dark')),
|
||||||
|
notifications: v.object({
|
||||||
|
email: v.boolean(),
|
||||||
|
push: v.boolean(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Arrays of objects
|
||||||
|
orders: defineTable({
|
||||||
|
customerId: v.id('users'),
|
||||||
|
items: v.array(
|
||||||
|
v.object({
|
||||||
|
productId: v.id('products'),
|
||||||
|
quantity: v.number(),
|
||||||
|
price: v.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('pending'),
|
||||||
|
v.literal('processing'),
|
||||||
|
v.literal('shipped'),
|
||||||
|
v.literal('delivered'),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Record type for dynamic keys
|
||||||
|
analytics: defineTable({
|
||||||
|
date: v.string(),
|
||||||
|
metrics: v.record(v.string(), v.number()),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Discriminated Unions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default defineSchema({
|
||||||
|
events: defineTable(
|
||||||
|
v.union(
|
||||||
|
v.object({
|
||||||
|
type: v.literal('user_signup'),
|
||||||
|
userId: v.id('users'),
|
||||||
|
email: v.string(),
|
||||||
|
}),
|
||||||
|
v.object({
|
||||||
|
type: v.literal('purchase'),
|
||||||
|
userId: v.id('users'),
|
||||||
|
orderId: v.id('orders'),
|
||||||
|
amount: v.number(),
|
||||||
|
}),
|
||||||
|
v.object({
|
||||||
|
type: v.literal('page_view'),
|
||||||
|
sessionId: v.string(),
|
||||||
|
path: v.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).index('by_type', ['type']),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional vs Nullable Fields
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default defineSchema({
|
||||||
|
items: defineTable({
|
||||||
|
// Optional: field may not exist
|
||||||
|
description: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Nullable: field exists but can be null
|
||||||
|
deletedAt: v.union(v.number(), v.null()),
|
||||||
|
|
||||||
|
// Optional and nullable
|
||||||
|
notes: v.optional(v.union(v.string(), v.null())),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Index Naming Convention
|
||||||
|
|
||||||
|
Always include all indexed fields in the index name:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default defineSchema({
|
||||||
|
posts: defineTable({
|
||||||
|
authorId: v.id('users'),
|
||||||
|
categoryId: v.id('categories'),
|
||||||
|
publishedAt: v.number(),
|
||||||
|
status: v.string(),
|
||||||
|
})
|
||||||
|
// Good: descriptive names
|
||||||
|
.index('by_author', ['authorId'])
|
||||||
|
.index('by_author_and_category', ['authorId', 'categoryId'])
|
||||||
|
.index('by_category_and_status', ['categoryId', 'status'])
|
||||||
|
.index('by_status_and_published', ['status', 'publishedAt']),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema Migration Strategies
|
||||||
|
|
||||||
|
#### Adding New Fields
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
users: defineTable({
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// After - add as optional first
|
||||||
|
users: defineTable({
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
avatarUrl: v.optional(v.string()), // New optional field
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Backfilling Data
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/migrations.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { internalMutation } from './_generated/server';
|
||||||
|
|
||||||
|
export const backfillAvatars = internalMutation({
|
||||||
|
args: {},
|
||||||
|
returns: v.number(),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const users = await ctx.db
|
||||||
|
.query('users')
|
||||||
|
.filter((q) => q.eq(q.field('avatarUrl'), undefined))
|
||||||
|
.take(100);
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
await ctx.db.patch(user._id, {
|
||||||
|
avatarUrl: `https://api.dicebear.com/7.x/initials/svg?seed=${user.name}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return users.length;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Making Optional Fields Required
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Step 1: Backfill all null values
|
||||||
|
// Step 2: Update schema to required
|
||||||
|
users: defineTable({
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
avatarUrl: v.string(), // Now required after backfill
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Complete E-commerce Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/schema.ts
|
||||||
|
import { defineSchema, defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export default defineSchema({
|
||||||
|
users: defineTable({
|
||||||
|
email: v.string(),
|
||||||
|
name: v.string(),
|
||||||
|
role: v.union(v.literal('customer'), v.literal('admin')),
|
||||||
|
createdAt: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_email', ['email'])
|
||||||
|
.index('by_role', ['role']),
|
||||||
|
|
||||||
|
products: defineTable({
|
||||||
|
name: v.string(),
|
||||||
|
description: v.string(),
|
||||||
|
price: v.number(),
|
||||||
|
category: v.string(),
|
||||||
|
inventory: v.number(),
|
||||||
|
isActive: v.boolean(),
|
||||||
|
})
|
||||||
|
.index('by_category', ['category'])
|
||||||
|
.index('by_active_and_category', ['isActive', 'category'])
|
||||||
|
.searchIndex('search_products', {
|
||||||
|
searchField: 'name',
|
||||||
|
filterFields: ['category', 'isActive'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
orders: defineTable({
|
||||||
|
userId: v.id('users'),
|
||||||
|
items: v.array(
|
||||||
|
v.object({
|
||||||
|
productId: v.id('products'),
|
||||||
|
quantity: v.number(),
|
||||||
|
priceAtPurchase: v.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
total: v.number(),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('pending'),
|
||||||
|
v.literal('paid'),
|
||||||
|
v.literal('shipped'),
|
||||||
|
v.literal('delivered'),
|
||||||
|
v.literal('cancelled'),
|
||||||
|
),
|
||||||
|
shippingAddress: v.object({
|
||||||
|
street: v.string(),
|
||||||
|
city: v.string(),
|
||||||
|
state: v.string(),
|
||||||
|
zip: v.string(),
|
||||||
|
country: v.string(),
|
||||||
|
}),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_user', ['userId'])
|
||||||
|
.index('by_user_and_status', ['userId', 'status'])
|
||||||
|
.index('by_status', ['status']),
|
||||||
|
|
||||||
|
reviews: defineTable({
|
||||||
|
productId: v.id('products'),
|
||||||
|
userId: v.id('users'),
|
||||||
|
rating: v.number(),
|
||||||
|
comment: v.optional(v.string()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_product', ['productId'])
|
||||||
|
.index('by_user', ['userId']),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Schema Types in Functions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/products.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { Doc, Id } from './_generated/dataModel';
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
|
||||||
|
// Use Doc type for full documents
|
||||||
|
type Product = Doc<'products'>;
|
||||||
|
|
||||||
|
// Use Id type for references
|
||||||
|
type ProductId = Id<'products'>;
|
||||||
|
|
||||||
|
export const get = query({
|
||||||
|
args: { productId: v.id('products') },
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('products'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
name: v.string(),
|
||||||
|
description: v.string(),
|
||||||
|
price: v.number(),
|
||||||
|
category: v.string(),
|
||||||
|
inventory: v.number(),
|
||||||
|
isActive: v.boolean(),
|
||||||
|
}),
|
||||||
|
v.null(),
|
||||||
|
),
|
||||||
|
handler: async (ctx, args): Promise<Product | null> => {
|
||||||
|
return await ctx.db.get(args.productId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Never run `npx convex deploy` unless explicitly instructed
|
||||||
|
- Never run any git commands unless explicitly instructed
|
||||||
|
- Always define explicit schemas rather than relying on inference
|
||||||
|
- Use descriptive index names that include all indexed fields
|
||||||
|
- Start with optional fields when adding new columns
|
||||||
|
- Use discriminated unions for polymorphic data
|
||||||
|
- Validate data at the schema level, not just in functions
|
||||||
|
- Plan index strategy based on query patterns
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Missing indexes for queries** - Every withIndex needs a corresponding schema index
|
||||||
|
2. **Wrong index field order** - Fields must be queried in order defined
|
||||||
|
3. **Using v.any() excessively** - Lose type safety benefits
|
||||||
|
4. **Not making new fields optional** - Breaks existing data
|
||||||
|
5. **Forgetting system fields** - \_id and \_creationTime are automatic
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Convex Documentation: https://docs.convex.dev/
|
||||||
|
- Convex LLMs.txt: https://docs.convex.dev/llms.txt
|
||||||
|
- Schemas: https://docs.convex.dev/database/schemas
|
||||||
|
- Indexes: https://docs.convex.dev/database/indexes
|
||||||
|
- Data Types: https://docs.convex.dev/database/types
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
---
|
||||||
|
name: convex-security-audit
|
||||||
|
displayName: Convex Security Audit
|
||||||
|
description: Deep security review patterns for authorization logic, data access boundaries, action isolation, rate limiting, and protecting sensitive operations
|
||||||
|
version: 1.0.0
|
||||||
|
author: Convex
|
||||||
|
tags: [convex, security, audit, authorization, rate-limiting, protection]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Convex Security Audit
|
||||||
|
|
||||||
|
Comprehensive security review patterns for Convex applications including authorization logic, data access boundaries, action isolation, rate limiting, and protecting sensitive operations.
|
||||||
|
|
||||||
|
## Documentation Sources
|
||||||
|
|
||||||
|
Before implementing, do not assume; fetch the latest documentation:
|
||||||
|
|
||||||
|
- Primary: https://docs.convex.dev/auth/functions-auth
|
||||||
|
- Production Security: https://docs.convex.dev/production
|
||||||
|
- For broader context: https://docs.convex.dev/llms.txt
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### Security Audit Areas
|
||||||
|
|
||||||
|
1. **Authorization Logic** - Who can do what
|
||||||
|
2. **Data Access Boundaries** - What data users can see
|
||||||
|
3. **Action Isolation** - Protecting external API calls
|
||||||
|
4. **Rate Limiting** - Preventing abuse
|
||||||
|
5. **Sensitive Operations** - Protecting critical functions
|
||||||
|
|
||||||
|
### Authorization Logic Audit
|
||||||
|
|
||||||
|
#### Role-Based Access Control (RBAC)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/lib/auth.ts
|
||||||
|
import { ConvexError } from 'convex/values';
|
||||||
|
|
||||||
|
import { Doc } from './_generated/dataModel';
|
||||||
|
import { MutationCtx, QueryCtx } from './_generated/server';
|
||||||
|
|
||||||
|
type UserRole = 'user' | 'moderator' | 'admin' | 'superadmin';
|
||||||
|
|
||||||
|
const roleHierarchy: Record<UserRole, number> = {
|
||||||
|
user: 0,
|
||||||
|
moderator: 1,
|
||||||
|
admin: 2,
|
||||||
|
superadmin: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getUser(
|
||||||
|
ctx: QueryCtx | MutationCtx,
|
||||||
|
): Promise<Doc<'users'> | null> {
|
||||||
|
const identity = await ctx.auth.getUserIdentity();
|
||||||
|
if (!identity) return null;
|
||||||
|
|
||||||
|
return await ctx.db
|
||||||
|
.query('users')
|
||||||
|
.withIndex('by_tokenIdentifier', (q) =>
|
||||||
|
q.eq('tokenIdentifier', identity.tokenIdentifier),
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireRole(
|
||||||
|
ctx: QueryCtx | MutationCtx,
|
||||||
|
minRole: UserRole,
|
||||||
|
): Promise<Doc<'users'>> {
|
||||||
|
const user = await getUser(ctx);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ConvexError({
|
||||||
|
code: 'UNAUTHENTICATED',
|
||||||
|
message: 'Authentication required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRoleLevel = roleHierarchy[user.role as UserRole] ?? 0;
|
||||||
|
const requiredLevel = roleHierarchy[minRole];
|
||||||
|
|
||||||
|
if (userRoleLevel < requiredLevel) {
|
||||||
|
throw new ConvexError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: `Role '${minRole}' or higher required`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission-based check
|
||||||
|
type Permission =
|
||||||
|
| 'read:users'
|
||||||
|
| 'write:users'
|
||||||
|
| 'delete:users'
|
||||||
|
| 'admin:system';
|
||||||
|
|
||||||
|
const rolePermissions: Record<UserRole, Permission[]> = {
|
||||||
|
user: ['read:users'],
|
||||||
|
moderator: ['read:users', 'write:users'],
|
||||||
|
admin: ['read:users', 'write:users', 'delete:users'],
|
||||||
|
superadmin: ['read:users', 'write:users', 'delete:users', 'admin:system'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function requirePermission(
|
||||||
|
ctx: QueryCtx | MutationCtx,
|
||||||
|
permission: Permission,
|
||||||
|
): Promise<Doc<'users'>> {
|
||||||
|
const user = await getUser(ctx);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ConvexError({
|
||||||
|
code: 'UNAUTHENTICATED',
|
||||||
|
message: 'Authentication required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRole = user.role as UserRole;
|
||||||
|
const permissions = rolePermissions[userRole] ?? [];
|
||||||
|
|
||||||
|
if (!permissions.includes(permission)) {
|
||||||
|
throw new ConvexError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: `Permission '${permission}' required`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Access Boundaries Audit
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/data.ts
|
||||||
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
import { getUser, requireRole } from './lib/auth';
|
||||||
|
|
||||||
|
// Audit: Users can only see their own data
|
||||||
|
export const getMyData = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('userData'),
|
||||||
|
content: v.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const user = await getUser(ctx);
|
||||||
|
if (!user) return [];
|
||||||
|
|
||||||
|
// SECURITY: Filter by userId
|
||||||
|
return await ctx.db
|
||||||
|
.query('userData')
|
||||||
|
.withIndex('by_user', (q) => q.eq('userId', user._id))
|
||||||
|
.collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit: Verify ownership before returning sensitive data
|
||||||
|
export const getSensitiveItem = query({
|
||||||
|
args: { itemId: v.id('sensitiveItems') },
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('sensitiveItems'),
|
||||||
|
secret: v.string(),
|
||||||
|
}),
|
||||||
|
v.null(),
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getUser(ctx);
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const item = await ctx.db.get(args.itemId);
|
||||||
|
|
||||||
|
// SECURITY: Verify ownership
|
||||||
|
if (!item || item.ownerId !== user._id) {
|
||||||
|
return null; // Don't reveal if item exists
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit: Shared resources with access list
|
||||||
|
export const getSharedDocument = query({
|
||||||
|
args: { docId: v.id('documents') },
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('documents'),
|
||||||
|
content: v.string(),
|
||||||
|
accessLevel: v.string(),
|
||||||
|
}),
|
||||||
|
v.null(),
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getUser(ctx);
|
||||||
|
const doc = await ctx.db.get(args.docId);
|
||||||
|
|
||||||
|
if (!doc) return null;
|
||||||
|
|
||||||
|
// Public documents
|
||||||
|
if (doc.visibility === 'public') {
|
||||||
|
return { ...doc, accessLevel: 'public' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be authenticated for non-public
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
// Owner has full access
|
||||||
|
if (doc.ownerId === user._id) {
|
||||||
|
return { ...doc, accessLevel: 'owner' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check shared access
|
||||||
|
const access = await ctx.db
|
||||||
|
.query('documentAccess')
|
||||||
|
.withIndex('by_doc_and_user', (q) =>
|
||||||
|
q.eq('documentId', args.docId).eq('userId', user._id),
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
|
||||||
|
if (!access) return null;
|
||||||
|
|
||||||
|
return { ...doc, accessLevel: access.level };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Action Isolation Audit
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/actions.ts
|
||||||
|
'use node';
|
||||||
|
|
||||||
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
|
import { api, internal } from './_generated/api';
|
||||||
|
import { action, internalAction } from './_generated/server';
|
||||||
|
|
||||||
|
// SECURITY: Never expose API keys in responses
|
||||||
|
export const callExternalAPI = action({
|
||||||
|
args: { query: v.string() },
|
||||||
|
returns: v.object({ result: v.string() }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Verify user is authenticated
|
||||||
|
const identity = await ctx.auth.getUserIdentity();
|
||||||
|
if (!identity) {
|
||||||
|
throw new ConvexError('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get API key from environment (not hardcoded)
|
||||||
|
const apiKey = process.env.EXTERNAL_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('API key not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log usage for audit trail
|
||||||
|
await ctx.runMutation(internal.audit.logAPICall, {
|
||||||
|
userId: identity.tokenIdentifier,
|
||||||
|
endpoint: 'external-api',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch('https://api.example.com/query', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query: args.query }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Don't expose external API error details
|
||||||
|
throw new ConvexError('External service unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Sanitize response before returning
|
||||||
|
return { result: sanitizeResponse(data) };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Internal action - not exposed to clients
|
||||||
|
export const _processPayment = internalAction({
|
||||||
|
args: {
|
||||||
|
userId: v.id('users'),
|
||||||
|
amount: v.number(),
|
||||||
|
paymentMethodId: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.object({
|
||||||
|
success: v.boolean(),
|
||||||
|
transactionId: v.optional(v.string()),
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||||
|
|
||||||
|
// Process payment with Stripe
|
||||||
|
// This should NEVER be exposed as a public action
|
||||||
|
|
||||||
|
return { success: true, transactionId: 'txn_xxx' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rate Limiting Audit
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/rateLimit.ts
|
||||||
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
|
||||||
|
const RATE_LIMITS = {
|
||||||
|
message: { requests: 10, windowMs: 60000 }, // 10 per minute
|
||||||
|
upload: { requests: 5, windowMs: 300000 }, // 5 per 5 minutes
|
||||||
|
api: { requests: 100, windowMs: 3600000 }, // 100 per hour
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkRateLimit = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
action: v.union(
|
||||||
|
v.literal('message'),
|
||||||
|
v.literal('upload'),
|
||||||
|
v.literal('api'),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
returns: v.object({
|
||||||
|
allowed: v.boolean(),
|
||||||
|
retryAfter: v.optional(v.number()),
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const limit = RATE_LIMITS[args.action];
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = now - limit.windowMs;
|
||||||
|
|
||||||
|
// Count requests in window
|
||||||
|
const requests = await ctx.db
|
||||||
|
.query('rateLimits')
|
||||||
|
.withIndex('by_user_and_action', (q) =>
|
||||||
|
q.eq('userId', args.userId).eq('action', args.action),
|
||||||
|
)
|
||||||
|
.filter((q) => q.gt(q.field('timestamp'), windowStart))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if (requests.length >= limit.requests) {
|
||||||
|
const oldestRequest = requests[0];
|
||||||
|
const retryAfter = oldestRequest.timestamp + limit.windowMs - now;
|
||||||
|
|
||||||
|
return { allowed: false, retryAfter };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record this request
|
||||||
|
await ctx.db.insert('rateLimits', {
|
||||||
|
userId: args.userId,
|
||||||
|
action: args.action,
|
||||||
|
timestamp: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use in mutations
|
||||||
|
export const sendMessage = mutation({
|
||||||
|
args: { content: v.string() },
|
||||||
|
returns: v.id('messages'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const identity = await ctx.auth.getUserIdentity();
|
||||||
|
if (!identity) throw new ConvexError('Authentication required');
|
||||||
|
|
||||||
|
// Check rate limit
|
||||||
|
const rateCheck = await checkRateLimit(ctx, {
|
||||||
|
userId: identity.tokenIdentifier,
|
||||||
|
action: 'message',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rateCheck.allowed) {
|
||||||
|
throw new ConvexError({
|
||||||
|
code: 'RATE_LIMITED',
|
||||||
|
message: `Too many requests. Try again in ${Math.ceil(rateCheck.retryAfter! / 1000)} seconds`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ctx.db.insert('messages', {
|
||||||
|
content: args.content,
|
||||||
|
authorId: identity.tokenIdentifier,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sensitive Operations Protection
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/admin.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { internalMutation, mutation } from './_generated/server';
|
||||||
|
import { requirePermission, requireRole } from './lib/auth';
|
||||||
|
|
||||||
|
// Two-factor confirmation for dangerous operations
|
||||||
|
export const deleteAllUserData = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.id('users'),
|
||||||
|
confirmationCode: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Require superadmin
|
||||||
|
const admin = await requireRole(ctx, 'superadmin');
|
||||||
|
|
||||||
|
// Verify confirmation code
|
||||||
|
const confirmation = await ctx.db
|
||||||
|
.query('confirmations')
|
||||||
|
.withIndex('by_admin_and_code', (q) =>
|
||||||
|
q.eq('adminId', admin._id).eq('code', args.confirmationCode),
|
||||||
|
)
|
||||||
|
.filter((q) => q.gt(q.field('expiresAt'), Date.now()))
|
||||||
|
.unique();
|
||||||
|
|
||||||
|
if (!confirmation || confirmation.action !== 'delete_user_data') {
|
||||||
|
throw new ConvexError('Invalid or expired confirmation code');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete confirmation to prevent reuse
|
||||||
|
await ctx.db.delete(confirmation._id);
|
||||||
|
|
||||||
|
// Schedule deletion (don't do it inline)
|
||||||
|
await ctx.scheduler.runAfter(0, internal.admin._performDeletion, {
|
||||||
|
userId: args.userId,
|
||||||
|
requestedBy: admin._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await ctx.db.insert('auditLogs', {
|
||||||
|
action: 'delete_user_data',
|
||||||
|
targetUserId: args.userId,
|
||||||
|
performedBy: admin._id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate confirmation code for sensitive action
|
||||||
|
export const requestDeletionConfirmation = mutation({
|
||||||
|
args: { userId: v.id('users') },
|
||||||
|
returns: v.string(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const admin = await requireRole(ctx, 'superadmin');
|
||||||
|
|
||||||
|
const code = generateSecureCode();
|
||||||
|
|
||||||
|
await ctx.db.insert('confirmations', {
|
||||||
|
adminId: admin._id,
|
||||||
|
code,
|
||||||
|
action: 'delete_user_data',
|
||||||
|
targetUserId: args.userId,
|
||||||
|
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// In production, send code via secure channel (email, SMS)
|
||||||
|
return code;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Complete Audit Trail System
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/audit.ts
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { internalMutation, mutation, query } from './_generated/server';
|
||||||
|
import { getUser, requireRole } from './lib/auth';
|
||||||
|
|
||||||
|
const auditEventValidator = v.object({
|
||||||
|
_id: v.id('auditLogs'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
action: v.string(),
|
||||||
|
userId: v.optional(v.string()),
|
||||||
|
resourceType: v.string(),
|
||||||
|
resourceId: v.string(),
|
||||||
|
details: v.optional(v.any()),
|
||||||
|
ipAddress: v.optional(v.string()),
|
||||||
|
timestamp: v.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Internal: Log audit event
|
||||||
|
export const logEvent = internalMutation({
|
||||||
|
args: {
|
||||||
|
action: v.string(),
|
||||||
|
userId: v.optional(v.string()),
|
||||||
|
resourceType: v.string(),
|
||||||
|
resourceId: v.string(),
|
||||||
|
details: v.optional(v.any()),
|
||||||
|
},
|
||||||
|
returns: v.id('auditLogs'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db.insert('auditLogs', {
|
||||||
|
...args,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin: View audit logs
|
||||||
|
export const getAuditLogs = query({
|
||||||
|
args: {
|
||||||
|
resourceType: v.optional(v.string()),
|
||||||
|
userId: v.optional(v.string()),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
returns: v.array(auditEventValidator),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await requireRole(ctx, 'admin');
|
||||||
|
|
||||||
|
let query = ctx.db.query('auditLogs');
|
||||||
|
|
||||||
|
if (args.resourceType) {
|
||||||
|
query = query.withIndex('by_resource_type', (q) =>
|
||||||
|
q.eq('resourceType', args.resourceType),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.order('desc').take(args.limit ?? 100);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Never run `npx convex deploy` unless explicitly instructed
|
||||||
|
- Never run any git commands unless explicitly instructed
|
||||||
|
- Implement defense in depth (multiple security layers)
|
||||||
|
- Log all sensitive operations for audit trails
|
||||||
|
- Use confirmation codes for destructive actions
|
||||||
|
- Rate limit all user-facing endpoints
|
||||||
|
- Never expose internal API keys or errors
|
||||||
|
- Review access patterns regularly
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Single point of failure** - Implement multiple auth checks
|
||||||
|
2. **Missing audit logs** - Log all sensitive operations
|
||||||
|
3. **Trusting client data** - Always validate server-side
|
||||||
|
4. **Exposing error details** - Sanitize error messages
|
||||||
|
5. **No rate limiting** - Always implement rate limits
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Convex Documentation: https://docs.convex.dev/
|
||||||
|
- Convex LLMs.txt: https://docs.convex.dev/llms.txt
|
||||||
|
- Functions Auth: https://docs.convex.dev/auth/functions-auth
|
||||||
|
- Production Security: https://docs.convex.dev/production
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
---
|
||||||
|
name: convex-security-check
|
||||||
|
displayName: Convex Security Check
|
||||||
|
description: Quick security audit checklist covering authentication, function exposure, argument validation, row-level access control, and environment variable handling
|
||||||
|
version: 1.0.0
|
||||||
|
author: Convex
|
||||||
|
tags: [convex, security, authentication, authorization, checklist]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Convex Security Check
|
||||||
|
|
||||||
|
A quick security audit checklist for Convex applications covering authentication, function exposure, argument validation, row-level access control, and environment variable handling.
|
||||||
|
|
||||||
|
## Documentation Sources
|
||||||
|
|
||||||
|
Before implementing, do not assume; fetch the latest documentation:
|
||||||
|
|
||||||
|
- Primary: https://docs.convex.dev/auth
|
||||||
|
- Production Security: https://docs.convex.dev/production
|
||||||
|
- Functions Auth: https://docs.convex.dev/auth/functions-auth
|
||||||
|
- For broader context: https://docs.convex.dev/llms.txt
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### Security Checklist
|
||||||
|
|
||||||
|
Use this checklist to quickly audit your Convex application's security:
|
||||||
|
|
||||||
|
#### 1. Authentication
|
||||||
|
|
||||||
|
- [ ] Authentication provider configured (Clerk, Auth0, etc.)
|
||||||
|
- [ ] All sensitive queries check `ctx.auth.getUserIdentity()`
|
||||||
|
- [ ] Unauthenticated access explicitly allowed where intended
|
||||||
|
- [ ] Session tokens properly validated
|
||||||
|
|
||||||
|
#### 2. Function Exposure
|
||||||
|
|
||||||
|
- [ ] Public functions (`query`, `mutation`, `action`) reviewed
|
||||||
|
- [ ] Internal functions use `internalQuery`, `internalMutation`, `internalAction`
|
||||||
|
- [ ] No sensitive operations exposed as public functions
|
||||||
|
- [ ] HTTP actions validate origin/authentication
|
||||||
|
|
||||||
|
#### 3. Argument Validation
|
||||||
|
|
||||||
|
- [ ] All functions have explicit `args` validators
|
||||||
|
- [ ] All functions have explicit `returns` validators
|
||||||
|
- [ ] No `v.any()` used for sensitive data
|
||||||
|
- [ ] ID validators use correct table names
|
||||||
|
|
||||||
|
#### 4. Row-Level Access Control
|
||||||
|
|
||||||
|
- [ ] Users can only access their own data
|
||||||
|
- [ ] Admin functions check user roles
|
||||||
|
- [ ] Shared resources have proper access checks
|
||||||
|
- [ ] Deletion functions verify ownership
|
||||||
|
|
||||||
|
#### 5. Environment Variables
|
||||||
|
|
||||||
|
- [ ] API keys stored in environment variables
|
||||||
|
- [ ] No secrets in code or schema
|
||||||
|
- [ ] Different keys for dev/prod environments
|
||||||
|
- [ ] Environment variables accessed only in actions
|
||||||
|
|
||||||
|
### Authentication Check
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/auth.ts
|
||||||
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
|
||||||
|
// Helper to require authentication
|
||||||
|
async function requireAuth(ctx: QueryCtx | MutationCtx) {
|
||||||
|
const identity = await ctx.auth.getUserIdentity();
|
||||||
|
if (!identity) {
|
||||||
|
throw new ConvexError('Authentication required');
|
||||||
|
}
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secure query pattern
|
||||||
|
export const getMyProfile = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('users'),
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
}),
|
||||||
|
v.null(),
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const identity = await requireAuth(ctx);
|
||||||
|
|
||||||
|
return await ctx.db
|
||||||
|
.query('users')
|
||||||
|
.withIndex('by_tokenIdentifier', (q) =>
|
||||||
|
q.eq('tokenIdentifier', identity.tokenIdentifier),
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Function Exposure Check
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// PUBLIC - Exposed to clients (review carefully!)
|
||||||
|
export const listPublicPosts = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
/* ... */
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
// Anyone can call this - intentionally public
|
||||||
|
return await ctx.db
|
||||||
|
.query('posts')
|
||||||
|
.withIndex('by_public', (q) => q.eq('isPublic', true))
|
||||||
|
.collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// INTERNAL - Only callable from other Convex functions
|
||||||
|
export const _updateUserCredits = internalMutation({
|
||||||
|
args: { userId: v.id('users'), amount: v.number() },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// This cannot be called directly from clients
|
||||||
|
await ctx.db.patch(args.userId, {
|
||||||
|
credits: args.amount,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Argument Validation Check
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// GOOD: Strict validation
|
||||||
|
export const createPost = mutation({
|
||||||
|
args: {
|
||||||
|
title: v.string(),
|
||||||
|
content: v.string(),
|
||||||
|
category: v.union(v.literal('tech'), v.literal('news'), v.literal('other')),
|
||||||
|
},
|
||||||
|
returns: v.id('posts'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const identity = await requireAuth(ctx);
|
||||||
|
return await ctx.db.insert('posts', {
|
||||||
|
...args,
|
||||||
|
authorId: identity.tokenIdentifier,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// BAD: Weak validation
|
||||||
|
export const createPostUnsafe = mutation({
|
||||||
|
args: {
|
||||||
|
data: v.any(), // DANGEROUS: Allows any data
|
||||||
|
},
|
||||||
|
returns: v.id('posts'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db.insert('posts', args.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Row-Level Access Control Check
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Verify ownership before update
|
||||||
|
export const updateTask = mutation({
|
||||||
|
args: {
|
||||||
|
taskId: v.id('tasks'),
|
||||||
|
title: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const identity = await requireAuth(ctx);
|
||||||
|
|
||||||
|
const task = await ctx.db.get(args.taskId);
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
if (!task || task.userId !== identity.tokenIdentifier) {
|
||||||
|
throw new ConvexError('Not authorized to update this task');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(args.taskId, { title: args.title });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify ownership before delete
|
||||||
|
export const deleteTask = mutation({
|
||||||
|
args: { taskId: v.id('tasks') },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const identity = await requireAuth(ctx);
|
||||||
|
|
||||||
|
const task = await ctx.db.get(args.taskId);
|
||||||
|
|
||||||
|
if (!task || task.userId !== identity.tokenIdentifier) {
|
||||||
|
throw new ConvexError('Not authorized to delete this task');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.delete(args.taskId);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables Check
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/actions.ts
|
||||||
|
'use node';
|
||||||
|
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
import { action } from './_generated/server';
|
||||||
|
|
||||||
|
export const sendEmail = action({
|
||||||
|
args: {
|
||||||
|
to: v.string(),
|
||||||
|
subject: v.string(),
|
||||||
|
body: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.object({ success: v.boolean() }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Access API key from environment
|
||||||
|
const apiKey = process.env.RESEND_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('RESEND_API_KEY not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('https://api.resend.com/emails', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
from: 'noreply@example.com',
|
||||||
|
to: args.to,
|
||||||
|
subject: args.subject,
|
||||||
|
html: args.body,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: response.ok };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Complete Security Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// convex/secure.ts
|
||||||
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
|
import { internalMutation, mutation, query } from './_generated/server';
|
||||||
|
|
||||||
|
// Authentication helper
|
||||||
|
async function getAuthenticatedUser(ctx: QueryCtx | MutationCtx) {
|
||||||
|
const identity = await ctx.auth.getUserIdentity();
|
||||||
|
if (!identity) {
|
||||||
|
throw new ConvexError({
|
||||||
|
code: 'UNAUTHENTICATED',
|
||||||
|
message: 'You must be logged in',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await ctx.db
|
||||||
|
.query('users')
|
||||||
|
.withIndex('by_tokenIdentifier', (q) =>
|
||||||
|
q.eq('tokenIdentifier', identity.tokenIdentifier),
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ConvexError({
|
||||||
|
code: 'USER_NOT_FOUND',
|
||||||
|
message: 'User profile not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin role
|
||||||
|
async function requireAdmin(ctx: QueryCtx | MutationCtx) {
|
||||||
|
const user = await getAuthenticatedUser(ctx);
|
||||||
|
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
throw new ConvexError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Admin access required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public: List own tasks
|
||||||
|
export const listMyTasks = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('tasks'),
|
||||||
|
title: v.string(),
|
||||||
|
completed: v.boolean(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const user = await getAuthenticatedUser(ctx);
|
||||||
|
|
||||||
|
return await ctx.db
|
||||||
|
.query('tasks')
|
||||||
|
.withIndex('by_user', (q) => q.eq('userId', user._id))
|
||||||
|
.collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin only: List all users
|
||||||
|
export const listAllUsers = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('users'),
|
||||||
|
name: v.string(),
|
||||||
|
role: v.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
await requireAdmin(ctx);
|
||||||
|
|
||||||
|
return await ctx.db.query('users').collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Internal: Update user role (never exposed)
|
||||||
|
export const _setUserRole = internalMutation({
|
||||||
|
args: {
|
||||||
|
userId: v.id('users'),
|
||||||
|
role: v.union(v.literal('user'), v.literal('admin')),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.patch(args.userId, { role: args.role });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Never run `npx convex deploy` unless explicitly instructed
|
||||||
|
- Never run any git commands unless explicitly instructed
|
||||||
|
- Always verify user identity before returning sensitive data
|
||||||
|
- Use internal functions for sensitive operations
|
||||||
|
- Validate all arguments with strict validators
|
||||||
|
- Check ownership before update/delete operations
|
||||||
|
- Store API keys in environment variables
|
||||||
|
- Review all public functions for security implications
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Missing authentication checks** - Always verify identity
|
||||||
|
2. **Exposing internal operations** - Use internalMutation/Query
|
||||||
|
3. **Trusting client-provided IDs** - Verify ownership
|
||||||
|
4. **Using v.any() for arguments** - Use specific validators
|
||||||
|
5. **Hardcoding secrets** - Use environment variables
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Convex Documentation: https://docs.convex.dev/
|
||||||
|
- Convex LLMs.txt: https://docs.convex.dev/llms.txt
|
||||||
|
- Authentication: https://docs.convex.dev/auth
|
||||||
|
- Production Security: https://docs.convex.dev/production
|
||||||
|
- Functions Auth: https://docs.convex.dev/auth/functions-auth
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend-design",
|
||||||
|
"description": "Frontend design skill for UI/UX implementation",
|
||||||
|
"author": {
|
||||||
|
"name": "Anthropic",
|
||||||
|
"email": "support@anthropic.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Frontend Design Plugin
|
||||||
|
|
||||||
|
Generates distinctive, production-grade frontend interfaces that avoid generic AI aesthetics.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
Claude automatically uses this skill for frontend work. Creates production-ready code with:
|
||||||
|
|
||||||
|
- Bold aesthetic choices
|
||||||
|
- Distinctive typography and color palettes
|
||||||
|
- High-impact animations and visual details
|
||||||
|
- Context-aware implementation
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
"Create a dashboard for a music streaming app"
|
||||||
|
"Build a landing page for an AI security startup"
|
||||||
|
"Design a settings panel with dark mode"
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude will choose a clear aesthetic direction and implement production code with meticulous attention to detail.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
See the [Frontend Aesthetics Cookbook](https://github.com/anthropics/claude-cookbooks/blob/main/coding/prompting_for_frontend_aesthetics.ipynb) for detailed guidance on prompting for high-quality frontend design.
|
||||||
|
|
||||||
|
## Authors
|
||||||
|
|
||||||
|
Prithvi Rajasekaran (prithvi@anthropic.com)
|
||||||
|
Alexander Bricken (alexander@anthropic.com)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: frontend-design
|
||||||
|
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
|
||||||
|
license: Complete terms in LICENSE.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
||||||
|
|
||||||
|
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
||||||
|
|
||||||
|
## Design Thinking
|
||||||
|
|
||||||
|
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
||||||
|
|
||||||
|
- **Purpose**: What problem does this interface solve? Who uses it?
|
||||||
|
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
||||||
|
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
||||||
|
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
||||||
|
|
||||||
|
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
||||||
|
|
||||||
|
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
||||||
|
|
||||||
|
- Production-grade and functional
|
||||||
|
- Visually striking and memorable
|
||||||
|
- Cohesive with a clear aesthetic point-of-view
|
||||||
|
- Meticulously refined in every detail
|
||||||
|
|
||||||
|
## Frontend Aesthetics Guidelines
|
||||||
|
|
||||||
|
Focus on:
|
||||||
|
|
||||||
|
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
||||||
|
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
||||||
|
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
||||||
|
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
||||||
|
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
||||||
|
|
||||||
|
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
||||||
|
|
||||||
|
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
||||||
|
|
||||||
|
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
||||||
|
|
||||||
|
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
---
|
||||||
|
name: payload
|
||||||
|
description: Use when working with Payload CMS 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 CMS 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#validation](reference/FIELDS.md#validation) |
|
||||||
|
| Filter relationship list | `filterOptions` on field | [FIELDS.md#relationship](reference/FIELDS.md#relationship) |
|
||||||
|
| Select specific fields | `select` parameter | [QUERIES.md#field-selection](reference/QUERIES.md#field-selection) |
|
||||||
|
| Auto-set author/dates | beforeChange hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) |
|
||||||
|
| Prevent hook loops | `req.context` check | [HOOKS.md#context](reference/HOOKS.md#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 path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { mongooseAdapter } from '@payloadcms/db-mongodb';
|
||||||
|
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||||
|
import { buildConfig } from 'payload';
|
||||||
|
|
||||||
|
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 { User } from '@/payload-types';
|
||||||
|
import type { Access } from 'payload';
|
||||||
|
|
||||||
|
// 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 <div>{docs.map(post => <h1 key={post.id}>{post.title}</h1>)}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
// Usage
|
||||||
|
import type { Post, User } from '@/payload-types';
|
||||||
|
|
||||||
|
// payload.config.ts
|
||||||
|
export default buildConfig({
|
||||||
|
typescript: {
|
||||||
|
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||||
|
},
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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: <https://payloadcms.com/llms-full.txt>
|
||||||
|
- Docs: <https://payloadcms.com/docs>
|
||||||
|
- GitHub: <https://github.com/payloadcms/payload>
|
||||||
|
- Examples: <https://github.com/payloadcms/payload/tree/main/examples>
|
||||||
|
- Templates: <https://github.com/payloadcms/payload/tree/main/templates>
|
||||||
@@ -0,0 +1,720 @@
|
|||||||
|
# Payload CMS 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
|
||||||
@@ -0,0 +1,706 @@
|
|||||||
|
# Payload CMS 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 { Access, CollectionConfig } 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 { FieldAccess, NumberField } 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 { FieldAccess, GroupField } 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 { Access, GlobalConfig } 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).
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
# Payload CMS 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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
# Payload CMS 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 type { TaskConfig } from 'payload';
|
||||||
|
import { buildConfig } 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 type { Endpoint } from 'payload';
|
||||||
|
import { buildConfig } 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 type { TextFieldClientComponent } from 'payload';
|
||||||
|
import { useField } from '@payloadcms/ui';
|
||||||
|
|
||||||
|
export const CustomField: TextFieldClientComponent = () => {
|
||||||
|
const { value, setValue } = useField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input value={value || ''} onChange={(e) => setValue(e.target.value)} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom View
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { DefaultTemplate } from '@payloadcms/next/templates';
|
||||||
|
|
||||||
|
export const CustomView = () => {
|
||||||
|
return (
|
||||||
|
<DefaultTemplate>
|
||||||
|
<h1>Custom Dashboard</h1>
|
||||||
|
{/* Your content */}
|
||||||
|
</DefaultTemplate>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 { redirectsPlugin } from '@payloadcms/plugin-redirects';
|
||||||
|
import { seoPlugin } from '@payloadcms/plugin-seo';
|
||||||
|
import { buildConfig } from 'payload';
|
||||||
|
|
||||||
|
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 type { Field, Payload } from 'payload';
|
||||||
|
import { buildConfig } 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.
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
# Payload CMS 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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
@@ -0,0 +1,648 @@
|
|||||||
|
# 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<Response>` | Async function returning Web API Response |
|
||||||
|
| `custom` | `Record<string, any>` | 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<string, any>` | Path parameters (e.g., `:id`) |
|
||||||
|
| `req.url` | `string` | Full request URL |
|
||||||
|
| `req.method` | `string` | HTTP method |
|
||||||
|
| `req.headers` | `Headers` | Request headers |
|
||||||
|
| `req.json()` | `() => Promise<any>` | Parse JSON body |
|
||||||
|
| `req.text()` | `() => Promise<string>` | 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: <https://payloadcms.com/docs/rest-api/overview>
|
||||||
|
- Custom Endpoints: <https://payloadcms.com/docs/rest-api/overview#custom-endpoints>
|
||||||
|
- Access Control: <https://payloadcms.com/docs/access-control/overview>
|
||||||
|
- Local API: <https://payloadcms.com/docs/local-api/overview>
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
# 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<TField extends ClientField | Field>(
|
||||||
|
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<TField extends ClientField | Field>(
|
||||||
|
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<TField extends ClientField | Field>(
|
||||||
|
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<TField extends ClientField | Field>(
|
||||||
|
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<TField extends ClientField | Field>(
|
||||||
|
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<TField extends ClientField | Field>(
|
||||||
|
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<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
|
||||||
|
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<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
|
||||||
|
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<TField extends ClientField | Field>(
|
||||||
|
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<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
|
||||||
|
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<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
|
||||||
|
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<TField extends ClientTab | Tab>(
|
||||||
|
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<NamedGroupFieldClient>): 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,
|
||||||
|
fieldIsHiddenOrDisabled,
|
||||||
|
fieldIsPresentationalOnly,
|
||||||
|
} from 'payload';
|
||||||
|
|
||||||
|
const dataFields = fields.filter(
|
||||||
|
(field) =>
|
||||||
|
fieldAffectsData(field) &&
|
||||||
|
!fieldIsPresentationalOnly(field) &&
|
||||||
|
!fieldIsHiddenOrDisabled(field),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container Type Switching
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { fieldHasSubFields, fieldIsArrayType, fieldIsBlockType } 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 { fieldHasMaxDepth, fieldSupportsMany } 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,754 @@
|
|||||||
|
# Payload CMS 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 type { CollectionConfig } from 'payload';
|
||||||
|
import { slugField } 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 {
|
||||||
|
HeadingFeature,
|
||||||
|
lexicalEditor,
|
||||||
|
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,
|
||||||
|
lexicalEditor,
|
||||||
|
LinkFeature,
|
||||||
|
OrderedListFeature,
|
||||||
|
UnderlineFeature,
|
||||||
|
UnorderedListFeature,
|
||||||
|
} 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 { Block, BlocksField } 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 { CheckboxField, UploadField } 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 { GroupField, TabsField } 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 = <T>(target: T, source: Partial<T>): T => {
|
||||||
|
// Implementation would deeply merge objects
|
||||||
|
return { ...target, ...source };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reusable link field factory
|
||||||
|
type LinkType = (options?: {
|
||||||
|
appearances?: ('default' | 'outline')[] | false;
|
||||||
|
disableLabel?: boolean;
|
||||||
|
overrides?: Record<string, unknown>;
|
||||||
|
}) => 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.
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Payload CMS 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<Page> = ({
|
||||||
|
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<Page> = ({
|
||||||
|
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))
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
# Payload CMS 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`
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Dependencies - MUST exclude these
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Turbo
|
||||||
|
.turbo
|
||||||
|
**/.turbo
|
||||||
|
|
||||||
|
# Next.js build artifacts
|
||||||
|
.next
|
||||||
|
**/.next
|
||||||
|
out
|
||||||
|
**/out
|
||||||
|
|
||||||
|
# Development
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.local
|
||||||
|
**/.local
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
**/__tests__
|
||||||
|
**/*.test.ts
|
||||||
|
**/*.test.tsx
|
||||||
|
**/*.spec.ts
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist
|
||||||
|
**/dist
|
||||||
|
build
|
||||||
|
**/build
|
||||||
|
|
||||||
|
# Convex local
|
||||||
|
packages/backend/.convex
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Docs
|
||||||
|
docs
|
||||||
|
README.md
|
||||||
|
|
||||||
|
# AI Stuff
|
||||||
|
.claude
|
||||||
|
AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
name: Build and Push Next App
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'apps/**'
|
||||||
|
- 'packages/**'
|
||||||
|
- 'tools/**'
|
||||||
|
- 'scripts/**'
|
||||||
|
- 'docker/**'
|
||||||
|
- '.gitea/workflows/build-next.yml'
|
||||||
|
- '.infisical.json'
|
||||||
|
- 'package.json'
|
||||||
|
- 'bun.lock'
|
||||||
|
- 'turbo.json'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.10
|
||||||
|
- run: bun install --frozen-lockfile
|
||||||
|
- name: Lint, typecheck, and test
|
||||||
|
env:
|
||||||
|
DOTENV_PROD: ${{ secrets.DOTENV_PROD }}
|
||||||
|
run: |
|
||||||
|
env_file="$(mktemp)"
|
||||||
|
trap 'rm -f "$env_file"' EXIT
|
||||||
|
printf '%s\n' "$DOTENV_PROD" > "$env_file"
|
||||||
|
bunx dotenv -e "$env_file" -- env NODE_ENV=test SKIP_E2E=1 bun run ci:check
|
||||||
|
|
||||||
|
build-next:
|
||||||
|
needs: [quality]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Log in to container registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.gbrown.org -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
- name: Build image
|
||||||
|
env:
|
||||||
|
DOTENV_PROD: ${{ secrets.DOTENV_PROD }}
|
||||||
|
run: |
|
||||||
|
env_file="$(mktemp)"
|
||||||
|
trap 'rm -f "$env_file"' EXIT
|
||||||
|
printf '%s\n' "$DOTENV_PROD" > "$env_file"
|
||||||
|
CI_ENV_FILE="$env_file" ./scripts/build-next-app staging
|
||||||
|
- name: Tag and push image
|
||||||
|
run: |
|
||||||
|
docker tag spoon:latest git.gbrown.org/gib/spoon:${{ gitea.sha }}
|
||||||
|
docker tag spoon:latest git.gbrown.org/gib/spoon:latest
|
||||||
|
docker push git.gbrown.org/gib/spoon:${{ gitea.sha }}
|
||||||
|
docker push git.gbrown.org/gib/spoon:latest
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# Hosting
|
||||||
|
/docker/data
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
coverage
|
||||||
|
**/.cache/
|
||||||
|
|
||||||
|
# generated Convex client/server bindings (tracked, but not linted/formatted)
|
||||||
|
packages/backend/convex/_generated/
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next/
|
||||||
|
.swc/
|
||||||
|
out/
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# production
|
||||||
|
build
|
||||||
|
|
||||||
|
# nitro
|
||||||
|
.nitro/
|
||||||
|
.output/
|
||||||
|
|
||||||
|
# expo
|
||||||
|
.expo/
|
||||||
|
expo-env.d.ts
|
||||||
|
apps/expo/.gitignore
|
||||||
|
apps/expo/ios
|
||||||
|
apps/expo/android
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
dist
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env
|
||||||
|
.env.staging
|
||||||
|
*.env
|
||||||
|
.local/
|
||||||
|
|
||||||
|
# turbo
|
||||||
|
.turbo/
|
||||||
|
**/.turbo/
|
||||||
|
|
||||||
|
# playwright e2e
|
||||||
|
apps/next/tests/e2e/.auth/
|
||||||
|
apps/next/test-results/
|
||||||
|
apps/next/playwright-report/
|
||||||
|
|
||||||
|
# tanstack
|
||||||
|
.tanstack
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
bunx lint-staged --concurrent 1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
bun run ci:check
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"workspaceId": "57f4b778-582f-49e2-89e1-5c7e6077facb",
|
||||||
|
"defaultEnvironment": "",
|
||||||
|
"gitBranchToEnvironmentMapping": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `apps/next`: Next.js 16 frontend.
|
||||||
|
- `apps/expo`: Expo scaffold; only work here when explicitly requested.
|
||||||
|
- `packages/backend/convex`: self-hosted Convex functions, schema, and auth.
|
||||||
|
- `packages/ui`: shared shadcn-based UI components.
|
||||||
|
- `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config.
|
||||||
|
- Local development uses host-run apps, local Convex on ports 3210/3211, local
|
||||||
|
Postgres on port 5432 for Convex storage, and the Convex dashboard on port 6791.
|
||||||
|
|
||||||
|
## Protected and generated files
|
||||||
|
|
||||||
|
- Never edit `packages/backend/convex/_generated/**` or
|
||||||
|
`packages/backend/convex/http.ts`.
|
||||||
|
- Do not rename `apps/next/next.config.js` or `apps/next/src/proxy.ts`.
|
||||||
|
- Preserve `typescript.ignoreBuildErrors` in Next config.
|
||||||
|
- Do not modify Sentry config or `tools/tailwind/theme.css` unless requested.
|
||||||
|
- Generated `.cache`, `.turbo`, `.local`, and environment files are ignored.
|
||||||
|
|
||||||
|
## Environment rules
|
||||||
|
|
||||||
|
- Local `dev` and `staging` come only from Infisical via
|
||||||
|
`scripts/with-env`; it never falls back to `.env*`.
|
||||||
|
- Run `infisical login` and `infisical init` before local development.
|
||||||
|
- Machine-generated values belong in `.local/<env>.generated.env`; never put
|
||||||
|
the generated Convex admin key in shared Infisical.
|
||||||
|
- CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical.
|
||||||
|
- App code imports validated variables from `@/env`, never `process.env`.
|
||||||
|
- Add cache-relevant variables to `turbo.json` `globalEnv`.
|
||||||
|
|
||||||
|
## Code and dependency rules
|
||||||
|
|
||||||
|
- Use `const` arrow functions instead of function declarations.
|
||||||
|
- Add `.js` extensions to Convex generated-file imports.
|
||||||
|
- Put custom frontend routes directly in `apps/next/src/app` or normal route
|
||||||
|
groups such as `apps/next/src/app/(auth)`.
|
||||||
|
- Extend the Convex `users` table for application user data; do not add a
|
||||||
|
profile table by default. Authenticate protected functions.
|
||||||
|
- Manage shared versions in root catalogs. Never run `bun update` in a package.
|
||||||
|
- Run root `bun install` and verify with `bun lint:ws`.
|
||||||
|
- Update this guide when architecture or workflows change.
|
||||||
|
|
||||||
|
## Local stack
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun db:up # start Postgres, Convex, and dashboard
|
||||||
|
bun dev:next # host Next + deploy/watch local Convex functions
|
||||||
|
bun db:down # stop and preserve local data
|
||||||
|
bun db:down:wipe # remove local data volumes and generated admin key
|
||||||
|
```
|
||||||
|
|
||||||
|
Local URLs: app `http://localhost:3000`, Convex `http://localhost:3210`,
|
||||||
|
dashboard `http://localhost:6791`, Postgres `localhost:5432`.
|
||||||
|
Use `INFISICAL_ENV=staging bun dev:next` for staging services. Normal `db:up`
|
||||||
|
never connects to staging.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Use `bun typecheck`, never a production build for routine validation. The full
|
||||||
|
gate is `SKIP_E2E=1 bun run ci:check`; local-stack smoke e2e is `bun test:e2e`.
|
||||||
|
Pre-commit runs lint-staged serially and pre-push runs the bounded full gate.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Spoon
|
||||||
|
|
||||||
|
A reusable Bun/Turborepo template with Next.js 16, Expo, self-hosted Convex,
|
||||||
|
shared UI/config packages, Vitest, and Docker deployment.
|
||||||
|
|
||||||
|
## Local setup
|
||||||
|
|
||||||
|
Requirements: Bun 1.3.10, Docker or Podman, Node 22, and the Infisical CLI.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
infisical login
|
||||||
|
infisical init
|
||||||
|
bun db:up
|
||||||
|
bun dev:next
|
||||||
|
```
|
||||||
|
|
||||||
|
The committed `.infisical.json` links this repository to its own Infisical
|
||||||
|
project. Local commands read `dev` by default and never fall back to `.env`
|
||||||
|
files. Select staging with `INFISICAL_ENV=staging bun dev:next`.
|
||||||
|
|
||||||
|
Local services:
|
||||||
|
|
||||||
|
- Next.js: `http://localhost:3000`
|
||||||
|
- Convex API: `http://localhost:3210`
|
||||||
|
- Convex dashboard: `http://localhost:6791`
|
||||||
|
- Convex Postgres: `localhost:5432`
|
||||||
|
|
||||||
|
Next and Expo run on the host. Spoon runs local Convex with Postgres storage by
|
||||||
|
default. Local Compose creates the database named by `LOCAL_INSTANCE_NAME`
|
||||||
|
because the Convex backend opens that database inside the Postgres cluster.
|
||||||
|
Convex receives a database-cluster URL without a path.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun db:down # stop; preserve local data
|
||||||
|
bun db:down:wipe # remove local data volumes and generated admin key
|
||||||
|
```
|
||||||
|
|
||||||
|
Normal `bun db:up` never contacts staging. It starts local Postgres, Convex, and
|
||||||
|
the dashboard, generates a machine-local Convex admin key in
|
||||||
|
`.local/dev.generated.env` when needed, deploys functions/schema, and configures
|
||||||
|
local Convex Auth keys.
|
||||||
|
|
||||||
|
Physical devices cannot resolve their own `localhost`; override the public
|
||||||
|
Convex URL with the development host's LAN address when testing Expo on-device.
|
||||||
|
|
||||||
|
## Environment model
|
||||||
|
|
||||||
|
- Local `dev` and `staging`: Infisical.
|
||||||
|
- Generated local state: `.local/<environment>.generated.env`.
|
||||||
|
- CI/CD: Gitea `DOTENV_PROD`, materialized only as a temporary runner file.
|
||||||
|
- Docker compilation: explicit Compose build args; `.env*` stays outside the
|
||||||
|
image context.
|
||||||
|
|
||||||
|
Run `sh scripts/with-env dev -- <command>` for an environment-aware command or
|
||||||
|
`sh scripts/export-env dev` to materialize a temporary merged dotenv stream.
|
||||||
|
Do not commit or maintain root `.env` files.
|
||||||
|
|
||||||
|
## Development and quality
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun dev:next
|
||||||
|
bun dev:expo
|
||||||
|
bun lint:ws
|
||||||
|
bun format
|
||||||
|
bun lint
|
||||||
|
bun typecheck
|
||||||
|
bun test:unit
|
||||||
|
bun test:integration
|
||||||
|
bun test:component
|
||||||
|
SKIP_E2E=1 bun run ci:check
|
||||||
|
```
|
||||||
|
|
||||||
|
`bun test:e2e` starts the isolated local stack and currently performs generic
|
||||||
|
stack smoke checks. It stops the stack afterward only when the stack was not
|
||||||
|
already running. It skips in CI and when `SKIP_E2E=1` is set.
|
||||||
|
|
||||||
|
Shared dependency versions belong in root catalogs. Edit the root catalog, run
|
||||||
|
`bun install`, then `bun lint:ws`; do not run `bun update` inside a workspace.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Production Compose retains the self-hosted Convex backend/dashboard and expects
|
||||||
|
`POSTGRES_URL` to be a database-cluster URL without a database path. Gitea runs
|
||||||
|
the quality gate first, builds the Next image from a temporary Gitea-secret env
|
||||||
|
file, then pushes SHA and `latest` tags. CI never installs or invokes Infisical.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
|
||||||
|
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { ConfigContext, ExpoConfig } from 'expo/config';
|
||||||
|
|
||||||
|
export default ({ config }: ConfigContext): ExpoConfig => ({
|
||||||
|
...config,
|
||||||
|
name: 'Spoon',
|
||||||
|
slug: 'spoon',
|
||||||
|
scheme: 'spoon',
|
||||||
|
version: '0.1.0',
|
||||||
|
orientation: 'portrait',
|
||||||
|
icon: './assets/icon-light.png',
|
||||||
|
userInterfaceStyle: 'automatic',
|
||||||
|
updates: {
|
||||||
|
fallbackToCacheTimeout: 0,
|
||||||
|
},
|
||||||
|
newArchEnabled: true,
|
||||||
|
assetBundlePatterns: ['**/*'],
|
||||||
|
ios: {
|
||||||
|
bundleIdentifier: 'org.gbrown.spoon',
|
||||||
|
supportsTablet: true,
|
||||||
|
icon: {
|
||||||
|
light: './assets/icon-light.png',
|
||||||
|
dark: './assets/icon-dark.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
package: 'org.gbrown.spoon',
|
||||||
|
adaptiveIcon: {
|
||||||
|
foregroundImage: './assets/icon-light.png',
|
||||||
|
backgroundColor: '#0f766e',
|
||||||
|
},
|
||||||
|
edgeToEdgeEnabled: true,
|
||||||
|
},
|
||||||
|
// extra: {
|
||||||
|
// eas: {
|
||||||
|
// projectId: "your-eas-project-id",
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
experiments: {
|
||||||
|
tsconfigPaths: true,
|
||||||
|
typedRoutes: true,
|
||||||
|
reactCanary: true,
|
||||||
|
reactCompiler: true,
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
'expo-router',
|
||||||
|
'expo-secure-store',
|
||||||
|
'expo-web-browser',
|
||||||
|
[
|
||||||
|
'expo-splash-screen',
|
||||||
|
{
|
||||||
|
backgroundColor: '#f8fafc',
|
||||||
|
image: './assets/icon-light.png',
|
||||||
|
dark: {
|
||||||
|
backgroundColor: '#111827',
|
||||||
|
image: './assets/icon-dark.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 4.1.2",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"base": {
|
||||||
|
"node": "22.12.0",
|
||||||
|
"bun": "1.3.10",
|
||||||
|
"ios": {
|
||||||
|
"resourceClass": "m-medium"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"extends": "base",
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"extends": "base",
|
||||||
|
"distribution": "internal",
|
||||||
|
"ios": {
|
||||||
|
"simulator": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"extends": "base"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'eslint/config';
|
||||||
|
|
||||||
|
import { baseConfig } from '@spoon/eslint-config/base';
|
||||||
|
import { reactConfig } from '@spoon/eslint-config/react';
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
{
|
||||||
|
ignores: ['.expo/**', 'expo-plugins/**'],
|
||||||
|
},
|
||||||
|
baseConfig,
|
||||||
|
reactConfig,
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import 'expo-router/entry';
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const path = require('node:path');
|
||||||
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
const { FileStore } = require('metro-cache');
|
||||||
|
const { withNativewind } = require('nativewind/metro');
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
config.cacheStores = [
|
||||||
|
new FileStore({
|
||||||
|
root: path.join(__dirname, 'node_modules', '.cache', 'metro'),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
|
module.exports = withNativewind(config);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/// <reference types="react-native-css/types" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited and should be committed with your source code. It is generated by react-native-css. If you need to move or disable this file, please see the documentation.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"name": "@spoon/expo",
|
||||||
|
"private": true,
|
||||||
|
"main": "index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"clean": "git clean -xdf .cache .expo .turbo android ios node_modules",
|
||||||
|
"dev": "bun with-env expo start",
|
||||||
|
"dev:tunnel": "bun with-env expo start --tunnel",
|
||||||
|
"dev:android": "bun with-env expo start --android",
|
||||||
|
"dev:ios": "bun with-env expo start --ios",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore",
|
||||||
|
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@convex-dev/auth": "catalog:convex",
|
||||||
|
"@expo/vector-icons": "^15.1.1",
|
||||||
|
"@legendapp/list": "^2.0.19",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.15.8",
|
||||||
|
"@react-navigation/elements": "^2.9.13",
|
||||||
|
"@react-navigation/native": "^7.2.1",
|
||||||
|
"@sentry/react-native": "^7.13.0",
|
||||||
|
"@spoon/backend": "workspace:*",
|
||||||
|
"convex": "catalog:convex",
|
||||||
|
"expo": "~54.0.33",
|
||||||
|
"expo-apple-authentication": "~8.0.8",
|
||||||
|
"expo-constants": "~18.0.13",
|
||||||
|
"expo-dev-client": "~6.0.20",
|
||||||
|
"expo-font": "~14.0.11",
|
||||||
|
"expo-haptics": "~15.0.8",
|
||||||
|
"expo-image": "~3.0.11",
|
||||||
|
"expo-linking": "~8.0.11",
|
||||||
|
"expo-router": "~6.0.23",
|
||||||
|
"expo-secure-store": "~15.0.8",
|
||||||
|
"expo-splash-screen": "~31.0.13",
|
||||||
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-symbols": "~1.0.8",
|
||||||
|
"expo-system-ui": "~6.0.9",
|
||||||
|
"expo-web-browser": "~15.0.10",
|
||||||
|
"nativewind": "5.0.0-preview.2",
|
||||||
|
"react": "catalog:react19",
|
||||||
|
"react-dom": "catalog:react19",
|
||||||
|
"react-native": "~0.81.6",
|
||||||
|
"react-native-css": "3.0.1",
|
||||||
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-reanimated": "~4.1.7",
|
||||||
|
"react-native-safe-area-context": "~5.6.2",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-web": "~0.21.2",
|
||||||
|
"react-native-worklets": "~0.5.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@spoon/eslint-config": "workspace:*",
|
||||||
|
"@spoon/prettier-config": "workspace:*",
|
||||||
|
"@spoon/tailwind-config": "workspace:*",
|
||||||
|
"@spoon/tsconfig": "workspace:*",
|
||||||
|
"@types/react": "catalog:react19",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"prettier": "catalog:",
|
||||||
|
"tailwindcss": "catalog:",
|
||||||
|
"typescript": "catalog:"
|
||||||
|
},
|
||||||
|
"prettier": "@spoon/prettier-config"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
module.exports = require('@spoon/tailwind-config/postcss-config');
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useColorScheme } from 'react-native';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { ConvexAuthProvider } from '@convex-dev/auth/react';
|
||||||
|
|
||||||
|
import { convex } from '~/utils/convex';
|
||||||
|
import { secureTokenStorage } from '~/utils/session-store';
|
||||||
|
|
||||||
|
import '../styles.css';
|
||||||
|
|
||||||
|
const RootLayout = () => {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
return (
|
||||||
|
<ConvexAuthProvider client={convex} storage={secureTokenStorage}>
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: colorScheme === 'dark' ? '#111827' : '#f8fafc',
|
||||||
|
},
|
||||||
|
headerTintColor: colorScheme === 'dark' ? '#f8fafc' : '#111827',
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: colorScheme === 'dark' ? '#111827' : '#f8fafc',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StatusBar style='auto' />
|
||||||
|
</ConvexAuthProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RootLayout;
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Alert, Pressable, Text, TextInput, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import * as Linking from 'expo-linking';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
import { useAuthActions } from '@convex-dev/auth/react';
|
||||||
|
import { useConvexAuth, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
WebBrowser.maybeCompleteAuthSession();
|
||||||
|
|
||||||
|
const Stat = ({ label, value }: { label: string; value: number }) => (
|
||||||
|
<View className='border-border bg-card flex-1 rounded-lg border p-4'>
|
||||||
|
<Text className='text-muted-foreground text-xs'>{label}</Text>
|
||||||
|
<Text className='text-foreground mt-2 text-2xl font-bold'>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Index = () => {
|
||||||
|
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||||
|
const { signIn, signOut } = useAuthActions();
|
||||||
|
const user = useQuery(api.auth.getUser, isAuthenticated ? {} : 'skip');
|
||||||
|
const spoons =
|
||||||
|
useQuery(api.spoons.listMine, isAuthenticated ? {} : 'skip') ?? [];
|
||||||
|
const syncRuns =
|
||||||
|
useQuery(
|
||||||
|
api.syncRuns.listRecent,
|
||||||
|
isAuthenticated ? { limit: 5 } : 'skip',
|
||||||
|
) ?? [];
|
||||||
|
const agentRequests =
|
||||||
|
useQuery(
|
||||||
|
api.agentRequests.listRecent,
|
||||||
|
isAuthenticated ? { limit: 5 } : 'skip',
|
||||||
|
) ?? [];
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const redirectTo = useMemo(() => Linking.createURL(''), []);
|
||||||
|
|
||||||
|
const handlePasswordSignIn = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await signIn('password', { email, password, flow: 'signIn' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Sign in failed', 'Check your email and password.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthentikSignIn = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await signIn('authentik', { redirectTo });
|
||||||
|
if (!result.redirect) return;
|
||||||
|
const authResult = await WebBrowser.openAuthSessionAsync(
|
||||||
|
result.redirect.toString(),
|
||||||
|
redirectTo,
|
||||||
|
);
|
||||||
|
if (authResult.type !== 'success') return;
|
||||||
|
const parsed = Linking.parse(authResult.url);
|
||||||
|
const code = parsed.queryParams?.code;
|
||||||
|
if (typeof code !== 'string') {
|
||||||
|
Alert.alert('Sign in failed', 'Authentik did not return a code.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await signIn('authentik', { code });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Sign in failed', 'Could not complete Authentik sign in.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className='bg-background flex-1'>
|
||||||
|
<Stack.Screen options={{ title: 'Spoon' }} />
|
||||||
|
<View className='flex-1 gap-5 p-6'>
|
||||||
|
<View>
|
||||||
|
<Text className='text-foreground text-4xl font-bold'>Spoon</Text>
|
||||||
|
<Text className='text-muted-foreground mt-2 text-base leading-6'>
|
||||||
|
Fork freely. Stay close to upstream.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Text className='text-muted-foreground'>Loading...</Text>
|
||||||
|
) : isAuthenticated ? (
|
||||||
|
<View className='gap-5'>
|
||||||
|
<View>
|
||||||
|
<Text className='text-foreground text-xl font-semibold'>
|
||||||
|
Welcome{user?.name ? `, ${user.name}` : ''}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground mt-1'>
|
||||||
|
Monitor your managed forks from anywhere.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<Stat label='Spoons' value={spoons.length} />
|
||||||
|
<Stat label='Updates' value={syncRuns.length} />
|
||||||
|
<Stat label='Agents' value={agentRequests.length} />
|
||||||
|
</View>
|
||||||
|
<View className='border-border bg-card rounded-lg border p-4'>
|
||||||
|
<Text className='text-foreground font-semibold'>
|
||||||
|
Recent Spoons
|
||||||
|
</Text>
|
||||||
|
{spoons.length ? (
|
||||||
|
spoons.slice(0, 4).map((spoon) => (
|
||||||
|
<Text key={spoon._id} className='text-muted-foreground mt-3'>
|
||||||
|
{spoon.name} - {spoon.status.replaceAll('_', ' ')}
|
||||||
|
</Text>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text className='text-muted-foreground mt-3'>
|
||||||
|
Create your first Spoon from the web dashboard.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
className='bg-primary items-center rounded-md p-3'
|
||||||
|
onPress={() => void signOut()}
|
||||||
|
>
|
||||||
|
<Text className='text-primary-foreground font-semibold'>
|
||||||
|
Sign out
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className='gap-4'>
|
||||||
|
<TextInput
|
||||||
|
className='border-input text-foreground rounded-md border px-3 py-3'
|
||||||
|
autoCapitalize='none'
|
||||||
|
keyboardType='email-address'
|
||||||
|
placeholder='Email'
|
||||||
|
placeholderTextColor='#64748b'
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
className='border-input text-foreground rounded-md border px-3 py-3'
|
||||||
|
secureTextEntry
|
||||||
|
placeholder='Password'
|
||||||
|
placeholderTextColor='#64748b'
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
className='bg-primary items-center rounded-md p-3 disabled:opacity-60'
|
||||||
|
disabled={submitting}
|
||||||
|
onPress={() => void handlePasswordSignIn()}
|
||||||
|
>
|
||||||
|
<Text className='text-primary-foreground font-semibold'>
|
||||||
|
Sign in with password
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
className='border-border items-center rounded-md border p-3 disabled:opacity-60'
|
||||||
|
disabled={submitting}
|
||||||
|
onPress={() => void handleAuthentikSignIn()}
|
||||||
|
>
|
||||||
|
<Text className='text-foreground font-semibold'>
|
||||||
|
Continue with Authentik
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Register the native redirect URI based on spoon:// in Authentik.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Index;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||||
|
|
||||||
|
const Post = () => {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className='bg-background flex-1'>
|
||||||
|
<Stack.Screen options={{ title: 'Post' }} />
|
||||||
|
<View className='flex-1 p-4'>
|
||||||
|
<Text className='text-foreground text-2xl font-bold'>Post {id}</Text>
|
||||||
|
<Text className='text-muted-foreground mt-2'>
|
||||||
|
Implement your post detail screen here using Convex queries.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Post;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
declare module '*.css';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@import 'nativewind/theme';
|
||||||
|
@import '@spoon/tailwind-config/theme';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Constants from 'expo-constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend this function when going to production by
|
||||||
|
* setting the baseUrl to your production API URL.
|
||||||
|
*/
|
||||||
|
export const getBaseUrl = () => {
|
||||||
|
/**
|
||||||
|
* Gets the IP address of your host-machine. If it cannot automatically find it,
|
||||||
|
* you'll have to manually set it. NOTE: Port 3000 should work for most but confirm
|
||||||
|
* you don't have anything else running on it, or you'd have to change it.
|
||||||
|
*
|
||||||
|
* **NOTE**: This is only for development. In production, you'll want to set the
|
||||||
|
* baseUrl to your production API URL.
|
||||||
|
*/
|
||||||
|
const debuggerHost = Constants.expoConfig?.hostUri;
|
||||||
|
const localhost = debuggerHost?.split(':')[0];
|
||||||
|
|
||||||
|
if (!localhost) {
|
||||||
|
// return "https://turbo.t3.gg";
|
||||||
|
throw new Error(
|
||||||
|
'Failed to get localhost. Please point to your production server.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `http://${localhost}:3000`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Constants from 'expo-constants';
|
||||||
|
import { ConvexReactClient } from 'convex/react';
|
||||||
|
|
||||||
|
const getConvexUrl = (): string => {
|
||||||
|
// Allow override via Expo extra config (set in app.config.ts for production)
|
||||||
|
const fromConfig = Constants.expoConfig?.extra?.convexUrl as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
if (fromConfig) return fromConfig;
|
||||||
|
|
||||||
|
// Fall back to deriving from the dev server host (same pattern as getBaseUrl)
|
||||||
|
const debuggerHost = Constants.expoConfig?.hostUri;
|
||||||
|
const localhost = debuggerHost?.split(':')[0];
|
||||||
|
|
||||||
|
if (!localhost) {
|
||||||
|
throw new Error(
|
||||||
|
'Could not determine Convex URL. Set extra.convexUrl in app.config.ts for production.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point at the self-hosted Convex backend on the local network
|
||||||
|
// Update this port if your Convex backend runs on a different port
|
||||||
|
return `http://${localhost}:3210`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const convex = new ConvexReactClient(getConvexUrl());
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
|
export const secureTokenStorage = {
|
||||||
|
getItem: (key: string) => SecureStore.getItemAsync(key),
|
||||||
|
setItem: (key: string, value: string) => SecureStore.setItemAsync(key, value),
|
||||||
|
removeItem: (key: string) => SecureStore.deleteItemAsync(key),
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": ["@spoon/tsconfig/base.json"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-native",
|
||||||
|
"checkJs": false,
|
||||||
|
"moduleSuffixes": [".ios", ".android", ".native", ""],
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src",
|
||||||
|
"*.ts",
|
||||||
|
"*.js",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts",
|
||||||
|
"nativewind-env.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://v2-8-20.turborepo.dev/schema.json",
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"dev": {
|
||||||
|
"persistent": true,
|
||||||
|
"interactive": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'eslint/config';
|
||||||
|
|
||||||
|
import { baseConfig, restrictEnvAccess } from '@spoon/eslint-config/base';
|
||||||
|
import { nextjsConfig } from '@spoon/eslint-config/nextjs';
|
||||||
|
import { reactConfig } from '@spoon/eslint-config/react';
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
{
|
||||||
|
ignores: ['.next/**'],
|
||||||
|
},
|
||||||
|
baseConfig,
|
||||||
|
reactConfig,
|
||||||
|
nextjsConfig,
|
||||||
|
restrictEnvAccess,
|
||||||
|
);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { withSentryConfig } from '@sentry/nextjs';
|
||||||
|
import { createJiti } from 'jiti';
|
||||||
|
import { withPlausibleProxy } from 'next-plausible';
|
||||||
|
|
||||||
|
const jiti = createJiti(import.meta.url);
|
||||||
|
await jiti.import('./src/env');
|
||||||
|
|
||||||
|
/** @type {import("next").NextConfig} */
|
||||||
|
const config = withPlausibleProxy({
|
||||||
|
customDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
||||||
|
})({
|
||||||
|
output: 'standalone',
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: '*.gbrown.org',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
serverExternalPackages: ['require-in-the-middle'],
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: '10mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/** Enables hot reloading for local packages without a build step */
|
||||||
|
transpilePackages: ['@spoon/backend', '@spoon/ui'],
|
||||||
|
typescript: { ignoreBuildErrors: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sentryConfig = {
|
||||||
|
// For all available options, see:
|
||||||
|
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||||
|
org: process.env.NEXT_PUBLIC_SENTRY_ORG,
|
||||||
|
project: process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
|
||||||
|
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
|
||||||
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
|
// Only print logs for uploading source maps in CI
|
||||||
|
silent: !process.env.CI,
|
||||||
|
// For all available options, see:
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||||
|
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||||
|
widenClientFileUpload: true,
|
||||||
|
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||||
|
// This can increase your server load as well as your hosting bill.
|
||||||
|
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||||
|
// side errors will fail.
|
||||||
|
tunnelRoute: '/monitoring',
|
||||||
|
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||||
|
disableLogger: true,
|
||||||
|
// Capture React Component Names
|
||||||
|
reactComponentAnnotation: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default withSentryConfig(config, sentryConfig);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "@spoon/next",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "bun with-env next build",
|
||||||
|
"build:docker": "next build --webpack",
|
||||||
|
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||||
|
"dev": "bun with-env next dev --turbo",
|
||||||
|
"dev:tunnel": "bun with-env next dev --turbo",
|
||||||
|
"dev:web": "bun with-env next dev --webpack",
|
||||||
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
|
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||||
|
"start": "bun with-env next start",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test:unit": "NODE_ENV=test vitest run --project unit",
|
||||||
|
"test:integration": "NODE_ENV=test vitest run --project integration",
|
||||||
|
"test:component": "NODE_ENV=test vitest run --project component",
|
||||||
|
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@convex-dev/auth": "catalog:convex",
|
||||||
|
"@sentry/nextjs": "^10.46.0",
|
||||||
|
"@spoon/backend": "workspace:*",
|
||||||
|
"@spoon/ui": "workspace:*",
|
||||||
|
"@t3-oss/env-nextjs": "^0.13.11",
|
||||||
|
"convex": "catalog:convex",
|
||||||
|
"next": "^16.2.1",
|
||||||
|
"next-plausible": "^3.12.5",
|
||||||
|
"react": "catalog:react19",
|
||||||
|
"react-dom": "catalog:react19",
|
||||||
|
"require-in-the-middle": "^7.5.2",
|
||||||
|
"superjson": "2.2.3",
|
||||||
|
"zod": "catalog:"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@spoon/eslint-config": "workspace:*",
|
||||||
|
"@spoon/prettier-config": "workspace:*",
|
||||||
|
"@spoon/tailwind-config": "workspace:*",
|
||||||
|
"@spoon/tsconfig": "workspace:*",
|
||||||
|
"@spoon/vitest-config": "workspace:*",
|
||||||
|
"@testing-library/react": "catalog:test",
|
||||||
|
"@types/node": "catalog:",
|
||||||
|
"@types/react": "catalog:react19",
|
||||||
|
"@types/react-dom": "catalog:react19",
|
||||||
|
"baseline-browser-mapping": "^2.10.11",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"jsdom": "catalog:test",
|
||||||
|
"prettier": "catalog:",
|
||||||
|
"tailwindcss": "catalog:",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
"vitest": "catalog:test"
|
||||||
|
},
|
||||||
|
"prettier": "@spoon/prettier-config"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from '@spoon/tailwind-config/postcss-config';
|
||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 845 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="382" height="146" viewBox="0 0 382 146" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M114.794 86.6648C111.454 83.6785 109.784 79.2644 109.784 73.434C109.784 67.6036 111.487 63.1896 114.896 60.2033C118.301 57.217 122.959 55.721 128.865 55.721C131.319 55.721 133.486 55.8973 135.372 56.2613C137.258 56.6197 139.063 57.2283 140.786 58.0929V67.5524C138.106 66.2157 135.064 65.5445 131.659 65.5445C128.66 65.5445 126.445 66.1417 125.018 67.3363C123.586 68.5308 122.873 70.5615 122.873 73.434C122.873 76.2099 123.575 78.2178 124.986 79.4578C126.391 80.7035 128.617 81.3236 131.665 81.3236C134.891 81.3236 137.955 80.5329 140.862 78.9573V88.8547C137.636 90.3849 133.615 91.1471 128.801 91.1471C122.797 91.1471 118.133 89.6511 114.794 86.6648Z" fill="#141414"/>
|
||||||
|
<path d="M143.77 73.4279C143.77 67.643 145.337 63.246 148.471 60.2312C151.605 57.2165 156.328 55.7148 162.645 55.7148C169.006 55.7148 173.761 57.2222 176.922 60.2312C180.078 63.2403 181.656 67.643 181.656 73.4279C181.656 85.2366 175.318 91.1409 162.645 91.1409C150.06 91.1466 143.77 85.2423 143.77 73.4279ZM167.179 79.4574C168.109 78.2116 168.574 76.2037 168.574 73.4335C168.574 70.7089 168.109 68.7123 167.179 67.4439C166.25 66.1754 164.737 65.544 162.645 65.544C160.603 65.544 159.122 66.1811 158.214 67.4439C157.306 68.7123 156.853 70.7089 156.853 73.4335C156.853 76.2094 157.306 78.2173 158.214 79.4574C159.122 80.7031 160.597 81.3231 162.645 81.3231C164.737 81.3231 166.244 80.6974 167.179 79.4574Z" fill="#141414"/>
|
||||||
|
<path d="M184.638 56.4315H196.629L196.97 59.014C198.288 58.0583 199.969 57.2677 202.011 56.6477C204.054 56.0276 206.167 55.7148 208.35 55.7148C212.392 55.7148 215.343 56.7671 217.207 58.8718C219.071 60.9764 220.001 64.2244 220.001 68.627V90.4299H207.194V69.9865C207.194 68.4564 206.864 67.3585 206.205 66.6873C205.546 66.0161 204.443 65.6862 202.898 65.6862C201.947 65.6862 200.968 65.9137 199.969 66.3688C198.969 66.8239 198.131 67.4097 197.445 68.1265V90.4299H184.638V56.4315Z" fill="#141414"/>
|
||||||
|
<path d="M220.038 56.4317H233.391L239.524 76.3689L245.658 56.4317H259.011L246.268 90.4301H232.775L220.038 56.4317Z" fill="#141414"/>
|
||||||
|
<path d="M263.043 87.5062C259.195 84.4687 257.396 79.1957 257.396 73.5018C257.396 67.9558 258.828 63.3882 262.097 60.2312C265.366 57.0743 270.349 55.7148 276.639 55.7148C282.426 55.7148 286.976 57.1255 290.3 59.9468C293.618 62.7682 295.282 66.6191 295.282 71.4939V77.4494H270.927C271.532 79.2184 272.299 80.4983 274.185 81.289C276.071 82.0796 278.703 82.4721 282.07 82.4721C284.08 82.4721 286.133 82.3071 288.219 81.9715C288.954 81.8521 290.165 81.6644 290.802 81.5222V89.7871C287.619 90.6972 283.377 91.1523 278.595 91.1523C272.159 91.1466 266.89 90.5437 263.043 87.5062ZM281.826 70.1344C281.826 68.4507 279.984 64.8273 276.282 64.8273C272.942 64.8273 270.738 68.3938 270.738 70.1344H281.826Z" fill="#141414"/>
|
||||||
|
<path d="M305.338 73.1437L293.346 56.4317H307.245L331.773 90.4301H317.74L312.287 82.825L306.835 90.4301H292.865L305.338 73.1437Z" fill="#141414"/>
|
||||||
|
<path d="M317.431 56.4317H331.265L320.647 71.3178L313.622 61.7787L317.431 56.4317Z" fill="#141414"/>
|
||||||
|
<path d="M82.2808 87.6517C89.652 86.8381 96.6012 82.9353 100.427 76.4211C98.6156 92.5331 80.8853 102.717 66.413 96.4643C65.0795 95.8897 63.9316 94.9339 63.1438 93.705C59.8915 88.6302 58.8224 82.1729 60.3585 76.313C64.7475 83.8399 73.6717 88.4539 82.2808 87.6517Z" fill="#141414"/>
|
||||||
|
<path d="M60.0895 71.5852C57.1016 78.4464 56.9722 86.4797 60.6353 93.0906C47.7442 83.453 47.8848 62.8294 60.4778 53.2885C61.6425 52.4067 63.0267 51.8833 64.4785 51.8036C70.4486 51.4907 76.5144 53.7835 80.7683 58.0561C72.1254 58.1415 63.7076 63.643 60.0895 71.5852Z" fill="#141414"/>
|
||||||
|
<path d="M84.9366 60.1673C80.5757 54.1253 73.7503 50.0119 66.2722 49.8868C80.7277 43.3669 98.5086 53.9375 100.444 69.5659C100.624 71.0167 100.388 72.4959 99.7409 73.8044C97.04 79.2547 92.032 83.4819 86.1801 85.0464C90.4678 77.144 89.9388 67.4894 84.9366 60.1673Z" fill="#141414"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="382" height="146" viewBox="0 0 382 146" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M114.794 86.6648C111.454 83.6785 109.784 79.2644 109.784 73.434C109.784 67.6036 111.487 63.1896 114.896 60.2033C118.301 57.2169 122.959 55.7209 128.865 55.7209C131.319 55.7209 133.486 55.8973 135.372 56.2613C137.258 56.6197 139.063 57.2283 140.786 58.0929V67.5524C138.106 66.2157 135.064 65.5445 131.659 65.5445C128.66 65.5445 126.445 66.1417 125.018 67.3363C123.586 68.5308 122.873 70.5615 122.873 73.434C122.873 76.2099 123.575 78.2178 124.986 79.4578C126.391 80.7035 128.617 81.3235 131.665 81.3235C134.891 81.3235 137.955 80.5329 140.862 78.9573V88.8547C137.636 90.3849 133.615 91.1471 128.801 91.1471C122.797 91.1471 118.133 89.6511 114.794 86.6648Z" fill="#141414"/>
|
||||||
|
<path d="M143.77 73.4278C143.77 67.6429 145.337 63.246 148.471 60.2312C151.605 57.2165 156.328 55.7148 162.645 55.7148C169.006 55.7148 173.761 57.2222 176.922 60.2312C180.078 63.2403 181.656 67.6429 181.656 73.4278C181.656 85.2366 175.318 91.1409 162.645 91.1409C150.06 91.1466 143.77 85.2422 143.77 73.4278ZM167.179 79.4573C168.109 78.2116 168.574 76.2037 168.574 73.4335C168.574 70.7089 168.109 68.7123 167.179 67.4439C166.25 66.1754 164.737 65.544 162.645 65.544C160.603 65.544 159.122 66.1811 158.214 67.4439C157.306 68.7123 156.853 70.7089 156.853 73.4335C156.853 76.2094 157.306 78.2173 158.214 79.4573C159.122 80.7031 160.597 81.3231 162.645 81.3231C164.737 81.3231 166.244 80.6974 167.179 79.4573Z" fill="#141414"/>
|
||||||
|
<path d="M184.638 56.4315H196.629L196.97 59.0139C198.288 58.0583 199.969 57.2677 202.011 56.6476C204.054 56.0276 206.167 55.7148 208.35 55.7148C212.392 55.7148 215.343 56.7671 217.207 58.8717C219.071 60.9764 220.001 64.2243 220.001 68.627V90.4299H207.194V69.9865C207.194 68.4564 206.864 67.3585 206.205 66.6873C205.546 66.0161 204.443 65.6862 202.898 65.6862C201.947 65.6862 200.968 65.9137 199.969 66.3688C198.969 66.8238 198.131 67.4097 197.445 68.1264V90.4299H184.638V56.4315Z" fill="#141414"/>
|
||||||
|
<path d="M220.038 56.4317H233.391L239.524 76.3689L245.658 56.4317H259.011L246.268 90.4301H232.775L220.038 56.4317Z" fill="#141414"/>
|
||||||
|
<path d="M263.043 87.5061C259.195 84.4686 257.396 79.1957 257.396 73.5018C257.396 67.9558 258.828 63.3882 262.097 60.2312C265.366 57.0743 270.349 55.7148 276.639 55.7148C282.426 55.7148 286.976 57.1255 290.3 59.9468C293.618 62.7682 295.282 66.6191 295.282 71.4939V77.4494H270.927C271.532 79.2184 272.299 80.4983 274.185 81.2889C276.071 82.0796 278.703 82.4721 282.07 82.4721C284.08 82.4721 286.133 82.3071 288.219 81.9715C288.954 81.8521 290.165 81.6644 290.802 81.5222V89.7871C287.619 90.6972 283.377 91.1523 278.595 91.1523C272.159 91.1466 266.89 90.5436 263.043 87.5061ZM281.826 70.1344C281.826 68.4507 279.984 64.8273 276.282 64.8273C272.942 64.8273 270.738 68.3938 270.738 70.1344H281.826Z" fill="#141414"/>
|
||||||
|
<path d="M305.338 73.1436L293.346 56.4317H307.245L331.773 90.4301H317.74L312.287 82.825L306.835 90.4301H292.865L305.338 73.1436Z" fill="#141414"/>
|
||||||
|
<path d="M317.431 56.4317H331.265L320.647 71.3177L313.622 61.7786L317.431 56.4317Z" fill="#141414"/>
|
||||||
|
<path d="M82.2808 87.6516C89.652 86.8381 96.6012 82.9352 100.427 76.421C98.6156 92.533 80.8853 102.717 66.413 96.4643C65.0795 95.8897 63.9316 94.9339 63.1438 93.705C59.8915 88.6302 58.8224 82.1729 60.3585 76.3129C64.7475 83.8398 73.6717 88.4538 82.2808 87.6516Z" fill="#F3B01C"/>
|
||||||
|
<path d="M60.0895 71.5852C57.1016 78.4465 56.9722 86.4797 60.6353 93.0906C47.7442 83.453 47.8848 62.8294 60.4778 53.2885C61.6425 52.4067 63.0267 51.8833 64.4785 51.8036C70.4486 51.4907 76.5144 53.7835 80.7683 58.0561C72.1254 58.1415 63.7076 63.643 60.0895 71.5852Z" fill="#8D2676"/>
|
||||||
|
<path d="M84.9366 60.1673C80.5757 54.1253 73.7503 50.0119 66.2722 49.8868C80.7277 43.3669 98.5086 53.9375 100.444 69.5659C100.624 71.0167 100.388 72.4959 99.7409 73.8044C97.04 79.2547 92.032 83.4819 86.1801 85.0464C90.4678 77.144 89.9388 67.4893 84.9366 60.1673Z" fill="#EE342F"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="382" height="146" viewBox="0 0 382 146" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M114.794 86.6648C111.454 83.6785 109.784 79.2644 109.784 73.434C109.784 67.6036 111.487 63.1896 114.896 60.2033C118.301 57.217 122.959 55.721 128.865 55.721C131.319 55.721 133.486 55.8973 135.372 56.2613C137.258 56.6197 139.063 57.2283 140.786 58.0929V67.5524C138.106 66.2157 135.064 65.5445 131.659 65.5445C128.66 65.5445 126.445 66.1417 125.018 67.3363C123.586 68.5308 122.873 70.5615 122.873 73.434C122.873 76.2099 123.575 78.2178 124.986 79.4578C126.391 80.7035 128.617 81.3236 131.665 81.3236C134.891 81.3236 137.955 80.5329 140.862 78.9573V88.8547C137.636 90.3849 133.615 91.1471 128.801 91.1471C122.797 91.1471 118.133 89.6511 114.794 86.6648Z" fill="white"/>
|
||||||
|
<path d="M143.77 73.4279C143.77 67.643 145.337 63.246 148.471 60.2312C151.605 57.2165 156.328 55.7148 162.645 55.7148C169.006 55.7148 173.761 57.2222 176.922 60.2312C180.078 63.2403 181.656 67.643 181.656 73.4279C181.656 85.2366 175.318 91.1409 162.645 91.1409C150.06 91.1466 143.77 85.2423 143.77 73.4279ZM167.179 79.4574C168.109 78.2116 168.574 76.2037 168.574 73.4335C168.574 70.7089 168.109 68.7123 167.179 67.4439C166.25 66.1754 164.737 65.544 162.645 65.544C160.603 65.544 159.122 66.1811 158.214 67.4439C157.306 68.7123 156.853 70.7089 156.853 73.4335C156.853 76.2094 157.306 78.2173 158.214 79.4574C159.122 80.7031 160.597 81.3231 162.645 81.3231C164.737 81.3231 166.244 80.6974 167.179 79.4574Z" fill="white"/>
|
||||||
|
<path d="M184.638 56.4315H196.629L196.97 59.014C198.288 58.0583 199.969 57.2677 202.011 56.6477C204.054 56.0276 206.167 55.7148 208.35 55.7148C212.392 55.7148 215.343 56.7671 217.207 58.8718C219.071 60.9764 220.001 64.2244 220.001 68.627V90.4299H207.194V69.9865C207.194 68.4564 206.864 67.3585 206.205 66.6873C205.546 66.0161 204.443 65.6862 202.898 65.6862C201.947 65.6862 200.968 65.9137 199.969 66.3688C198.969 66.8239 198.131 67.4097 197.445 68.1265V90.4299H184.638V56.4315Z" fill="white"/>
|
||||||
|
<path d="M220.038 56.4317H233.391L239.524 76.3689L245.658 56.4317H259.011L246.268 90.4301H232.775L220.038 56.4317Z" fill="white"/>
|
||||||
|
<path d="M263.043 87.5062C259.195 84.4687 257.396 79.1957 257.396 73.5018C257.396 67.9558 258.828 63.3882 262.097 60.2312C265.366 57.0743 270.349 55.7148 276.639 55.7148C282.426 55.7148 286.976 57.1255 290.3 59.9468C293.618 62.7682 295.282 66.6191 295.282 71.4939V77.4494H270.927C271.532 79.2184 272.299 80.4983 274.185 81.289C276.071 82.0796 278.703 82.4721 282.07 82.4721C284.08 82.4721 286.133 82.3071 288.219 81.9715C288.954 81.8521 290.165 81.6644 290.802 81.5222V89.7871C287.619 90.6972 283.377 91.1523 278.595 91.1523C272.159 91.1466 266.89 90.5437 263.043 87.5062ZM281.826 70.1344C281.826 68.4507 279.984 64.8273 276.282 64.8273C272.942 64.8273 270.738 68.3938 270.738 70.1344H281.826Z" fill="white"/>
|
||||||
|
<path d="M305.338 73.1437L293.346 56.4317H307.245L331.773 90.4301H317.74L312.287 82.825L306.835 90.4301H292.865L305.338 73.1437Z" fill="white"/>
|
||||||
|
<path d="M317.431 56.4317H331.265L320.647 71.3178L313.622 61.7786L317.431 56.4317Z" fill="white"/>
|
||||||
|
<path d="M82.2808 87.6517C89.652 86.8381 96.6012 82.9353 100.427 76.4211C98.6156 92.533 80.8853 102.717 66.413 96.4643C65.0795 95.8897 63.9316 94.9339 63.1438 93.705C59.8915 88.6302 58.8224 82.1729 60.3585 76.313C64.7475 83.8399 73.6717 88.4538 82.2808 87.6517Z" fill="white"/>
|
||||||
|
<path d="M60.0895 71.5852C57.1016 78.4464 56.9722 86.4797 60.6353 93.0906C47.7442 83.453 47.8848 62.8294 60.4778 53.2885C61.6425 52.4067 63.0267 51.8833 64.4785 51.8036C70.4486 51.4907 76.5144 53.7835 80.7683 58.0561C72.1254 58.1415 63.7076 63.643 60.0895 71.5852Z" fill="white"/>
|
||||||
|
<path d="M84.9366 60.1673C80.5757 54.1253 73.7503 50.0119 66.2722 49.8868C80.7277 43.3669 98.5086 53.9375 100.444 69.5659C100.624 71.0167 100.388 72.4959 99.7409 73.8044C97.04 79.2547 92.032 83.4819 86.1801 85.0464C90.4678 77.144 89.9388 67.4893 84.9366 60.1673Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="184" height="188" viewBox="0 0 184 188" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M108.092 130.021C126.258 128.003 143.385 118.323 152.815 102.167C148.349 142.128 104.653 167.385 68.9858 151.878C65.6992 150.453 62.8702 148.082 60.9288 145.034C52.9134 132.448 50.2786 116.433 54.0644 101.899C64.881 120.567 86.8748 132.01 108.092 130.021Z" fill="#141414"/>
|
||||||
|
<path d="M53.4012 90.1735C46.0375 107.19 45.7186 127.114 54.7463 143.51C22.9759 119.608 23.3226 68.4578 54.358 44.7949C57.2286 42.6078 60.64 41.3096 64.2178 41.1121C78.9312 40.336 93.8804 46.0225 104.364 56.6193C83.0637 56.8309 62.318 70.4756 53.4012 90.1735Z" fill="#141414"/>
|
||||||
|
<path d="M114.637 61.8552C103.89 46.8701 87.0686 36.6684 68.6387 36.358C104.264 20.1876 148.085 46.4045 152.856 85.1654C153.3 88.7635 152.717 92.4322 151.122 95.6775C144.466 109.195 132.124 119.679 117.702 123.559C128.269 103.96 126.965 80.0151 114.637 61.8552Z" fill="#141414"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 948 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="184" height="188" viewBox="0 0 184 188" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M108.092 130.021C126.258 128.003 143.385 118.323 152.815 102.167C148.349 142.128 104.653 167.385 68.9858 151.878C65.6992 150.453 62.8702 148.082 60.9288 145.034C52.9134 132.448 50.2786 116.433 54.0644 101.899C64.881 120.567 86.8748 132.01 108.092 130.021Z" fill="#F3B01C"/>
|
||||||
|
<path d="M53.4012 90.1735C46.0375 107.191 45.7186 127.114 54.7463 143.51C22.9759 119.608 23.3226 68.4578 54.358 44.7949C57.2286 42.6078 60.64 41.3097 64.2178 41.1121C78.9312 40.336 93.8804 46.0225 104.364 56.6193C83.0637 56.831 62.318 70.4756 53.4012 90.1735Z" fill="#8D2676"/>
|
||||||
|
<path d="M114.637 61.8552C103.89 46.8701 87.0686 36.6684 68.6387 36.358C104.264 20.1876 148.085 46.4045 152.856 85.1654C153.3 88.7635 152.717 92.4322 151.122 95.6775C144.466 109.195 132.124 119.679 117.702 123.559C128.269 103.96 126.965 80.0151 114.637 61.8552Z" fill="#EE342F"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 948 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="184" height="188" viewBox="0 0 184 188" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M108.092 130.021C126.258 128.003 143.385 118.323 152.815 102.167C148.349 142.127 104.653 167.385 68.9858 151.878C65.6992 150.453 62.8702 148.082 60.9288 145.034C52.9134 132.448 50.2786 116.433 54.0644 101.899C64.881 120.567 86.8748 132.01 108.092 130.021Z" fill="white"/>
|
||||||
|
<path d="M53.4012 90.1735C46.0375 107.19 45.7186 127.114 54.7463 143.51C22.9759 119.608 23.3226 68.4578 54.358 44.7949C57.2286 42.6078 60.64 41.3096 64.2178 41.1121C78.9312 40.336 93.8804 46.0225 104.364 56.6193C83.0637 56.8309 62.318 70.4756 53.4012 90.1735Z" fill="white"/>
|
||||||
|
<path d="M114.637 61.8552C103.89 46.8701 87.0686 36.6684 68.6387 36.358C104.264 20.1876 148.085 46.4045 152.856 85.1654C153.3 88.7635 152.717 92.4322 151.122 95.6775C144.466 109.195 132.124 119.679 117.702 123.559C128.269 103.96 126.965 80.0151 114.637 61.8552Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 942 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg width="322" height="146" viewBox="0 0 322 146" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M55.2938 86.6648C51.9542 83.6785 50.2844 79.2644 50.2844 73.434C50.2844 67.6036 51.9866 63.1896 55.3965 60.2033C58.8009 57.2169 63.4591 55.7209 69.3655 55.7209C71.8188 55.7209 73.9858 55.8973 75.8717 56.2613C77.7577 56.6197 79.5626 57.2283 81.2864 58.0929V67.5524C78.6061 66.2157 75.5637 65.5445 72.1593 65.5445C69.1601 65.5445 66.9446 66.1417 65.5179 67.3363C64.0859 68.5308 63.3726 70.5615 63.3726 73.434C63.3726 76.2099 64.0751 78.2178 65.4855 79.4578C66.8905 80.7035 69.1169 81.3235 72.1647 81.3235C75.3908 81.3235 78.4548 80.5329 81.3621 78.9573V88.8547C78.136 90.3849 74.1155 91.1471 69.3006 91.1471C63.2969 91.1471 58.6334 89.6511 55.2938 86.6648Z" fill="#141414"/>
|
||||||
|
<path d="M84.2698 73.4278C84.2698 67.6429 85.8369 63.246 88.9711 60.2312C92.1054 57.2165 96.8284 55.7148 103.145 55.7148C109.506 55.7148 114.261 57.2222 117.422 60.2312C120.578 63.2403 122.156 67.6429 122.156 73.4278C122.156 85.2366 115.818 91.1409 103.145 91.1409C90.5599 91.1466 84.2698 85.2422 84.2698 73.4278ZM107.679 79.4573C108.609 78.2116 109.074 76.2037 109.074 73.4335C109.074 70.7089 108.609 68.7123 107.679 67.4439C106.75 66.1754 105.237 65.544 103.145 65.544C101.103 65.544 99.6222 66.1811 98.7143 67.4439C97.8065 68.7123 97.3525 70.7089 97.3525 73.4335C97.3525 76.2094 97.8065 78.2173 98.7143 79.4573C99.6222 80.7031 101.097 81.3231 103.145 81.3231C105.237 81.3231 106.744 80.6974 107.679 79.4573Z" fill="#141414"/>
|
||||||
|
<path d="M125.138 56.4315H137.129L137.47 59.0139C138.788 58.0583 140.469 57.2677 142.511 56.6476C144.554 56.0276 146.667 55.7148 148.85 55.7148C152.892 55.7148 155.843 56.7671 157.707 58.8717C159.571 60.9764 160.501 64.2243 160.501 68.627V90.4299H147.694V69.9865C147.694 68.4564 147.364 67.3585 146.705 66.6873C146.046 66.0161 144.943 65.6862 143.398 65.6862C142.447 65.6862 141.468 65.9137 140.469 66.3688C139.469 66.8238 138.631 67.4097 137.945 68.1264V90.4299H125.138V56.4315Z" fill="#141414"/>
|
||||||
|
<path d="M160.538 56.4317H173.891L180.024 76.3689L186.158 56.4317H199.511L186.768 90.4301H173.275L160.538 56.4317Z" fill="#141414"/>
|
||||||
|
<path d="M203.543 87.5061C199.695 84.4686 197.896 79.1957 197.896 73.5018C197.896 67.9558 199.328 63.3882 202.597 60.2312C205.866 57.0743 210.849 55.7148 217.139 55.7148C222.926 55.7148 227.476 57.1255 230.8 59.9468C234.118 62.7682 235.782 66.6191 235.782 71.4939V77.4494H211.427C212.032 79.2184 212.799 80.4983 214.685 81.2889C216.571 82.0796 219.203 82.4721 222.57 82.4721C224.58 82.4721 226.633 82.3071 228.719 81.9715C229.454 81.8521 230.665 81.6644 231.302 81.5222V89.7871C228.119 90.6972 223.877 91.1523 219.095 91.1523C212.659 91.1466 207.39 90.5436 203.543 87.5061ZM222.326 70.1344C222.326 68.4507 220.484 64.8273 216.782 64.8273C213.442 64.8273 211.238 68.3938 211.238 70.1344H222.326Z" fill="#141414"/>
|
||||||
|
<path d="M245.838 73.1436L233.846 56.4317H247.745L272.273 90.4301H258.24L252.787 82.825L247.335 90.4301H233.365L245.838 73.1436Z" fill="#141414"/>
|
||||||
|
<path d="M257.931 56.4317H271.765L261.147 71.3177L254.122 61.7786L257.931 56.4317Z" fill="#141414"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg width="322" height="146" viewBox="0 0 322 146" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M55.2938 86.6648C51.9542 83.6785 50.2844 79.2644 50.2844 73.434C50.2844 67.6036 51.9866 63.1896 55.3965 60.2033C58.8009 57.2169 63.4591 55.7209 69.3655 55.7209C71.8188 55.7209 73.9858 55.8973 75.8717 56.2613C77.7577 56.6197 79.5626 57.2283 81.2864 58.0929V67.5524C78.6061 66.2157 75.5637 65.5445 72.1593 65.5445C69.1601 65.5445 66.9446 66.1417 65.5179 67.3363C64.0859 68.5308 63.3726 70.5615 63.3726 73.434C63.3726 76.2099 64.0751 78.2178 65.4855 79.4578C66.8905 80.7035 69.1169 81.3235 72.1647 81.3235C75.3908 81.3235 78.4548 80.5329 81.3621 78.9573V88.8547C78.136 90.3849 74.1155 91.1471 69.3006 91.1471C63.2969 91.1471 58.6334 89.6511 55.2938 86.6648Z" fill="white"/>
|
||||||
|
<path d="M84.2698 73.4278C84.2698 67.6429 85.8369 63.246 88.9711 60.2312C92.1054 57.2165 96.8284 55.7148 103.145 55.7148C109.506 55.7148 114.261 57.2222 117.422 60.2312C120.578 63.2403 122.156 67.6429 122.156 73.4278C122.156 85.2366 115.818 91.1409 103.145 91.1409C90.5599 91.1466 84.2698 85.2422 84.2698 73.4278ZM107.679 79.4573C108.609 78.2116 109.074 76.2037 109.074 73.4335C109.074 70.7089 108.609 68.7123 107.679 67.4439C106.75 66.1754 105.237 65.544 103.145 65.544C101.103 65.544 99.6222 66.1811 98.7143 67.4439C97.8065 68.7123 97.3525 70.7089 97.3525 73.4335C97.3525 76.2094 97.8065 78.2173 98.7143 79.4573C99.6222 80.7031 101.097 81.3231 103.145 81.3231C105.237 81.3231 106.744 80.6974 107.679 79.4573Z" fill="white"/>
|
||||||
|
<path d="M125.138 56.4315H137.129L137.47 59.0139C138.788 58.0583 140.469 57.2677 142.511 56.6476C144.554 56.0276 146.667 55.7148 148.85 55.7148C152.892 55.7148 155.843 56.7671 157.707 58.8717C159.571 60.9764 160.501 64.2243 160.501 68.627V90.4299H147.694V69.9865C147.694 68.4564 147.364 67.3585 146.705 66.6873C146.046 66.0161 144.943 65.6862 143.398 65.6862C142.447 65.6862 141.468 65.9137 140.469 66.3688C139.469 66.8238 138.631 67.4097 137.945 68.1264V90.4299H125.138V56.4315Z" fill="white"/>
|
||||||
|
<path d="M160.538 56.4317H173.891L180.024 76.3689L186.158 56.4317H199.511L186.768 90.4301H173.275L160.538 56.4317Z" fill="white"/>
|
||||||
|
<path d="M203.543 87.5061C199.695 84.4686 197.896 79.1957 197.896 73.5018C197.896 67.9558 199.328 63.3882 202.597 60.2312C205.866 57.0743 210.849 55.7148 217.139 55.7148C222.926 55.7148 227.476 57.1255 230.8 59.9468C234.118 62.7682 235.782 66.6191 235.782 71.4939V77.4494H211.427C212.032 79.2184 212.799 80.4983 214.685 81.2889C216.571 82.0796 219.203 82.4721 222.57 82.4721C224.58 82.4721 226.633 82.3071 228.719 81.9715C229.454 81.8521 230.665 81.6644 231.302 81.5222V89.7871C228.119 90.6972 223.877 91.1523 219.095 91.1523C212.659 91.1466 207.39 90.5436 203.543 87.5061ZM222.326 70.1344C222.326 68.4507 220.484 64.8273 216.782 64.8273C213.442 64.8273 211.238 68.3938 211.238 70.1344H222.326Z" fill="white"/>
|
||||||
|
<path d="M245.838 73.1436L233.846 56.4317H247.745L272.273 90.4301H258.24L252.787 82.825L247.335 90.4301H233.365L245.838 73.1436Z" fill="white"/>
|
||||||
|
<path d="M257.931 56.4317H271.765L261.147 71.3177L254.122 61.7786L257.931 56.4317Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M414.4 376.5 200 379.6l-1.4-256.7 103.5-15.2 108.8-1.5z" style="fill:#fff"/><path d="M502.6 103.7c-3.3-3.3-7.8-3.3-7.8-3.3s-95.5 5.4-144.9 6.5c-10.8.2-21.6.5-32.3.6V203c-4.5-2.1-9-4.3-13.5-6.4 0-29.6-.1-88.9-.1-88.9-23.6.3-72.7-1.8-72.7-1.8s-115.2-5.8-127.7-6.9c-8-.5-18.3-1.7-31.8 1.2-7.1 1.5-27.3 6-43.8 21.9C-8.7 154.8.7 206.7 1.9 214.5c1.4 9.5 5.6 36 25.8 59 37.3 45.7 117.6 44.6 117.6 44.6s9.9 23.5 24.9 45.2c20.4 27 41.3 48 61.7 50.5 51.3 0 153.9-.1 153.9-.1s9.8.1 23-8.4c11.4-6.9 21.6-19.1 21.6-19.1s10.5-11.2 25.2-36.9c4.5-7.9 8.2-15.6 11.5-22.8 0 0 45-95.4 45-188.2-1-28-7.9-33-9.5-34.6M97.7 269.9c-21.1-6.9-30.1-15.2-30.1-15.2S52 243.8 44.2 222.3c-13.4-36-1.1-58-1.1-58s6.8-18.3 31.4-24.4c11.2-3 25.2-2.5 25.2-2.5s5.8 48.4 12.8 76.7c5.9 23.8 20.2 63.3 20.2 63.3s-21.3-2.6-35-7.5m289.4-4.5c-5.2 12.6-44.8 92.1-44.8 92.1s-5 11.8-16 12.5c-4.7.3-8.4-1-8.4-1s-.2-.1-4.3-1.7l-92-44.8s-8.9-4.6-10.4-12.7c-1.8-6.6 2.2-14.7 2.2-14.7l44.2-91.1s3.9-7.9 9.9-10.6c.5-.2 1.9-.8 3.7-1.2 6.6-1.7 14.7 2.3 14.7 2.3l18.4 8.9c-3.7 7.6-7.5 15.2-11.2 22.9-5.5-.1-10.5 2.9-13.1 7.7-2.8 5.1-2.2 11.5 1.5 16.1-6.6 13.8-13.3 27.5-19.9 41.1-6.7.1-12.5 4.7-14.1 11.2-1.5 6.5 1.6 13.3 7.4 16.3 6.3 3.3 14.3 1.5 18.5-4.4 4.2-5.8 3.5-13.8-1.5-18.8l19.5-40c1.2.1 3 .2 5-.4 3.3-.7 5.8-2.9 5.8-2.9 3.4 1.5 7 3.1 10.8 5 3.9 2 7.6 4 10.9 5.9.7.4 1.5.9 2.3 1.5 1.3 1.1 2.8 2.5 3.8 4.5 1.5 4.5-1.5 12.1-1.5 12.1-1.9 6.2-15 33.1-15 33.1-6.6-.2-12.5 4.1-14.4 10.2-2.1 6.6.9 14.1 7.2 17.3 6.4 3.3 14.2 1.4 18.3-4.3 4.1-5.5 3.7-13.3-.9-18.4l4.6-9.2c4.1-8.5 11-24.8 11-24.8.7-1.4 4.6-8.4 2.2-17.3-2-9.3-10.3-13.6-10.3-13.6-9.9-6.4-23.8-12.4-23.8-12.4s0-3.3-.9-5.8-2.3-4.2-3.2-5.1c3.6-7.6 7.4-15.1 11-22.6l61.8 29.9s10.3 4.6 12.5 13.2c1.5 6-.4 11.4-1.5 14" style="fill:#609926"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,141 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
Textarea,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
|
const AgentsPage = () => {
|
||||||
|
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||||
|
const requests = useQuery(api.agentRequests.listRecent, { limit: 50 }) ?? [];
|
||||||
|
const createRequest = useMutation(api.agentRequests.create);
|
||||||
|
const [spoonId, setSpoonId] = useState('');
|
||||||
|
const [targetBranch, setTargetBranch] = useState('');
|
||||||
|
const [prompt, setPrompt] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!spoonId) {
|
||||||
|
toast.error('Choose a Spoon first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await createRequest({
|
||||||
|
spoonId: spoonId as Id<'spoons'>,
|
||||||
|
prompt,
|
||||||
|
targetBranch: targetBranch || undefined,
|
||||||
|
});
|
||||||
|
setPrompt('');
|
||||||
|
setTargetBranch('');
|
||||||
|
toast.success('Agent request queued.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Could not queue agent request.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className='space-y-6'>
|
||||||
|
<div>
|
||||||
|
<h1 className='text-3xl font-semibold tracking-normal'>Agents</h1>
|
||||||
|
<p className='text-muted-foreground mt-2'>
|
||||||
|
Queue prompt-driven work for future AI merge request automation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-6 xl:grid-cols-[0.9fr_1.1fr]'>
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Request work</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label>Spoon</Label>
|
||||||
|
<Select value={spoonId} onValueChange={setSpoonId}>
|
||||||
|
<SelectTrigger className='w-full'>
|
||||||
|
<SelectValue placeholder='Choose a Spoon' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{spoons.map((spoon) => (
|
||||||
|
<SelectItem key={spoon._id} value={spoon._id}>
|
||||||
|
{spoon.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='targetBranch'>Target branch</Label>
|
||||||
|
<Input
|
||||||
|
id='targetBranch'
|
||||||
|
value={targetBranch}
|
||||||
|
placeholder='feature/my-change'
|
||||||
|
onChange={(event) => setTargetBranch(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='prompt'>Prompt</Label>
|
||||||
|
<Textarea
|
||||||
|
id='prompt'
|
||||||
|
value={prompt}
|
||||||
|
required
|
||||||
|
onChange={(event) => setPrompt(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type='submit' disabled={submitting || !spoons.length}>
|
||||||
|
{submitting ? 'Queueing...' : 'Queue request'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent requests</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{requests.length ? (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{requests.map((request) => (
|
||||||
|
<div key={request._id} className='border-border border p-4'>
|
||||||
|
<p className='font-medium'>{request.prompt}</p>
|
||||||
|
<p className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
{request.status.replaceAll('_', ' ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className='text-muted-foreground'>
|
||||||
|
Agent requests will appear here after you create a Spoon and
|
||||||
|
queue work.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentsPage;
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { MetricCard } from '@/components/dashboard/metric-card';
|
||||||
|
import { SpoonCard } from '@/components/spoons/spoon-card';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
|
import { Bot, GitBranch, GitPullRequest, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||||
|
|
||||||
|
const DashboardPage = () => {
|
||||||
|
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||||
|
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
||||||
|
const agentRequests =
|
||||||
|
useQuery(api.agentRequests.listRecent, { limit: 5 }) ?? [];
|
||||||
|
const activeSpoons = spoons.filter(
|
||||||
|
(spoon) => spoon.status === 'active',
|
||||||
|
).length;
|
||||||
|
const needsReview = syncRuns.filter(
|
||||||
|
(run) => run.status === 'needs_review',
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className='space-y-6'>
|
||||||
|
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
|
||||||
|
<div>
|
||||||
|
<h1 className='text-3xl font-semibold tracking-normal'>Dashboard</h1>
|
||||||
|
<p className='text-muted-foreground mt-2'>
|
||||||
|
Monitor managed forks, upstream activity, and queued agent work.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href='/spoons/new'>Create Spoon</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||||
|
<MetricCard
|
||||||
|
label='Total Spoons'
|
||||||
|
value={spoons.length}
|
||||||
|
note='Managed forks'
|
||||||
|
icon={GitBranch}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label='Active Spoons'
|
||||||
|
value={activeSpoons}
|
||||||
|
note='Ready for checks'
|
||||||
|
icon={GitPullRequest}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label='Needs review'
|
||||||
|
value={needsReview}
|
||||||
|
note='Upstream updates'
|
||||||
|
icon={RefreshCw}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label='Agent requests'
|
||||||
|
value={agentRequests.length}
|
||||||
|
note='Queued and recent'
|
||||||
|
icon={Bot}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid gap-6 xl:grid-cols-2'>
|
||||||
|
<section className='space-y-3'>
|
||||||
|
<h2 className='text-lg font-semibold'>Recent Spoons</h2>
|
||||||
|
{spoons.length ? (
|
||||||
|
spoons
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((spoon) => <SpoonCard key={spoon._id} spoon={spoon} />)
|
||||||
|
) : (
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardContent className='p-6'>
|
||||||
|
<p className='font-medium'>No Spoons yet</p>
|
||||||
|
<p className='text-muted-foreground mt-2 text-sm'>
|
||||||
|
Create a manual Spoon record to start shaping your fork
|
||||||
|
maintenance dashboard.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section className='space-y-3'>
|
||||||
|
<h2 className='text-lg font-semibold'>Recent activity</h2>
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='text-base'>Upstream checks</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{syncRuns.length ? (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{syncRuns.map((run) => (
|
||||||
|
<div
|
||||||
|
key={run._id}
|
||||||
|
className='border-border border p-3 text-sm'
|
||||||
|
>
|
||||||
|
<p className='font-medium'>
|
||||||
|
{run.kind.replaceAll('_', ' ')}
|
||||||
|
</p>
|
||||||
|
<p className='text-muted-foreground'>
|
||||||
|
{run.status.replaceAll('_', ' ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className='text-muted-foreground text-sm'>
|
||||||
|
Scheduled upstream checks will appear here once provider
|
||||||
|
automation is connected.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { AppShell } from '@/components/app-shell/app-shell';
|
||||||
|
|
||||||
|
const Layout = ({ children }: { children: ReactNode }) => (
|
||||||
|
<AppShell>{children}</AppShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NewSpoonForm } from '@/components/spoons/new-spoon-form';
|
||||||
|
|
||||||
|
const NewSpoonPage = () => (
|
||||||
|
<main className='space-y-6'>
|
||||||
|
<div>
|
||||||
|
<h1 className='text-3xl font-semibold tracking-normal'>New Spoon</h1>
|
||||||
|
<p className='text-muted-foreground mt-2 max-w-2xl'>
|
||||||
|
Create a provider-neutral managed fork record. This does not call a Git
|
||||||
|
provider yet; it prepares the dashboard surface for future automation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NewSpoonForm />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default NewSpoonPage;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { SpoonCard } from '@/components/spoons/spoon-card';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import { Button, Card, CardContent } from '@spoon/ui';
|
||||||
|
|
||||||
|
const SpoonsPage = () => {
|
||||||
|
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||||
|
return (
|
||||||
|
<main className='space-y-6'>
|
||||||
|
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
|
||||||
|
<div>
|
||||||
|
<h1 className='text-3xl font-semibold tracking-normal'>My Spoons</h1>
|
||||||
|
<p className='text-muted-foreground mt-2'>
|
||||||
|
Managed forks you want to keep close to their upstream projects.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href='/spoons/new'>New Spoon</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{spoons.length ? (
|
||||||
|
<div className='grid gap-4 xl:grid-cols-2'>
|
||||||
|
{spoons.map((spoon) => (
|
||||||
|
<SpoonCard key={spoon._id} spoon={spoon} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardContent className='p-8'>
|
||||||
|
<p className='text-lg font-medium'>No managed forks yet</p>
|
||||||
|
<p className='text-muted-foreground mt-2 max-w-xl'>
|
||||||
|
Add your first Spoon manually. Provider-backed forking can build
|
||||||
|
on this same record later.
|
||||||
|
</p>
|
||||||
|
<Button className='mt-5' asChild>
|
||||||
|
<Link href='/spoons/new'>Create Spoon</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpoonsPage;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
|
const UpdatesPage = () => {
|
||||||
|
const runs = useQuery(api.syncRuns.listRecent, { limit: 50 }) ?? [];
|
||||||
|
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||||
|
return (
|
||||||
|
<main className='space-y-6'>
|
||||||
|
<div>
|
||||||
|
<h1 className='text-3xl font-semibold tracking-normal'>Updates</h1>
|
||||||
|
<p className='text-muted-foreground mt-2'>
|
||||||
|
Upstream checks, merge attempts, and AI reviews will appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-3 md:flex-row'>
|
||||||
|
<Select defaultValue='all'>
|
||||||
|
<SelectTrigger className='w-full md:w-48'>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='all'>All statuses</SelectItem>
|
||||||
|
<SelectItem value='needs_review'>Needs review</SelectItem>
|
||||||
|
<SelectItem value='conflict'>Conflict</SelectItem>
|
||||||
|
<SelectItem value='clean'>Clean</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select defaultValue='all'>
|
||||||
|
<SelectTrigger className='w-full md:w-64'>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='all'>All Spoons</SelectItem>
|
||||||
|
{spoons.map((spoon) => (
|
||||||
|
<SelectItem key={spoon._id} value={spoon._id}>
|
||||||
|
{spoon.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent sync runs</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{runs.length ? (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{runs.map((run) => (
|
||||||
|
<div key={run._id} className='border-border border p-4'>
|
||||||
|
<p className='font-medium'>{run.kind.replaceAll('_', ' ')}</p>
|
||||||
|
<p className='text-muted-foreground text-sm'>
|
||||||
|
{run.status.replaceAll('_', ' ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className='text-muted-foreground'>
|
||||||
|
Scheduled upstream checks will appear here once provider
|
||||||
|
connections and workers are added.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpdatesPage;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const generateMetadata = (): Metadata => {
|
||||||
|
return {
|
||||||
|
title: 'Forgot Password',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ForgotPasswordLayout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) => {
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
export default ForgotPasswordLayout;
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuthActions } from '@convex-dev/auth/react';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { PASSWORD_MAX, PASSWORD_MIN } from '@spoon/backend/types';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
Input,
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSeparator,
|
||||||
|
InputOTPSlot,
|
||||||
|
SubmitButton,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
|
const forgotPasswordSchema = z.object({
|
||||||
|
email: z.email({ message: 'Invalid email.' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetVerificationSchema = z
|
||||||
|
.object({
|
||||||
|
code: z.string({ message: 'Invalid code.' }),
|
||||||
|
newPassword: z
|
||||||
|
.string()
|
||||||
|
.min(PASSWORD_MIN, {
|
||||||
|
message: `Password must be at least ${PASSWORD_MIN} characters.`,
|
||||||
|
})
|
||||||
|
.max(PASSWORD_MAX, {
|
||||||
|
message: `Password must be no more than ${PASSWORD_MAX} characters.`,
|
||||||
|
})
|
||||||
|
.regex(/^\S+$/, {
|
||||||
|
message: 'Password must not contain whitespace.',
|
||||||
|
})
|
||||||
|
.regex(/[0-9]/, {
|
||||||
|
message: 'Password must contain at least one digit.',
|
||||||
|
})
|
||||||
|
.regex(/[a-z]/, {
|
||||||
|
message: 'Password must contain at least one lowercase letter.',
|
||||||
|
})
|
||||||
|
.regex(/[A-Z]/, {
|
||||||
|
message: 'Password must contain at least one uppercase letter.',
|
||||||
|
})
|
||||||
|
.regex(/[\p{P}\p{S}]/u, {
|
||||||
|
message: 'Password must contain at least one symbol.',
|
||||||
|
}),
|
||||||
|
confirmPassword: z.string().min(PASSWORD_MIN, {
|
||||||
|
message: `Password must be at least ${PASSWORD_MIN} characters.`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||||
|
message: 'Passwords do not match!',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ForgotPassword = () => {
|
||||||
|
const { signIn } = useAuthActions();
|
||||||
|
const [flow, setFlow] = useState<'reset' | 'reset-verification'>('reset');
|
||||||
|
const [email, setEmail] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [code, setCode] = useState<string>('');
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const forgotPasswordForm = useForm<z.infer<typeof forgotPasswordSchema>>({
|
||||||
|
resolver: zodResolver(forgotPasswordSchema),
|
||||||
|
defaultValues: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetVerificationForm = useForm<
|
||||||
|
z.infer<typeof resetVerificationSchema>
|
||||||
|
>({
|
||||||
|
resolver: zodResolver(resetVerificationSchema),
|
||||||
|
defaultValues: { code, newPassword: '', confirmPassword: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleForgotPasswordSubmit = async (
|
||||||
|
values: z.infer<typeof forgotPasswordSchema>,
|
||||||
|
) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('email', values.email);
|
||||||
|
formData.append('flow', flow);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await signIn('password', formData).then(() => {
|
||||||
|
setEmail(values.email);
|
||||||
|
setFlow('reset-verification');
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting password: ', error);
|
||||||
|
toast.error('Error resetting password.');
|
||||||
|
} finally {
|
||||||
|
forgotPasswordForm.reset();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetVerificationSubmit = async (
|
||||||
|
values: z.infer<typeof resetVerificationSchema>,
|
||||||
|
) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('code', code);
|
||||||
|
formData.append('newPassword', values.newPassword);
|
||||||
|
formData.append('email', email);
|
||||||
|
formData.append('flow', flow);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await signIn('password', formData);
|
||||||
|
router.push('/');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting password: ', error);
|
||||||
|
toast.error('Error resetting password.');
|
||||||
|
} finally {
|
||||||
|
resetVerificationForm.reset();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col items-center'>
|
||||||
|
<Card className='bg-card/25 min-h-[400px] w-sm p-4 lg:w-md'>
|
||||||
|
<CardHeader className='flex flex-col items-center gap-4'>
|
||||||
|
{flow === 'reset' ? (
|
||||||
|
<>
|
||||||
|
<CardTitle className='text-2xl font-bold'>
|
||||||
|
Forgot Password
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your email address and we will send you a link to reset
|
||||||
|
your password.
|
||||||
|
</CardDescription>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CardTitle className='text-2xl font-bold'>
|
||||||
|
Reset Password
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your code and new password and we will reset your
|
||||||
|
password.
|
||||||
|
</CardDescription>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Card className='bg-card/50'>
|
||||||
|
<CardContent>
|
||||||
|
{flow === 'reset' ? (
|
||||||
|
<Form {...forgotPasswordForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={forgotPasswordForm.handleSubmit(
|
||||||
|
handleForgotPasswordSubmit,
|
||||||
|
)}
|
||||||
|
className='flex flex-col space-y-4'
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={forgotPasswordForm.control}
|
||||||
|
name='email'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className='text-xl'>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type='email'
|
||||||
|
placeholder='you@example.com'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className='flex w-full flex-col items-center'>
|
||||||
|
<FormMessage className='w-5/6 text-center' />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SubmitButton
|
||||||
|
disabled={loading}
|
||||||
|
pendingText='Sending Email...'
|
||||||
|
className='mx-auto w-2/3 text-xl font-semibold'
|
||||||
|
>
|
||||||
|
Send Email
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
|
<Form {...resetVerificationForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={resetVerificationForm.handleSubmit(
|
||||||
|
handleResetVerificationSubmit,
|
||||||
|
)}
|
||||||
|
className='flex flex-col space-y-4'
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={resetVerificationForm.control}
|
||||||
|
name='code'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className='text-xl'>Code</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<InputOTP
|
||||||
|
maxLength={6}
|
||||||
|
{...field}
|
||||||
|
value={code}
|
||||||
|
onChange={(value) => setCode(value)}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSeparator />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Please enter the one-time password sent to your
|
||||||
|
phone.
|
||||||
|
</FormDescription>
|
||||||
|
<div className='flex w-full flex-col items-center'>
|
||||||
|
<FormMessage className='w-5/6 text-center' />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={resetVerificationForm.control}
|
||||||
|
name='newPassword'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className='text-xl'>
|
||||||
|
New Password
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type='password'
|
||||||
|
placeholder='Your password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className='flex w-full flex-col items-center'>
|
||||||
|
<FormMessage className='w-5/6 text-center' />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={resetVerificationForm.control}
|
||||||
|
name='confirmPassword'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className='text-xl'>
|
||||||
|
Confirm Passsword
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type='password'
|
||||||
|
placeholder='Confirm your password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className='flex w-full flex-col items-center'>
|
||||||
|
<FormMessage className='w-5/6 text-center' />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SubmitButton
|
||||||
|
disabled={loading}
|
||||||
|
pendingText='Resetting Password...'
|
||||||
|
className='mx-auto w-2/3 text-xl font-semibold'
|
||||||
|
>
|
||||||
|
Reset Password
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default ForgotPassword;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { isAuthenticatedNextjs } from '@convex-dev/auth/nextjs/server';
|
||||||
|
|
||||||
|
export const generateMetadata = (): Metadata => {
|
||||||
|
return {
|
||||||
|
title: 'Profile',
|
||||||
|
robots: {
|
||||||
|
index: false,
|
||||||
|
follow: false,
|
||||||
|
googleBot: {
|
||||||
|
index: false,
|
||||||
|
follow: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProfileLayout = async ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) => {
|
||||||
|
if (!(await isAuthenticatedNextjs())) redirect('/sign-in');
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
export default ProfileLayout;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AvatarUpload,
|
||||||
|
ProfileHeader,
|
||||||
|
ResetPasswordForm,
|
||||||
|
SignOutForm,
|
||||||
|
UserInfoForm,
|
||||||
|
} from '@/components/layout/auth/profile';
|
||||||
|
import { preloadQuery } from 'convex/nextjs';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import { Card, Separator } from '@spoon/ui';
|
||||||
|
|
||||||
|
const Profile = async () => {
|
||||||
|
const preloadedUser = await preloadQuery(api.auth.getUser, {});
|
||||||
|
const preloadedUserProvider = await preloadQuery(
|
||||||
|
api.auth.getUserProvider,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<main className='container mx-auto px-4 py-12 md:py-16'>
|
||||||
|
<div className='mx-auto max-w-3xl'>
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className='mb-8 text-center'>
|
||||||
|
<h1 className='mb-2 text-3xl font-bold tracking-tight sm:text-4xl'>
|
||||||
|
Your Profile
|
||||||
|
</h1>
|
||||||
|
<p className='text-muted-foreground'>
|
||||||
|
Manage your personal information and preferences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Card */}
|
||||||
|
<Card className='border-border/40'>
|
||||||
|
<ProfileHeader />
|
||||||
|
<AvatarUpload preloadedUser={preloadedUser} />
|
||||||
|
<Separator className='my-6' />
|
||||||
|
<UserInfoForm
|
||||||
|
preloadedUser={preloadedUser}
|
||||||
|
preloadedProvider={preloadedUserProvider}
|
||||||
|
/>
|
||||||
|
<ResetPasswordForm preloadedProvider={preloadedUserProvider} />
|
||||||
|
<Separator className='my-6' />
|
||||||
|
<SignOutForm />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Profile;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const generateMetadata = (): Metadata => {
|
||||||
|
return {
|
||||||
|
title: 'Sign In',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const SignInLayout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) => {
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
export default SignInLayout;
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { AuthentikSignInButton } from '@/components/layout/auth/buttons';
|
||||||
|
import { useAuthActions } from '@convex-dev/auth/react';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { ConvexError } from 'convex/values';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PASSWORD_MAX,
|
||||||
|
PASSWORD_MIN,
|
||||||
|
PASSWORD_REGEX,
|
||||||
|
} from '@spoon/backend/types';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
Input,
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSeparator,
|
||||||
|
InputOTPSlot,
|
||||||
|
Separator,
|
||||||
|
SubmitButton,
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
|
const signInFormSchema = z.object({
|
||||||
|
email: z.email({
|
||||||
|
message: 'Please enter a valid email address.',
|
||||||
|
}),
|
||||||
|
password: z.string().regex(PASSWORD_REGEX, {
|
||||||
|
message: 'Incorrect password. Does not meet requirements.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const signUpFormSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(2, {
|
||||||
|
message: 'Name must be at least 2 characters.',
|
||||||
|
}),
|
||||||
|
email: z.email({
|
||||||
|
message: 'Please enter a valid email address.',
|
||||||
|
}),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(PASSWORD_MIN, {
|
||||||
|
message: `Password must be at least ${PASSWORD_MIN} characters.`,
|
||||||
|
})
|
||||||
|
.max(PASSWORD_MAX, {
|
||||||
|
message: `Password must be no more than ${PASSWORD_MAX} characters.`,
|
||||||
|
})
|
||||||
|
.regex(/^\S+$/, {
|
||||||
|
message: 'Password must not contain whitespace.',
|
||||||
|
})
|
||||||
|
.regex(/[0-9]/, {
|
||||||
|
message: 'Password must contain at least one digit.',
|
||||||
|
})
|
||||||
|
.regex(/[a-z]/, {
|
||||||
|
message: 'Password must contain at least one lowercase letter.',
|
||||||
|
})
|
||||||
|
.regex(/[A-Z]/, {
|
||||||
|
message: 'Password must contain at least one uppercase letter.',
|
||||||
|
})
|
||||||
|
.regex(/[\p{P}\p{S}]/u, {
|
||||||
|
message: 'Password must contain at least one symbol.',
|
||||||
|
}),
|
||||||
|
confirmPassword: z.string().min(PASSWORD_MIN, {
|
||||||
|
message: `Password must be at least ${PASSWORD_MIN} characters.`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: 'Passwords do not match!',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifyEmailFormSchema = z.object({
|
||||||
|
code: z.string({ message: 'Invalid code.' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const SignIn = () => {
|
||||||
|
const { signIn } = useAuthActions();
|
||||||
|
const [flow, setFlow] = useState<'signIn' | 'signUp' | 'email-verification'>(
|
||||||
|
'signIn',
|
||||||
|
);
|
||||||
|
const [email, setEmail] = useState<string>('');
|
||||||
|
const [code, setCode] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const signInForm = useForm<z.infer<typeof signInFormSchema>>({
|
||||||
|
resolver: zodResolver(signInFormSchema),
|
||||||
|
defaultValues: { email: '', password: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const signUpForm = useForm<z.infer<typeof signUpFormSchema>>({
|
||||||
|
resolver: zodResolver(signUpFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
email,
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifyEmailForm = useForm<z.infer<typeof verifyEmailFormSchema>>({
|
||||||
|
resolver: zodResolver(verifyEmailFormSchema),
|
||||||
|
defaultValues: { code },
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('email', values.email);
|
||||||
|
formData.append('password', values.password);
|
||||||
|
formData.append('flow', flow);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await signIn('password', formData).then(() => router.push('/dashboard'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error signing in:', error);
|
||||||
|
toast.error('Error signing in.');
|
||||||
|
} finally {
|
||||||
|
signInForm.reset();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignUp = async (values: z.infer<typeof signUpFormSchema>) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('email', values.email);
|
||||||
|
formData.append('password', values.password);
|
||||||
|
formData.append('flow', flow);
|
||||||
|
formData.append('name', values.name);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (values.confirmPassword !== values.password)
|
||||||
|
throw new ConvexError('Passwords do not match.');
|
||||||
|
await signIn('password', formData).then(() => {
|
||||||
|
setEmail(values.email);
|
||||||
|
setFlow('email-verification');
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error signing up:', error);
|
||||||
|
toast.error('Error signing up.');
|
||||||
|
} finally {
|
||||||
|
signUpForm.reset();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyEmail = async (
|
||||||
|
_values: z.infer<typeof verifyEmailFormSchema>,
|
||||||
|
) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('code', code);
|
||||||
|
formData.append('flow', flow);
|
||||||
|
formData.append('email', email);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await signIn('password', formData).then(() => router.push('/dashboard'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying email:', error);
|
||||||
|
toast.error('Error verifying email.');
|
||||||
|
} finally {
|
||||||
|
verifyEmailForm.reset();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (flow === 'email-verification') {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col items-center'>
|
||||||
|
<Card className='bg-card/25 min-h-[720px] w-md p-4'>
|
||||||
|
<CardContent>
|
||||||
|
<div className='mb-6 text-center'>
|
||||||
|
<h2 className='text-2xl font-bold'>Verify Your Email</h2>
|
||||||
|
<p className='text-muted-foreground'>We sent a code to {email}</p>
|
||||||
|
</div>
|
||||||
|
<Form {...verifyEmailForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
|
||||||
|
className='flex flex-col space-y-8'
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={verifyEmailForm.control}
|
||||||
|
name='code'
|
||||||
|
render={({ field: _field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className='text-xl'>Code</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<InputOTP
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={(value) => setCode(value)}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSeparator />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Please enter the one-time password sent to your email.
|
||||||
|
</FormDescription>
|
||||||
|
<div className='flex w-full flex-col items-center'>
|
||||||
|
<FormMessage className='w-5/6 text-center' />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SubmitButton
|
||||||
|
disabled={loading}
|
||||||
|
pendingText='Signing Up...'
|
||||||
|
className='mx-auto w-2/3 text-xl font-semibold'
|
||||||
|
>
|
||||||
|
Verify Email
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<div className='mt-4 text-center'>
|
||||||
|
<button
|
||||||
|
onClick={() => setFlow('signUp')}
|
||||||
|
className='text-muted-foreground text-sm hover:underline'
|
||||||
|
>
|
||||||
|
Back to Sign Up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col items-center'>
|
||||||
|
<Card className='bg-card/25 min-h-[720px] w-md p-4'>
|
||||||
|
<Tabs
|
||||||
|
defaultValue={flow}
|
||||||
|
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
|
||||||
|
className='flex-col items-center'
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger
|
||||||
|
value='signIn'
|
||||||
|
className='cursor-pointer px-6 py-2 text-2xl font-bold'
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value='signUp'
|
||||||
|
className='cursor-pointer px-6 py-2 text-2xl font-bold'
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent
|
||||||
|
value='signIn'
|
||||||
|
className='flex min-h-[560px] flex-row items-center'
|
||||||
|
>
|
||||||
|
<Card className='bg-card/50 min-w-xs py-10 sm:min-w-sm'>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...signInForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={signInForm.handleSubmit(handleSignIn)}
|
||||||
|
className='flex flex-col space-y-8'
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={signInForm.control}
|
||||||
|
name='email'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className='text-xl'>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type='email'
|
||||||
|
placeholder='you@example.com'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className='flex w-full flex-col items-center'>
|
||||||
|
<FormMessage className='w-5/6 text-center' />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={signInForm.control}
|
||||||
|
name='password'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className='flex justify-between'>
|
||||||
|
<FormLabel className='text-xl'>Password</FormLabel>
|
||||||
|
<Link href='/forgot-password'>
|
||||||
|
Forgot Password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type='password'
|
||||||
|
placeholder='Your password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className='flex w-full flex-col items-center'>
|
||||||
|
<FormMessage className='w-5/6 text-center' />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SubmitButton
|
||||||
|
disabled={loading}
|
||||||
|
pendingText='Signing in...'
|
||||||
|
className='mx-auto w-2/3 text-xl font-semibold'
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<div className='flex justify-center'>
|
||||||
|
<div className='mx-auto my-2.5 flex w-1/4 flex-row items-center justify-center'>
|
||||||
|
<Separator className='mr-3 py-0.5' />
|
||||||
|
<span className='text-lg font-semibold'>or</span>
|
||||||
|
<Separator className='ml-3 py-0.5' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='mt-3 flex justify-center'>
|
||||||
|
<AuthentikSignInButton />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value='signUp'>
|
||||||
|
<Card className='bg-card/50 min-w-xs sm:min-w-sm'>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...signUpForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={signUpForm.handleSubmit(handleSignUp)}
|
||||||
|
className='flex flex-col space-y-8'
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={signUpForm.control}
|
||||||
|
name='name'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className='text-xl'>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
placeholder='Full Name'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className='flex w-full flex-col items-center'>
|
||||||
|
<FormMessage className='w-5/6 text-center' />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={signUpForm.control}
|
||||||
|
name='email'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className='text-xl'>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type='email'
|
||||||
|
placeholder='you@example.com'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className='flex w-full flex-col items-center'>
|
||||||
|
<FormMessage className='w-5/6 text-center' />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={signUpForm.control}
|
||||||
|
name='password'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className='text-xl'>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type='password'
|
||||||
|
placeholder='Your password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className='flex w-full flex-col items-center'>
|
||||||
|
<FormMessage className='w-5/6 text-center' />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={signUpForm.control}
|
||||||
|
name='confirmPassword'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className='text-xl'>
|
||||||
|
Confirm Passsword
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type='password'
|
||||||
|
placeholder='Confirm your password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className='flex w-full flex-col items-center'>
|
||||||
|
<FormMessage className='w-5/6 text-center' />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SubmitButton
|
||||||
|
disabled={loading}
|
||||||
|
pendingText='Signing Up...'
|
||||||
|
className='mx-auto w-2/3 text-xl font-semibold'
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<div className='my-auto flex w-2/3 justify-center'>
|
||||||
|
<div className='my-2.5 flex w-1/3 flex-row items-center'>
|
||||||
|
<Separator className='mr-3 py-0.5' />
|
||||||
|
<span className='text-lg font-semibold'>or</span>
|
||||||
|
<Separator className='ml-3 py-0.5' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='mt-3 flex justify-center'>
|
||||||
|
<AuthentikSignInButton type='signUp' />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default SignIn;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
import NextError from 'next/error';
|
||||||
|
import { Geist, Geist_Mono } from 'next/font/google';
|
||||||
|
|
||||||
|
import '@/app/styles.css';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import Footer from '@/components/layout/footer';
|
||||||
|
import Header from '@/components/layout/header';
|
||||||
|
import { ConvexClientProvider } from '@/components/providers';
|
||||||
|
import { env } from '@/env';
|
||||||
|
import { generateMetadata } from '@/lib/metadata';
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import PlausibleProvider from 'next-plausible';
|
||||||
|
|
||||||
|
import { Button, ThemeProvider, Toaster } from '@spoon/ui';
|
||||||
|
|
||||||
|
export const metadata: Metadata = generateMetadata();
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: [
|
||||||
|
{ media: '(prefers-color-scheme: light)', color: 'white' },
|
||||||
|
{ media: '(prefers-color-scheme: dark)', color: 'black' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-geist-sans',
|
||||||
|
});
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-geist-mono',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface GlobalErrorProps {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
||||||
|
useEffect(() => {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
}, [error]);
|
||||||
|
return (
|
||||||
|
<PlausibleProvider
|
||||||
|
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
|
||||||
|
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
|
||||||
|
>
|
||||||
|
<html lang='en' suppressHydrationWarning>
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute='class'
|
||||||
|
defaultTheme='system'
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<ConvexClientProvider>
|
||||||
|
<main className='flex min-h-screen flex-col items-center'>
|
||||||
|
<Header />
|
||||||
|
<NextError statusCode={0} />
|
||||||
|
{reset !== undefined && (
|
||||||
|
<Button onClick={() => reset()}>Try Again</Button>
|
||||||
|
)}
|
||||||
|
<Toaster />
|
||||||
|
<Footer />
|
||||||
|
</main>
|
||||||
|
<main className='flex min-h-[90vh] flex-col items-center'>
|
||||||
|
<Toaster />
|
||||||
|
</main>
|
||||||
|
</ConvexClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
</PlausibleProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default GlobalError;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
import { Geist, Geist_Mono } from 'next/font/google';
|
||||||
|
import { env } from '@/env';
|
||||||
|
|
||||||
|
import '@/app/styles.css';
|
||||||
|
|
||||||
|
import Footer from '@/components/layout/footer';
|
||||||
|
import Header from '@/components/layout/header';
|
||||||
|
import { ConvexClientProvider } from '@/components/providers';
|
||||||
|
import { generateMetadata } from '@/lib/metadata';
|
||||||
|
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
|
||||||
|
import PlausibleProvider from 'next-plausible';
|
||||||
|
|
||||||
|
import { ThemeProvider, Toaster } from '@spoon/ui';
|
||||||
|
|
||||||
|
export const metadata: Metadata = generateMetadata();
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: [
|
||||||
|
{ media: '(prefers-color-scheme: light)', color: 'white' },
|
||||||
|
{ media: '(prefers-color-scheme: dark)', color: 'black' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-geist-sans',
|
||||||
|
});
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-geist-mono',
|
||||||
|
});
|
||||||
|
|
||||||
|
const RootLayout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return (
|
||||||
|
<ConvexAuthNextjsServerProvider>
|
||||||
|
<PlausibleProvider
|
||||||
|
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
|
||||||
|
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
|
||||||
|
>
|
||||||
|
<html lang='en' suppressHydrationWarning>
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute='class'
|
||||||
|
defaultTheme='system'
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<ConvexClientProvider>
|
||||||
|
<div className='flex min-h-screen flex-col'>
|
||||||
|
<Header />
|
||||||
|
{children}
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
<Toaster />
|
||||||
|
</ConvexClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
</PlausibleProvider>
|
||||||
|
</ConvexAuthNextjsServerProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default RootLayout;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Agents, CTA, Features, Hero, Workflow } from '@/components/landing';
|
||||||
|
|
||||||
|
const Home = () => (
|
||||||
|
<main className='flex min-h-screen flex-col'>
|
||||||
|
<Hero />
|
||||||
|
<Workflow />
|
||||||
|
<Features />
|
||||||
|
<Agents />
|
||||||
|
<CTA />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Home;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@import 'tw-animate-css';
|
||||||
|
@import '@spoon/tailwind-config/theme';
|
||||||
|
|
||||||
|
@source '../../../../packages/ui/src/**/*.{ts,tsx}';
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
@custom-variant light (&:where(.light, .light *));
|
||||||
|
@custom-variant auto (&:where(.auto, .auto *));
|
||||||
|
|
||||||
|
@utility container {
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-inline: 2rem;
|
||||||
|
@media (width >= --theme(--breakpoint-sm)) {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
@media (width >= 1400px) {
|
||||||
|
max-width: 1400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply bg-background;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
letter-spacing: var(--tracking-normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { Bot, GitBranch, LayoutDashboard, RefreshCw, User } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@spoon/ui';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ href: '/spoons', label: 'My Spoons', icon: GitBranch },
|
||||||
|
{ href: '/updates', label: 'Updates', icon: RefreshCw },
|
||||||
|
{ href: '/agents', label: 'Agents', icon: Bot },
|
||||||
|
{ href: '/profile', label: 'Profile', icon: User },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AppShell = ({ children }: { children: ReactNode }) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
return (
|
||||||
|
<div className='bg-muted/20 flex-1 border-t'>
|
||||||
|
<div className='container mx-auto grid gap-6 px-4 py-6 lg:grid-cols-[14rem_1fr]'>
|
||||||
|
<aside className='lg:sticky lg:top-20 lg:self-start'>
|
||||||
|
<nav className='border-border bg-card flex gap-1 overflow-x-auto border p-2 lg:flex-col'>
|
||||||
|
{navItems.map(({ href, label, icon: Icon }) => {
|
||||||
|
const active =
|
||||||
|
pathname === href ||
|
||||||
|
(href !== '/dashboard' && pathname.startsWith(href));
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
'hover:bg-muted flex min-w-fit items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className='size-4' />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<div className='min-w-0'>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Utensils } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@spoon/ui';
|
||||||
|
|
||||||
|
export const LogoMark = ({ className }: { className?: string }) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'bg-primary text-primary-foreground inline-flex size-9 items-center justify-center rounded-md',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Utensils className='size-5' />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SpoonLogo = ({ className }: { className?: string }) => (
|
||||||
|
<Link href='/' className={cn('flex items-center gap-2', className)}>
|
||||||
|
<LogoMark />
|
||||||
|
<span className='text-xl font-semibold tracking-normal'>Spoon</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent } from '@spoon/ui';
|
||||||
|
|
||||||
|
export const MetricCard = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
note,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
note: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}) => (
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardContent className='p-5'>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<p className='text-muted-foreground text-sm'>{label}</p>
|
||||||
|
<Icon className='text-primary size-4' />
|
||||||
|
</div>
|
||||||
|
<p className='mt-3 text-3xl font-semibold'>{value}</p>
|
||||||
|
<p className='text-muted-foreground mt-1 text-xs'>{note}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@spoon/ui';
|
||||||
|
|
||||||
|
export const CTA = () => (
|
||||||
|
<section className='container mx-auto px-4 py-20'>
|
||||||
|
<div className='border-border bg-card flex flex-col items-start justify-between gap-6 border p-8 md:flex-row md:items-center'>
|
||||||
|
<div>
|
||||||
|
<h2 className='text-2xl font-semibold tracking-normal'>
|
||||||
|
Start your first Spoon
|
||||||
|
</h2>
|
||||||
|
<p className='text-muted-foreground mt-2 max-w-2xl'>
|
||||||
|
Create a manual managed fork record today. Provider connections,
|
||||||
|
scheduled checks, and AI merge request automation can build on the
|
||||||
|
same foundation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href='/spoons/new'>
|
||||||
|
New Spoon
|
||||||
|
<ArrowRight className='size-4' />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
GitMerge,
|
||||||
|
History,
|
||||||
|
SearchCheck,
|
||||||
|
ShieldCheck,
|
||||||
|
TriangleAlert,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||||
|
|
||||||
|
const maintenance = [
|
||||||
|
{
|
||||||
|
title: 'Upstream security fixes',
|
||||||
|
description:
|
||||||
|
'Track the changes that land upstream so important fixes do not disappear into fork drift.',
|
||||||
|
icon: ShieldCheck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Conflict detection',
|
||||||
|
description:
|
||||||
|
'Make update risk visible before a merge request reaches the fork you actually maintain.',
|
||||||
|
icon: TriangleAlert,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'AI-reviewed changes',
|
||||||
|
description:
|
||||||
|
'Prepare for agent-assisted analysis that explains whether upstream changes affect your custom work.',
|
||||||
|
icon: SearchCheck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Merge request history',
|
||||||
|
description:
|
||||||
|
'Keep a durable timeline of upstream checks, review outcomes, and merge request decisions.',
|
||||||
|
icon: History,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Workflow = () => {
|
||||||
|
const steps = [
|
||||||
|
'Choose upstream',
|
||||||
|
'Create a Spoon',
|
||||||
|
'Customize your fork',
|
||||||
|
'Track upstream',
|
||||||
|
'Review and merge updates',
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<section id='workflow' className='border-border/60 bg-muted/30 border-y'>
|
||||||
|
<div className='container mx-auto px-4 py-16'>
|
||||||
|
<div className='mb-10 max-w-2xl'>
|
||||||
|
<h2 className='text-3xl font-semibold tracking-normal'>
|
||||||
|
A fork workflow that keeps moving
|
||||||
|
</h2>
|
||||||
|
<p className='text-muted-foreground mt-3'>
|
||||||
|
Spoon starts with a provider-neutral model: upstream project,
|
||||||
|
managed fork, update checks, and reviewable merge requests.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-3 md:grid-cols-5'>
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div key={step} className='border-border bg-card border p-4'>
|
||||||
|
<p className='text-primary text-sm font-semibold'>
|
||||||
|
{String(index + 1).padStart(2, '0')}
|
||||||
|
</p>
|
||||||
|
<p className='mt-4 text-sm font-medium'>{step}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Features = () => (
|
||||||
|
<section id='maintenance' className='container mx-auto px-4 py-20'>
|
||||||
|
<div className='mb-10 max-w-2xl'>
|
||||||
|
<h2 className='text-3xl font-semibold tracking-normal'>
|
||||||
|
Maintenance is the product
|
||||||
|
</h2>
|
||||||
|
<p className='text-muted-foreground mt-3'>
|
||||||
|
The first version establishes the dashboard surfaces and records that
|
||||||
|
future Git provider integrations and AI review jobs will use.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4'>
|
||||||
|
{maintenance.map(({ title, description, icon: Icon }) => (
|
||||||
|
<Card key={title} className='border-border/70 shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<Icon className='text-primary size-5' />
|
||||||
|
<CardTitle className='text-base'>{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className='text-muted-foreground text-sm leading-6'>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Agents = () => (
|
||||||
|
<section id='agents' className='border-border/60 bg-muted/30 border-y'>
|
||||||
|
<div className='container mx-auto grid gap-8 px-4 py-20 lg:grid-cols-2'>
|
||||||
|
<div>
|
||||||
|
<div className='bg-primary/10 text-primary mb-4 flex size-10 items-center justify-center rounded-md'>
|
||||||
|
<Bot className='size-5' />
|
||||||
|
</div>
|
||||||
|
<h2 className='text-3xl font-semibold tracking-normal'>
|
||||||
|
Agent requests belong next to fork maintenance
|
||||||
|
</h2>
|
||||||
|
<p className='text-muted-foreground mt-4 leading-7'>
|
||||||
|
Spoon is being shaped so a user can ask an agent to implement a
|
||||||
|
change, open a merge request against the managed fork, and still keep
|
||||||
|
upstream updates in view. This pass stores those requests without
|
||||||
|
running automation yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='border-border bg-card border p-5'>
|
||||||
|
<div className='mb-4 flex items-center gap-3'>
|
||||||
|
<GitMerge className='text-primary size-5' />
|
||||||
|
<p className='font-medium'>Queued agent request</p>
|
||||||
|
</div>
|
||||||
|
<p className='text-muted-foreground text-sm leading-6'>
|
||||||
|
“Add a project-specific onboarding flow, open a merge request, and
|
||||||
|
flag any upstream files this may affect.”
|
||||||
|
</p>
|
||||||
|
<div className='mt-5 grid gap-2 text-sm'>
|
||||||
|
<div className='flex justify-between'>
|
||||||
|
<span className='text-muted-foreground'>Target</span>
|
||||||
|
<span>feature/onboarding</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex justify-between'>
|
||||||
|
<span className='text-muted-foreground'>Status</span>
|
||||||
|
<span>Queued</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useConvexAuth } from 'convex/react';
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Bot,
|
||||||
|
CheckCircle2,
|
||||||
|
GitBranch,
|
||||||
|
GitPullRequest,
|
||||||
|
ShieldCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge, Button } from '@spoon/ui';
|
||||||
|
|
||||||
|
const previewRows = [
|
||||||
|
{
|
||||||
|
name: 'editor-spoon',
|
||||||
|
upstream: 'upstream/main',
|
||||||
|
status: 'Clean update',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
tone: 'text-emerald-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'billing-fork',
|
||||||
|
upstream: 'release/2026.06',
|
||||||
|
status: 'AI review queued',
|
||||||
|
icon: Bot,
|
||||||
|
tone: 'text-teal-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs-platform',
|
||||||
|
upstream: 'main',
|
||||||
|
status: 'Needs review',
|
||||||
|
icon: GitPullRequest,
|
||||||
|
tone: 'text-amber-600',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Hero = () => {
|
||||||
|
const { isAuthenticated } = useConvexAuth();
|
||||||
|
return (
|
||||||
|
<section className='container mx-auto px-4 py-16 md:py-24'>
|
||||||
|
<div className='grid items-center gap-10 lg:grid-cols-[0.92fr_1.08fr]'>
|
||||||
|
<div className='max-w-3xl'>
|
||||||
|
<Badge variant='outline' className='mb-5 gap-2'>
|
||||||
|
<ShieldCheck className='size-3.5 text-emerald-600' />
|
||||||
|
Self-hostable fork maintenance
|
||||||
|
</Badge>
|
||||||
|
<h1 className='max-w-4xl text-4xl font-semibold tracking-normal text-balance sm:text-5xl md:text-6xl'>
|
||||||
|
Fork freely. Stay close to upstream.
|
||||||
|
</h1>
|
||||||
|
<p className='text-muted-foreground mt-6 max-w-2xl text-lg leading-8'>
|
||||||
|
Spoon helps you customize upstream projects without inheriting the
|
||||||
|
full maintenance burden. Track drift, review update risk, and keep
|
||||||
|
managed forks ready for merge requests.
|
||||||
|
</p>
|
||||||
|
<div className='mt-8 flex flex-col gap-3 sm:flex-row'>
|
||||||
|
<Button size='lg' asChild>
|
||||||
|
<Link href={isAuthenticated ? '/dashboard' : '/sign-in'}>
|
||||||
|
{isAuthenticated ? 'Open dashboard' : 'Start with Spoon'}
|
||||||
|
<ArrowRight className='size-4' />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button size='lg' variant='outline' asChild>
|
||||||
|
<Link href='#workflow'>See how it works</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='border-border bg-card border shadow-sm'>
|
||||||
|
<div className='border-border flex items-center justify-between border-b px-5 py-4'>
|
||||||
|
<div>
|
||||||
|
<p className='text-sm font-medium'>Spoon dashboard</p>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Upstream status across managed forks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className='bg-primary/10 text-primary hover:bg-primary/10'>
|
||||||
|
3 active Spoons
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-4 p-5 md:grid-cols-3'>
|
||||||
|
{[
|
||||||
|
['Updates', '4', '2 clean'],
|
||||||
|
['Needs review', '1', 'conflict risk'],
|
||||||
|
['Agents', '2', 'queued'],
|
||||||
|
].map(([label, value, note]) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className='border-border bg-background border p-4'
|
||||||
|
>
|
||||||
|
<p className='text-muted-foreground text-xs'>{label}</p>
|
||||||
|
<p className='mt-2 text-2xl font-semibold'>{value}</p>
|
||||||
|
<p className='text-muted-foreground mt-1 text-xs'>{note}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className='space-y-3 px-5 pb-5'>
|
||||||
|
{previewRows.map(({ name, upstream, status, icon: Icon, tone }) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className='border-border bg-background flex items-center justify-between gap-4 border p-4'
|
||||||
|
>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<span className='bg-muted flex size-9 items-center justify-center rounded-md'>
|
||||||
|
<GitBranch className='size-4' />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className='text-sm font-medium'>{name}</p>
|
||||||
|
<p className='text-muted-foreground text-xs'>{upstream}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className='flex items-center gap-2 text-sm'>
|
||||||
|
<Icon className={`size-4 ${tone}`} />
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { Hero } from './hero';
|
||||||
|
export { Agents, Features, Workflow } from './features';
|
||||||
|
export { CTA } from './cta';
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
const techStack = [
|
||||||
|
{
|
||||||
|
category: 'Frontend',
|
||||||
|
technologies: [
|
||||||
|
{ name: 'Next.js 16', description: 'React framework with App Router' },
|
||||||
|
{ name: 'Expo 54', description: 'React Native framework' },
|
||||||
|
{ name: 'React 19', description: 'Latest React with Server Components' },
|
||||||
|
{
|
||||||
|
name: 'Tailwind CSS v4',
|
||||||
|
description: 'Utility-first CSS framework',
|
||||||
|
},
|
||||||
|
{ name: 'shadcn/ui', description: 'Beautiful component library' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Backend',
|
||||||
|
technologies: [
|
||||||
|
{ name: 'Convex', description: 'Self-hosted reactive backend' },
|
||||||
|
{
|
||||||
|
name: '@convex-dev/auth',
|
||||||
|
description: 'Multi-provider authentication',
|
||||||
|
},
|
||||||
|
{ name: 'UseSend', description: 'Self-hosted email service' },
|
||||||
|
{ name: 'File Storage', description: 'Built-in file uploads' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Developer Tools',
|
||||||
|
technologies: [
|
||||||
|
{ name: 'Turborepo', description: 'High-performance build system' },
|
||||||
|
{ name: 'TypeScript', description: 'Type-safe development' },
|
||||||
|
{ name: 'Bun', description: 'Fast package manager and runtime' },
|
||||||
|
{ name: 'ESLint + Prettier', description: 'Code quality tools' },
|
||||||
|
{ name: 'Docker', description: 'Containerized deployment' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TechStack = () => (
|
||||||
|
<section id='tech-stack' className='border-border/40 bg-muted/30 border-t'>
|
||||||
|
<div className='container mx-auto px-4 py-24'>
|
||||||
|
<div className='mx-auto max-w-6xl'>
|
||||||
|
<div className='mb-16 text-center'>
|
||||||
|
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
|
||||||
|
Modern Tech Stack
|
||||||
|
</h2>
|
||||||
|
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
|
||||||
|
Built with the latest and greatest tools for maximum productivity
|
||||||
|
and performance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid gap-12 md:grid-cols-3'>
|
||||||
|
{techStack.map((stack) => (
|
||||||
|
<div key={stack.category}>
|
||||||
|
<h3 className='mb-6 text-xl font-semibold'>{stack.category}</h3>
|
||||||
|
<ul className='space-y-4'>
|
||||||
|
{stack.technologies.map((tech) => (
|
||||||
|
<li key={tech.name}>
|
||||||
|
<div className='text-foreground font-medium'>
|
||||||
|
{tech.name}
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
{tech.description}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
import { useAuthActions } from '@convex-dev/auth/react';
|
||||||
|
import { KeyRound } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { buttonVariants } from '@spoon/ui';
|
||||||
|
import { Button } from '@spoon/ui';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
};
|
||||||
|
type?: 'signIn' | 'signUp';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthentikSignInButton = ({
|
||||||
|
buttonProps,
|
||||||
|
type = 'signIn',
|
||||||
|
}: Props) => {
|
||||||
|
const { signIn } = useAuthActions();
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size='lg'
|
||||||
|
onClick={() => signIn('authentik')}
|
||||||
|
className='text-lg font-semibold'
|
||||||
|
{...buttonProps}
|
||||||
|
>
|
||||||
|
<div className='my-auto flex flex-row items-center gap-2'>
|
||||||
|
<KeyRound className='size-5' />
|
||||||
|
<p>{type === 'signIn' ? 'Continue' : 'Sign up'} with Authentik</p>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AuthentikSignInButton } from './gibs-auth';
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { Preloaded } from 'convex/react';
|
||||||
|
import type { ChangeEvent } from 'react';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useMutation, usePreloadedQuery, useQuery } from 'convex/react';
|
||||||
|
import { Loader2, Pencil, Upload, XIcon } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
BasedAvatar,
|
||||||
|
Button,
|
||||||
|
CardContent,
|
||||||
|
ImageCrop,
|
||||||
|
ImageCropApply,
|
||||||
|
ImageCropContent,
|
||||||
|
Input,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
|
type AvatarUploadProps = {
|
||||||
|
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataUrlToBlob = async (
|
||||||
|
dataUrl: string,
|
||||||
|
): Promise<{ blob: Blob; type: string }> => {
|
||||||
|
const re = /^data:([^;,]+)[;,]/;
|
||||||
|
const m = re.exec(dataUrl);
|
||||||
|
const type = m?.[1] ?? 'image/png';
|
||||||
|
|
||||||
|
const res = await fetch(dataUrl);
|
||||||
|
const blob = await res.blob();
|
||||||
|
return { blob, type };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||||
|
const user = usePreloadedQuery(preloadedUser);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [croppedImage, setCroppedImage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
||||||
|
const updateUser = useMutation(api.auth.updateUser);
|
||||||
|
|
||||||
|
const currentImageUrl = useQuery(
|
||||||
|
api.files.getImageUrl,
|
||||||
|
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0] ?? null;
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast.error('Please select an image file.');
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedFile(file);
|
||||||
|
setCroppedImage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setCroppedImage(null);
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!croppedImage) {
|
||||||
|
toast.error('Please apply a crop first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
const { blob, type } = await dataUrlToBlob(croppedImage);
|
||||||
|
const postUrl = await generateUploadUrl();
|
||||||
|
|
||||||
|
const result = await fetch(postUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': type },
|
||||||
|
body: blob,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
const msg = await result.text().catch(() => 'Upload failed.');
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadResponse = (await result.json()) as {
|
||||||
|
storageId: Id<'_storage'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateUser({ image: uploadResponse.storageId });
|
||||||
|
|
||||||
|
toast.success('Profile picture updated.');
|
||||||
|
handleReset();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload failed:', error);
|
||||||
|
toast.error('Upload failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardContent>
|
||||||
|
<div className='flex flex-col items-center gap-4'>
|
||||||
|
{/* Current avatar + trigger (hidden when cropping) */}
|
||||||
|
{!selectedFile && (
|
||||||
|
<div
|
||||||
|
className='group relative cursor-pointer'
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<BasedAvatar
|
||||||
|
src={currentImageUrl ?? undefined}
|
||||||
|
fullName={user?.name}
|
||||||
|
className='h-42 w-42 text-6xl font-semibold'
|
||||||
|
userIconProps={{ size: 100 }}
|
||||||
|
/>
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center rounded-full bg-black/0 transition-all group-hover:bg-black/50'>
|
||||||
|
<Upload
|
||||||
|
className='text-white opacity-0 transition-opacity group-hover:opacity-100'
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='absolute inset-1 flex items-end justify-end transition-all'>
|
||||||
|
<Pencil
|
||||||
|
className='text-white opacity-100 transition-opacity group-hover:opacity-0'
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File input (hidden) */}
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
id='avatar-upload'
|
||||||
|
type='file'
|
||||||
|
accept='image/*'
|
||||||
|
className='hidden'
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Crop UI */}
|
||||||
|
{selectedFile && !croppedImage && (
|
||||||
|
<div className='flex flex-col items-center gap-3'>
|
||||||
|
<ImageCrop
|
||||||
|
aspect={1}
|
||||||
|
circularCrop
|
||||||
|
file={selectedFile}
|
||||||
|
maxImageSize={3 * 1024 * 1024} // 3MB guard
|
||||||
|
onCrop={setCroppedImage}
|
||||||
|
>
|
||||||
|
<ImageCropContent className='max-w-sm' />
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Button size='icon' variant='outline'>
|
||||||
|
<ImageCropApply className='h-full w-full scale-150' />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleReset} size='icon' variant='destructive'>
|
||||||
|
<XIcon className='scale-150' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ImageCrop>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cropped preview + actions */}
|
||||||
|
{croppedImage && (
|
||||||
|
<div className='flex flex-col items-center gap-3'>
|
||||||
|
<Avatar className='h-42 w-42'>
|
||||||
|
<AvatarImage alt='Cropped preview' src={croppedImage} />
|
||||||
|
</Avatar>
|
||||||
|
<div className='flex items-center gap-1'>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isUploading}
|
||||||
|
variant='secondary'
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<span className='inline-flex items-center gap-2'>
|
||||||
|
<Loader2 className='h-4 w-4 animate-spin' />
|
||||||
|
Saving...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Save Avatar'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleReset}
|
||||||
|
size='icon'
|
||||||
|
type='button'
|
||||||
|
variant='destructive'
|
||||||
|
>
|
||||||
|
<XIcon className='size-4' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Uploading indicator */}
|
||||||
|
{isUploading && !croppedImage && (
|
||||||
|
<div className='mt-2 flex items-center text-sm text-gray-500'>
|
||||||
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||||
|
Uploading...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
);
|
||||||
|
};
|
||||||