Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1709233f7b | ||
|
|
08861cddb4 | ||
|
|
693a10957c |
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,462 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,593 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,473 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,463 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,735 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,732 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,448 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,401 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,567 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,386 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "frontend-design",
|
||||
"description": "Frontend design skill for UI/UX implementation",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,393 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,720 +0,0 @@
|
||||
# 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
|
||||
@@ -1,706 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,334 +0,0 @@
|
||||
# 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,
|
||||
}),
|
||||
});
|
||||
```
|
||||
@@ -1,390 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,303 +0,0 @@
|
||||
# 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
@@ -1,648 +0,0 @@
|
||||
# Payload Custom API Endpoints Reference
|
||||
|
||||
Custom REST API endpoints extend Payload's auto-generated CRUD operations with custom logic, authentication flows, webhooks, and integrations.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Endpoint Configuration
|
||||
|
||||
| Property | Type | Description |
|
||||
| --------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| `path` | `string` | Route path after collection/global slug (e.g., `/:id/tracking`) |
|
||||
| `method` | `'get' \| 'post' \| 'put' \| 'patch' \| 'delete'` | HTTP method (lowercase) |
|
||||
| `handler` | `(req: PayloadRequest) => Promise<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>
|
||||
@@ -1,559 +0,0 @@
|
||||
# Payload Field Type Guards Reference
|
||||
|
||||
Complete reference with detailed examples and patterns. See [FIELDS.md](FIELDS.md#field-type-guards) for quick reference table of all guards.
|
||||
|
||||
## Structural Guards
|
||||
|
||||
### fieldHasSubFields
|
||||
|
||||
Checks if field contains nested fields (group, array, row, or collapsible).
|
||||
|
||||
```ts
|
||||
import type { Field } from 'payload';
|
||||
import { fieldHasSubFields } from 'payload';
|
||||
|
||||
function traverseFields(fields: Field[]): void {
|
||||
fields.forEach((field) => {
|
||||
if (fieldHasSubFields(field)) {
|
||||
// Safe to access field.fields
|
||||
traverseFields(field.fields);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
fieldHasSubFields<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
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,754 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,194 +0,0 @@
|
||||
# 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))
|
||||
@@ -1,278 +0,0 @@
|
||||
# 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,676 @@
|
||||
---
|
||||
description: Guidelines and best practices for building Convex projects, including database schema design, queries, mutations, and real-world examples
|
||||
globs: **/*.ts,**/*.tsx,**/*.js,**/*.jsx
|
||||
---
|
||||
|
||||
# Convex guidelines
|
||||
## Function guidelines
|
||||
### New function syntax
|
||||
- ALWAYS use the new function syntax for Convex functions. For example:
|
||||
```typescript
|
||||
import { query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
export const f = query({
|
||||
args: {},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
// Function body
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Http endpoint syntax
|
||||
- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example:
|
||||
```typescript
|
||||
import { httpRouter } from "convex/server";
|
||||
import { httpAction } from "./_generated/server";
|
||||
const http = httpRouter();
|
||||
http.route({
|
||||
path: "/echo",
|
||||
method: "POST",
|
||||
handler: httpAction(async (ctx, req) => {
|
||||
const body = await req.bytes();
|
||||
return new Response(body, { status: 200 });
|
||||
}),
|
||||
});
|
||||
```
|
||||
- HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`.
|
||||
|
||||
### Validators
|
||||
- Below is an example of an array validator:
|
||||
```typescript
|
||||
import { mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default mutation({
|
||||
args: {
|
||||
simpleArray: v.array(v.union(v.string(), v.number())),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
//...
|
||||
},
|
||||
});
|
||||
```
|
||||
- Below is an example of a schema with validators that codify a discriminated union type:
|
||||
```typescript
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default defineSchema({
|
||||
results: defineTable(
|
||||
v.union(
|
||||
v.object({
|
||||
kind: v.literal("error"),
|
||||
errorMessage: v.string(),
|
||||
}),
|
||||
v.object({
|
||||
kind: v.literal("success"),
|
||||
value: v.number(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
});
|
||||
```
|
||||
- Always use the `v.null()` validator when returning a null value. Below is an example query that returns a null value:
|
||||
```typescript
|
||||
import { query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const exampleQuery = query({
|
||||
args: {},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
console.log("This query returns a null value");
|
||||
return null;
|
||||
},
|
||||
});
|
||||
```
|
||||
- Here are the valid Convex types along with their respective validators:
|
||||
Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes |
|
||||
| ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Id | string | `doc._id` | `v.id(tableName)` | |
|
||||
| Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. |
|
||||
| Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. |
|
||||
| Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. |
|
||||
| Boolean | boolean | `true` | `v.boolean()` |
|
||||
| String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. |
|
||||
| Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. |
|
||||
| Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. |
|
||||
| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". |
|
||||
| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "_". |
|
||||
|
||||
### Function registration
|
||||
- Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`.
|
||||
- Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private.
|
||||
- You CANNOT register a function through the `api` or `internal` objects.
|
||||
- ALWAYS include argument and return validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`. If a function doesn't return anything, include `returns: v.null()` as its output validator.
|
||||
- If the JavaScript implementation of a Convex function doesn't have a return value, it implicitly returns `null`.
|
||||
|
||||
### Function calling
|
||||
- Use `ctx.runQuery` to call a query from a query, mutation, or action.
|
||||
- Use `ctx.runMutation` to call a mutation from a mutation or action.
|
||||
- Use `ctx.runAction` to call an action from an action.
|
||||
- ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead.
|
||||
- Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions.
|
||||
- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls.
|
||||
- When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example,
|
||||
```
|
||||
export const f = query({
|
||||
args: { name: v.string() },
|
||||
returns: v.string(),
|
||||
handler: async (ctx, args) => {
|
||||
return "Hello " + args.name;
|
||||
},
|
||||
});
|
||||
|
||||
export const g = query({
|
||||
args: {},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const result: string = await ctx.runQuery(api.example.f, { name: "Bob" });
|
||||
return null;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Function references
|
||||
- Function references are pointers to registered Convex functions.
|
||||
- Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`.
|
||||
- Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`.
|
||||
- Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`.
|
||||
- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`.
|
||||
- Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`.
|
||||
|
||||
### Api design
|
||||
- Convex uses file-based routing, so thoughtfully organize files with public query, mutation, or action functions within the `convex/` directory.
|
||||
- Use `query`, `mutation`, and `action` to define public functions.
|
||||
- Use `internalQuery`, `internalMutation`, and `internalAction` to define private, internal functions.
|
||||
|
||||
### Pagination
|
||||
- Paginated queries are queries that return a list of results in incremental pages.
|
||||
- You can define pagination using the following syntax:
|
||||
|
||||
```ts
|
||||
import { v } from "convex/values";
|
||||
import { query, mutation } from "./_generated/server";
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
export const listWithExtraArg = query({
|
||||
args: { paginationOpts: paginationOptsValidator, author: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db
|
||||
.query("messages")
|
||||
.filter((q) => q.eq(q.field("author"), args.author))
|
||||
.order("desc")
|
||||
.paginate(args.paginationOpts);
|
||||
},
|
||||
});
|
||||
```
|
||||
Note: `paginationOpts` is an object with the following properties:
|
||||
- `numItems`: the maximum number of documents to return (the validator is `v.number()`)
|
||||
- `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`)
|
||||
- A query that ends in `.paginate()` returns an object that has the following properties:
|
||||
- page (contains an array of documents that you fetches)
|
||||
- isDone (a boolean that represents whether or not this is the last page of documents)
|
||||
- continueCursor (a string that represents the cursor to use to fetch the next page of documents)
|
||||
|
||||
|
||||
## Validator guidelines
|
||||
- `v.bigint()` is deprecated for representing signed 64-bit integers. Use `v.int64()` instead.
|
||||
- Use `v.record()` for defining a record type. `v.map()` and `v.set()` are not supported.
|
||||
|
||||
## Schema guidelines
|
||||
- Always define your schema in `convex/schema.ts`.
|
||||
- Always import the schema definition functions from `convex/server`:
|
||||
- System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`.
|
||||
- Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2".
|
||||
- Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes.
|
||||
|
||||
## Typescript guidelines
|
||||
- You can use the helper typescript type `Id` imported from './_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table.
|
||||
- If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record<Id<'users'>, string>`. Below is an example of using `Record` with an `Id` type in a query:
|
||||
```ts
|
||||
import { query } from "./_generated/server";
|
||||
import { Doc, Id } from "./_generated/dataModel";
|
||||
|
||||
export const exampleQuery = query({
|
||||
args: { userIds: v.array(v.id("users")) },
|
||||
returns: v.record(v.id("users"), v.string()),
|
||||
handler: async (ctx, args) => {
|
||||
const idToUsername: Record<Id<"users">, string> = {};
|
||||
for (const userId of args.userIds) {
|
||||
const user = await ctx.db.get(userId);
|
||||
if (user) {
|
||||
idToUsername[user._id] = user.username;
|
||||
}
|
||||
}
|
||||
|
||||
return idToUsername;
|
||||
},
|
||||
});
|
||||
```
|
||||
- Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`.
|
||||
- Always use `as const` for string literals in discriminated union types.
|
||||
- When using the `Array` type, make sure to always define your arrays as `const array: Array<T> = [...];`
|
||||
- When using the `Record` type, make sure to always define your records as `const record: Record<KeyType, ValueType> = {...};`
|
||||
- Always add `@types/node` to your `package.json` when using any Node.js built-in modules.
|
||||
|
||||
## Full text search guidelines
|
||||
- A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like:
|
||||
|
||||
const messages = await ctx.db
|
||||
.query("messages")
|
||||
.withSearchIndex("search_body", (q) =>
|
||||
q.search("body", "hello hi").eq("channel", "#general"),
|
||||
)
|
||||
.take(10);
|
||||
|
||||
## Query guidelines
|
||||
- Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead.
|
||||
- Convex queries do NOT support `.delete()`. Instead, `.collect()` the results, iterate over them, and call `ctx.db.delete(row._id)` on each result.
|
||||
- Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query.
|
||||
- When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax.
|
||||
### Ordering
|
||||
- By default Convex always returns documents in ascending `_creationTime` order.
|
||||
- You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending.
|
||||
- Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans.
|
||||
|
||||
|
||||
## Mutation guidelines
|
||||
- Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist.
|
||||
- Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist.
|
||||
|
||||
## Action guidelines
|
||||
- Always add `"use node";` to the top of files containing actions that use Node.js built-in modules.
|
||||
- Never use `ctx.db` inside of an action. Actions don't have access to the database.
|
||||
- Below is an example of the syntax for an action:
|
||||
```ts
|
||||
import { action } from "./_generated/server";
|
||||
|
||||
export const exampleAction = action({
|
||||
args: {},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
console.log("This action does not return anything");
|
||||
return null;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Scheduling guidelines
|
||||
### Cron guidelines
|
||||
- Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers.
|
||||
- Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods.
|
||||
- Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example,
|
||||
```ts
|
||||
import { cronJobs } from "convex/server";
|
||||
import { internal } from "./_generated/api";
|
||||
import { internalAction } from "./_generated/server";
|
||||
|
||||
const empty = internalAction({
|
||||
args: {},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
console.log("empty");
|
||||
},
|
||||
});
|
||||
|
||||
const crons = cronJobs();
|
||||
|
||||
// Run `internal.crons.empty` every two hours.
|
||||
crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {});
|
||||
|
||||
export default crons;
|
||||
```
|
||||
- You can register Convex functions within `crons.ts` just like any other file.
|
||||
- If a cron calls an internal function, always import the `internal` object from '_generated/api', even if the internal function is registered in the same file.
|
||||
|
||||
|
||||
## File storage guidelines
|
||||
- Convex includes file storage for large files like images, videos, and PDFs.
|
||||
- The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist.
|
||||
- Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata.
|
||||
|
||||
Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`.
|
||||
```
|
||||
import { query } from "./_generated/server";
|
||||
import { Id } from "./_generated/dataModel";
|
||||
|
||||
type FileMetadata = {
|
||||
_id: Id<"_storage">;
|
||||
_creationTime: number;
|
||||
contentType?: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const exampleQuery = query({
|
||||
args: { fileId: v.id("_storage") },
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const metadata: FileMetadata | null = await ctx.db.system.get(args.fileId);
|
||||
console.log(metadata);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
```
|
||||
- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage.
|
||||
|
||||
|
||||
# Examples:
|
||||
## Example: chat-app
|
||||
|
||||
### Task
|
||||
```
|
||||
Create a real-time chat application backend with AI responses. The app should:
|
||||
- Allow creating users with names
|
||||
- Support multiple chat channels
|
||||
- Enable users to send messages to channels
|
||||
- Automatically generate AI responses to user messages
|
||||
- Show recent message history
|
||||
|
||||
The backend should provide APIs for:
|
||||
1. User management (creation)
|
||||
2. Channel management (creation)
|
||||
3. Message operations (sending, listing)
|
||||
4. AI response generation using OpenAI's GPT-4
|
||||
|
||||
Messages should be stored with their channel, author, and content. The system should maintain message order
|
||||
and limit history display to the 10 most recent messages per channel.
|
||||
|
||||
```
|
||||
|
||||
### Analysis
|
||||
1. Task Requirements Summary:
|
||||
- Build a real-time chat backend with AI integration
|
||||
- Support user creation
|
||||
- Enable channel-based conversations
|
||||
- Store and retrieve messages with proper ordering
|
||||
- Generate AI responses automatically
|
||||
|
||||
2. Main Components Needed:
|
||||
- Database tables: users, channels, messages
|
||||
- Public APIs for user/channel management
|
||||
- Message handling functions
|
||||
- Internal AI response generation system
|
||||
- Context loading for AI responses
|
||||
|
||||
3. Public API and Internal Functions Design:
|
||||
Public Mutations:
|
||||
- createUser:
|
||||
- file path: convex/index.ts
|
||||
- arguments: {name: v.string()}
|
||||
- returns: v.object({userId: v.id("users")})
|
||||
- purpose: Create a new user with a given name
|
||||
- createChannel:
|
||||
- file path: convex/index.ts
|
||||
- arguments: {name: v.string()}
|
||||
- returns: v.object({channelId: v.id("channels")})
|
||||
- purpose: Create a new channel with a given name
|
||||
- sendMessage:
|
||||
- file path: convex/index.ts
|
||||
- arguments: {channelId: v.id("channels"), authorId: v.id("users"), content: v.string()}
|
||||
- returns: v.null()
|
||||
- purpose: Send a message to a channel and schedule a response from the AI
|
||||
|
||||
Public Queries:
|
||||
- listMessages:
|
||||
- file path: convex/index.ts
|
||||
- arguments: {channelId: v.id("channels")}
|
||||
- returns: v.array(v.object({
|
||||
_id: v.id("messages"),
|
||||
_creationTime: v.number(),
|
||||
channelId: v.id("channels"),
|
||||
authorId: v.optional(v.id("users")),
|
||||
content: v.string(),
|
||||
}))
|
||||
- purpose: List the 10 most recent messages from a channel in descending creation order
|
||||
|
||||
Internal Functions:
|
||||
- generateResponse:
|
||||
- file path: convex/index.ts
|
||||
- arguments: {channelId: v.id("channels")}
|
||||
- returns: v.null()
|
||||
- purpose: Generate a response from the AI for a given channel
|
||||
- loadContext:
|
||||
- file path: convex/index.ts
|
||||
- arguments: {channelId: v.id("channels")}
|
||||
- returns: v.array(v.object({
|
||||
_id: v.id("messages"),
|
||||
_creationTime: v.number(),
|
||||
channelId: v.id("channels"),
|
||||
authorId: v.optional(v.id("users")),
|
||||
content: v.string(),
|
||||
}))
|
||||
- writeAgentResponse:
|
||||
- file path: convex/index.ts
|
||||
- arguments: {channelId: v.id("channels"), content: v.string()}
|
||||
- returns: v.null()
|
||||
- purpose: Write an AI response to a given channel
|
||||
|
||||
4. Schema Design:
|
||||
- users
|
||||
- validator: { name: v.string() }
|
||||
- indexes: <none>
|
||||
- channels
|
||||
- validator: { name: v.string() }
|
||||
- indexes: <none>
|
||||
- messages
|
||||
- validator: { channelId: v.id("channels"), authorId: v.optional(v.id("users")), content: v.string() }
|
||||
- indexes
|
||||
- by_channel: ["channelId"]
|
||||
|
||||
5. Background Processing:
|
||||
- AI response generation runs asynchronously after each user message
|
||||
- Uses OpenAI's GPT-4 to generate contextual responses
|
||||
- Maintains conversation context using recent message history
|
||||
|
||||
|
||||
### Implementation
|
||||
|
||||
#### package.json
|
||||
```typescript
|
||||
{
|
||||
"name": "chat-app",
|
||||
"description": "This example shows how to build a chat app without authentication.",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"convex": "^1.17.4",
|
||||
"openai": "^4.79.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### tsconfig.json
|
||||
```typescript
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"exclude": ["convex"],
|
||||
"include": ["**/src/**/*.tsx", "**/src/**/*.ts", "vite.config.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
#### convex/index.ts
|
||||
```typescript
|
||||
import {
|
||||
query,
|
||||
mutation,
|
||||
internalQuery,
|
||||
internalMutation,
|
||||
internalAction,
|
||||
} from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import OpenAI from "openai";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
/**
|
||||
* Create a user with a given name.
|
||||
*/
|
||||
export const createUser = mutation({
|
||||
args: {
|
||||
name: v.string(),
|
||||
},
|
||||
returns: v.id("users"),
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db.insert("users", { name: args.name });
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a channel with a given name.
|
||||
*/
|
||||
export const createChannel = mutation({
|
||||
args: {
|
||||
name: v.string(),
|
||||
},
|
||||
returns: v.id("channels"),
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db.insert("channels", { name: args.name });
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* List the 10 most recent messages from a channel in descending creation order.
|
||||
*/
|
||||
export const listMessages = query({
|
||||
args: {
|
||||
channelId: v.id("channels"),
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id("messages"),
|
||||
_creationTime: v.number(),
|
||||
channelId: v.id("channels"),
|
||||
authorId: v.optional(v.id("users")),
|
||||
content: 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(10);
|
||||
return messages;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Send a message to a channel and schedule a response from the AI.
|
||||
*/
|
||||
export const sendMessage = mutation({
|
||||
args: {
|
||||
channelId: v.id("channels"),
|
||||
authorId: v.id("users"),
|
||||
content: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const channel = await ctx.db.get(args.channelId);
|
||||
if (!channel) {
|
||||
throw new Error("Channel not found");
|
||||
}
|
||||
const user = await ctx.db.get(args.authorId);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
await ctx.db.insert("messages", {
|
||||
channelId: args.channelId,
|
||||
authorId: args.authorId,
|
||||
content: args.content,
|
||||
});
|
||||
await ctx.scheduler.runAfter(0, internal.index.generateResponse, {
|
||||
channelId: args.channelId,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const openai = new OpenAI();
|
||||
|
||||
export const generateResponse = internalAction({
|
||||
args: {
|
||||
channelId: v.id("channels"),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const context = await ctx.runQuery(internal.index.loadContext, {
|
||||
channelId: args.channelId,
|
||||
});
|
||||
const response = await openai.chat.completions.create({
|
||||
model: "gpt-4o",
|
||||
messages: context,
|
||||
});
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) {
|
||||
throw new Error("No content in response");
|
||||
}
|
||||
await ctx.runMutation(internal.index.writeAgentResponse, {
|
||||
channelId: args.channelId,
|
||||
content,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const loadContext = internalQuery({
|
||||
args: {
|
||||
channelId: v.id("channels"),
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
role: v.union(v.literal("user"), v.literal("assistant")),
|
||||
content: v.string(),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const channel = await ctx.db.get(args.channelId);
|
||||
if (!channel) {
|
||||
throw new Error("Channel not found");
|
||||
}
|
||||
const messages = await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
|
||||
.order("desc")
|
||||
.take(10);
|
||||
|
||||
const result = [];
|
||||
for (const message of messages) {
|
||||
if (message.authorId) {
|
||||
const user = await ctx.db.get(message.authorId);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
result.push({
|
||||
role: "user" as const,
|
||||
content: `${user.name}: ${message.content}`,
|
||||
});
|
||||
} else {
|
||||
result.push({ role: "assistant" as const, content: message.content });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
export const writeAgentResponse = internalMutation({
|
||||
args: {
|
||||
channelId: v.id("channels"),
|
||||
content: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.insert("messages", {
|
||||
channelId: args.channelId,
|
||||
content: args.content,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### convex/schema.ts
|
||||
```typescript
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default defineSchema({
|
||||
channels: defineTable({
|
||||
name: v.string(),
|
||||
}),
|
||||
|
||||
users: defineTable({
|
||||
name: v.string(),
|
||||
}),
|
||||
|
||||
messages: defineTable({
|
||||
channelId: v.id("channels"),
|
||||
authorId: v.optional(v.id("users")),
|
||||
content: v.string(),
|
||||
}).index("by_channel", ["channelId"]),
|
||||
});
|
||||
```
|
||||
|
||||
#### src/App.tsx
|
||||
```typescript
|
||||
export default function App() {
|
||||
return <div>Hello World</div>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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,23 @@
|
||||
# Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo.
|
||||
# Keep this file up-to-date when you add new variables to \`.env\`.
|
||||
# This file will be committed to version control, so make sure not to have any secrets in it.
|
||||
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
|
||||
NODE_ENV=
|
||||
CI=
|
||||
SKIP_ENV_VALIDATION=
|
||||
CONVEX_SELF_HOSTED_URL=
|
||||
CONVEX_SELF_HOSTED_ADMIN_KEY=
|
||||
CONVEX_AUTH_URL=
|
||||
SITE_URL=
|
||||
USESEND_API_KEY=
|
||||
AUTH_AUTHENTIK_ID=
|
||||
AUTH_AUTHENTIK_SECRET=
|
||||
AUTH_AUTHENTIK_ISSUER=
|
||||
AUTH_MICROSOFT_ENTRA_ID_ID=
|
||||
AUTH_MICROSOFT_ENTRA_ID_SECRET=
|
||||
AUTH_MICROSOFT_ENTRA_ID_ISSUER=
|
||||
AUTH_MICROSOFT_ENTRA_ID_AUTH_UR=
|
||||
SENTRY_AUTH_TOKEN=
|
||||
SENTRY_DSN=
|
||||
SENTRY_ORG=
|
||||
SENTRY_PROJECT_NAME=
|
||||
@@ -1,56 +0,0 @@
|
||||
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 convexmonorepo-no-payload-next:latest git.gbrown.org/gib/convexmonorepo-no-payload-next:${{ gitea.sha }}
|
||||
docker tag convexmonorepo-no-payload-next:latest git.gbrown.org/gib/convexmonorepo-no-payload-next:latest
|
||||
docker push git.gbrown.org/gib/convexmonorepo-no-payload-next:${{ gitea.sha }}
|
||||
docker push git.gbrown.org/gib/convexmonorepo-no-payload-next:latest
|
||||
@@ -1,8 +1,5 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# Hosting
|
||||
/docker/data
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
@@ -10,10 +7,6 @@ node_modules
|
||||
|
||||
# testing
|
||||
coverage
|
||||
**/.cache/
|
||||
|
||||
# generated Convex client/server bindings (tracked, but not linted/formatted)
|
||||
packages/backend/convex/_generated/
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
@@ -52,17 +45,9 @@ yarn-error.log*
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env
|
||||
.env.staging
|
||||
.local/
|
||||
|
||||
# turbo
|
||||
.turbo/
|
||||
**/.turbo/
|
||||
|
||||
# playwright e2e
|
||||
apps/next/tests/e2e/.auth/
|
||||
apps/next/test-results/
|
||||
apps/next/playwright-report/
|
||||
.turbo
|
||||
|
||||
# tanstack
|
||||
.tanstack
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
bunx lint-staged --concurrent 1
|
||||
@@ -1 +0,0 @@
|
||||
bun run ci:check
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"workspaceId": "b2760150-c581-4e0d-acb5-b4da51cee58d",
|
||||
"defaultEnvironment": "",
|
||||
"gitBranchToEnvironmentMapping": null
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"arrowParens": "always"
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
# 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, and
|
||||
its own data volume. Convex does not use Postgres unless a cloned project
|
||||
explicitly enables the commented optional `POSTGRES_URL` example.
|
||||
|
||||
## 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 Convex and dashboard
|
||||
bun dev:next # host Next + deploy/watch local Convex functions
|
||||
bun db:down # stop and preserve Convex data
|
||||
bun db:down:wipe # remove Convex volume and generated admin key
|
||||
```
|
||||
|
||||
Local URLs: app `http://localhost:3000`, Convex `http://localhost:3210`,
|
||||
dashboard `http://localhost:6791`.
|
||||
Use `INFISICAL_ENV=staging bun dev:next` for staging services. Normal `db:up`
|
||||
never connects to staging and does not start Postgres.
|
||||
|
||||
## 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.
|
||||
@@ -187,7 +187,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
Copyright 2024 Convex, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -1,85 +1,190 @@
|
||||
# Convex Turbo Monorepo
|
||||
# Fullstack monorepo template feat. Expo, Turbo, Next.js, Convex, Clerk
|
||||
|
||||
A reusable Bun/Turborepo template with Next.js 16, Expo, self-hosted Convex,
|
||||
shared UI/config packages, Vitest, and Docker deployment.
|
||||
This is a modern TypeScript monorepo template with AI web and native apps
|
||||
featuring:
|
||||
|
||||
## Local setup
|
||||
- Turborepo: Monorepo management
|
||||
- React 19: Latest React with concurrent features
|
||||
- Next.js 15: Web app & marketing page with App Router
|
||||
- Tailwind CSS v4: Modern CSS-first configuration
|
||||
- React Native [Expo](https://expo.dev/): Mobile/native app with New Architecture
|
||||
- [Convex](https://convex.dev): Backend, database, server functions
|
||||
- [Clerk](https://clerk.dev): User authentication
|
||||
- OpenAI: Text summarization (optional)
|
||||
|
||||
Requirements: Bun 1.3.10, Docker or Podman, Node 22, and the Infisical CLI.
|
||||
The example app is a note taking app that can summarize notes using AI. Features
|
||||
include:
|
||||
|
||||
- Marketing page
|
||||
- Dashboard page (web & native)
|
||||
- Note taking page (web & native)
|
||||
- Backend API that serves web & native with the same API
|
||||
- Relational database
|
||||
- End to end type safety (schema definition to frontend API clients)
|
||||
- User authentication
|
||||
- Asynchronous call to an OpenAI
|
||||
- Everything is realtime by default
|
||||
|
||||
## Using this example
|
||||
|
||||
### 1. Install dependencies
|
||||
|
||||
If you don't have `yarn` installed, run `npm install --global yarn`.
|
||||
|
||||
Run `yarn`.
|
||||
|
||||
### 2. Configure Convex
|
||||
|
||||
> Note: The following command will print an error and ask you to add the
|
||||
> appropriate environment variable to proceed. Continue reading on for how to do
|
||||
> that.
|
||||
|
||||
```sh
|
||||
bun install --frozen-lockfile
|
||||
infisical login
|
||||
infisical init
|
||||
bun db:up
|
||||
bun dev:next
|
||||
npm run setup --workspace packages/backend
|
||||
```
|
||||
|
||||
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`.
|
||||
The script will log you into Convex if you aren't already and prompt you to
|
||||
create a project (free). It will then wait to deploy your code until you set the
|
||||
environment variables in the dashboard.
|
||||
|
||||
Local services:
|
||||
Configure Clerk with [this guide](https://docs.convex.dev/auth/clerk). Then add
|
||||
the `CLERK_ISSUER_URL` found in the "convex" template
|
||||
[here](https://dashboard.clerk.com/last-active?path=jwt-templates), to your
|
||||
Convex environment variables
|
||||
[here](https://dashboard.convex.dev/deployment/settings/environment-variables&var=CLERK_ISSUER_URL).
|
||||
|
||||
- Next.js: `http://localhost:3000`
|
||||
- Convex API: `http://localhost:3210`
|
||||
- Convex dashboard: `http://localhost:6791`
|
||||
Make sure to enable **Google and Apple** as possible Social Connection
|
||||
providers, as these are used by the React Native login implementation.
|
||||
|
||||
Next and Expo run on the host. Convex uses a self-hosted data volume and does
|
||||
not use Postgres by default. The commented `POSTGRES_URL` in Compose is an
|
||||
opt-in example for cloned projects that explicitly choose Convex-on-Postgres.
|
||||
After that, optionally add the `OPENAI_API_KEY` env var from
|
||||
[OpenAI](https://platform.openai.com/account/api-keys) to your Convex
|
||||
environment variables to get AI summaries.
|
||||
|
||||
The `setup` command should now finish successfully.
|
||||
|
||||
### 3. Configure both apps
|
||||
|
||||
In each app directory (`apps/web`, `apps/native`) create a `.env.local` file
|
||||
using the `.example.env` as a template and fill out your Convex and Clerk
|
||||
environment variables.
|
||||
|
||||
- Use the `CONVEX_URL` from `packages/backend/.env.local` for
|
||||
`{NEXT,EXPO}_PUBLIC_CONVEX_URL`.
|
||||
- The Clerk publishable & secret keys can be found
|
||||
[here](https://dashboard.clerk.com/last-active?path=api-keys).
|
||||
|
||||
### 4. Run both apps
|
||||
|
||||
Run the following command to run both the web and mobile apps:
|
||||
|
||||
```sh
|
||||
bun db:down # stop; preserve Convex data
|
||||
bun db:down:wipe # remove the Convex volume and generated admin key
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Normal `bun db:up` never contacts staging and does not start Postgres. It starts
|
||||
the local Convex backend and dashboard, generates a machine-local Convex admin
|
||||
key in `.local/dev.generated.env` when needed, deploys functions/schema, and
|
||||
configures local Convex Auth keys.
|
||||
This will allow you to use the ⬆ and ⬇ keyboard keys to see logs for each
|
||||
of the Convex backend, web app, and mobile app separately.
|
||||
If you'd rather see all of the logs in one place, delete the
|
||||
`"ui": "tui",` line in [turbo.json](./turbo.json).
|
||||
|
||||
Physical devices cannot resolve their own `localhost`; override the public
|
||||
Convex URL with the development host's LAN address when testing Expo on-device.
|
||||
## Deploying
|
||||
|
||||
## 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
|
||||
In order to both deploy the frontend and Convex, run this as the build command from the apps/web directory:
|
||||
|
||||
```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
|
||||
cd ../../packages/backend && npx convex deploy --cmd 'cd ../../apps/web && turbo run build' --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL
|
||||
```
|
||||
|
||||
`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.
|
||||
There is a vercel.json file in the apps/web directory with this configuration for Vercel.
|
||||
|
||||
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.
|
||||
## What's inside?
|
||||
|
||||
## Deployment
|
||||
This monorepo template includes the following packages/apps:
|
||||
|
||||
Production Compose retains the self-hosted Convex backend/dashboard and has no
|
||||
Postgres service by default. A commented `POSTGRES_URL` remains only as an
|
||||
optional Convex-on-Postgres example for cloned projects. 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.
|
||||
### Apps and Packages
|
||||
|
||||
- `web`: a [Next.js 15](https://nextjs.org/) app with Tailwind CSS and Clerk
|
||||
- `native`: a [React Native](https://reactnative.dev/) app built with
|
||||
[expo](https://docs.expo.dev/)
|
||||
- `packages/backend`: a [Convex](https://www.convex.dev/) folder with the
|
||||
database schema and shared functions
|
||||
|
||||
Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
|
||||
|
||||
To install a new package, `cd` into that directory, such as [packages/backend](./packages/backend/), and then run `yarn add mypackage@latest`
|
||||
|
||||
### Utilities
|
||||
|
||||
This Turborepo has some additional tools already setup for you:
|
||||
|
||||
- [Expo](https://docs.expo.dev/) for native development
|
||||
- [TypeScript](https://www.typescriptlang.org/) for static type checking
|
||||
- [Prettier](https://prettier.io) for code formatting
|
||||
|
||||
# What is Convex?
|
||||
|
||||
[Convex](https://convex.dev) is a hosted backend platform with a built-in
|
||||
reactive database that lets you write your
|
||||
[database schema](https://docs.convex.dev/database/schemas) and
|
||||
[server functions](https://docs.convex.dev/functions) in
|
||||
[TypeScript](https://docs.convex.dev/typescript). Server-side database
|
||||
[queries](https://docs.convex.dev/functions/query-functions) automatically
|
||||
[cache](https://docs.convex.dev/functions/query-functions#caching--reactivity)
|
||||
and [subscribe](https://docs.convex.dev/client/react#reactivity) to data,
|
||||
powering a
|
||||
[realtime `useQuery` hook](https://docs.convex.dev/client/react#fetching-data)
|
||||
in our [React client](https://docs.convex.dev/client/react). There are also
|
||||
clients for [Python](https://docs.convex.dev/client/python),
|
||||
[Rust](https://docs.convex.dev/client/rust),
|
||||
[ReactNative](https://docs.convex.dev/client/react-native), and
|
||||
[Node](https://docs.convex.dev/client/javascript), as well as a straightforward
|
||||
[HTTP API](https://github.com/get-convex/convex-js/blob/main/src/browser/http_client.ts#L40).
|
||||
|
||||
The database supports
|
||||
[NoSQL-style documents](https://docs.convex.dev/database/document-storage) with
|
||||
[relationships](https://docs.convex.dev/database/document-ids) and
|
||||
[custom indexes](https://docs.convex.dev/database/indexes/) (including on fields
|
||||
in nested objects).
|
||||
|
||||
The [`query`](https://docs.convex.dev/functions/query-functions) and
|
||||
[`mutation`](https://docs.convex.dev/functions/mutation-functions) server
|
||||
functions have transactional, low latency access to the database and leverage
|
||||
our [`v8` runtime](https://docs.convex.dev/functions/runtimes) with
|
||||
[determinism guardrails](https://docs.convex.dev/functions/runtimes#using-randomness-and-time-in-queries-and-mutations)
|
||||
to provide the strongest ACID guarantees on the market: immediate consistency,
|
||||
serializable isolation, and automatic conflict resolution via
|
||||
[optimistic multi-version concurrency control](https://docs.convex.dev/database/advanced/occ)
|
||||
(OCC / MVCC).
|
||||
|
||||
The [`action` server functions](https://docs.convex.dev/functions/actions) have
|
||||
access to external APIs and enable other side-effects and non-determinism in
|
||||
either our [optimized `v8` runtime](https://docs.convex.dev/functions/runtimes)
|
||||
or a more
|
||||
[flexible `node` runtime](https://docs.convex.dev/functions/runtimes#nodejs-runtime).
|
||||
|
||||
Functions can run in the background via
|
||||
[scheduling](https://docs.convex.dev/scheduling/scheduled-functions) and
|
||||
[cron jobs](https://docs.convex.dev/scheduling/cron-jobs).
|
||||
|
||||
Development is cloud-first, with
|
||||
[hot reloads for server function](https://docs.convex.dev/cli#run-the-convex-dev-server)
|
||||
editing via the [CLI](https://docs.convex.dev/cli). There is a
|
||||
[dashboard UI](https://docs.convex.dev/dashboard) to
|
||||
[browse and edit data](https://docs.convex.dev/dashboard/deployments/data),
|
||||
[edit environment variables](https://docs.convex.dev/production/environment-variables),
|
||||
[view logs](https://docs.convex.dev/dashboard/deployments/logs),
|
||||
[run server functions](https://docs.convex.dev/dashboard/deployments/functions),
|
||||
and more.
|
||||
|
||||
There are built-in features for
|
||||
[reactive pagination](https://docs.convex.dev/database/pagination),
|
||||
[file storage](https://docs.convex.dev/file-storage),
|
||||
[reactive search](https://docs.convex.dev/text-search),
|
||||
[https endpoints](https://docs.convex.dev/functions/http-actions) (for
|
||||
webhooks),
|
||||
[streaming import/export](https://docs.convex.dev/database/import-export/), and
|
||||
[runtime data validation](https://docs.convex.dev/database/schemas#validators)
|
||||
for [function arguments](https://docs.convex.dev/functions/args-validation) and
|
||||
[database data](https://docs.convex.dev/database/schemas#schema-validation).
|
||||
|
||||
Everything scales automatically, and it’s
|
||||
[free to start](https://www.convex.dev/plans).
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import type { ConfigContext, ExpoConfig } from 'expo/config';
|
||||
import type { ConfigContext, ExpoConfig } from "expo/config";
|
||||
|
||||
export default ({ config }: ConfigContext): ExpoConfig => ({
|
||||
...config,
|
||||
name: 'expo',
|
||||
slug: 'expo',
|
||||
scheme: 'expo',
|
||||
version: '0.1.0',
|
||||
orientation: 'portrait',
|
||||
icon: './assets/icon-light.png',
|
||||
userInterfaceStyle: 'automatic',
|
||||
name: "expo",
|
||||
slug: "expo",
|
||||
scheme: "expo",
|
||||
version: "0.1.0",
|
||||
orientation: "portrait",
|
||||
icon: "./assets/icon-light.png",
|
||||
userInterfaceStyle: "automatic",
|
||||
updates: {
|
||||
fallbackToCacheTimeout: 0,
|
||||
},
|
||||
newArchEnabled: true,
|
||||
assetBundlePatterns: ['**/*'],
|
||||
assetBundlePatterns: ["**/*"],
|
||||
ios: {
|
||||
bundleIdentifier: 'your.bundle.identifier',
|
||||
bundleIdentifier: "your.bundle.identifier",
|
||||
supportsTablet: true,
|
||||
icon: {
|
||||
light: './assets/icon-light.png',
|
||||
dark: './assets/icon-dark.png',
|
||||
light: "./assets/icon-light.png",
|
||||
dark: "./assets/icon-dark.png",
|
||||
},
|
||||
},
|
||||
android: {
|
||||
package: 'your.bundle.identifier',
|
||||
package: "your.bundle.identifier",
|
||||
adaptiveIcon: {
|
||||
foregroundImage: './assets/icon-light.png',
|
||||
backgroundColor: '#1F104A',
|
||||
foregroundImage: "./assets/icon-light.png",
|
||||
backgroundColor: "#1F104A",
|
||||
},
|
||||
edgeToEdgeEnabled: true,
|
||||
},
|
||||
@@ -42,17 +42,17 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
||||
reactCompiler: true,
|
||||
},
|
||||
plugins: [
|
||||
'expo-router',
|
||||
'expo-secure-store',
|
||||
'expo-web-browser',
|
||||
"expo-router",
|
||||
"expo-secure-store",
|
||||
"expo-web-browser",
|
||||
[
|
||||
'expo-splash-screen',
|
||||
"expo-splash-screen",
|
||||
{
|
||||
backgroundColor: '#E4E4E7',
|
||||
image: './assets/icon-light.png',
|
||||
backgroundColor: "#E4E4E7",
|
||||
image: "./assets/icon-light.png",
|
||||
dark: {
|
||||
backgroundColor: '#18181B',
|
||||
image: './assets/icon-dark.png',
|
||||
backgroundColor: "#18181B",
|
||||
image: "./assets/icon-dark.png",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"build": {
|
||||
"base": {
|
||||
"node": "22.12.0",
|
||||
"bun": "1.3.10",
|
||||
"pnpm": "9.15.4",
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import { defineConfig } from "eslint/config";
|
||||
|
||||
import { baseConfig } from '@gib/eslint-config/base';
|
||||
import { reactConfig } from '@gib/eslint-config/react';
|
||||
import { baseConfig } from "@acme/eslint-config/base";
|
||||
import { reactConfig } from "@acme/eslint-config/react";
|
||||
|
||||
export default defineConfig(
|
||||
{
|
||||
ignores: ['.expo/**', 'expo-plugins/**'],
|
||||
ignores: [".expo/**", "expo-plugins/**"],
|
||||
},
|
||||
baseConfig,
|
||||
reactConfig,
|
||||
|
||||
@@ -1 +1 @@
|
||||
import 'expo-router/entry';
|
||||
import "expo-router/entry";
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// Learn more: https://docs.expo.dev/guides/monorepos/
|
||||
const path = require('node:path');
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const { FileStore } = require('metro-cache');
|
||||
const { withNativewind } = require('nativewind/metro');
|
||||
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'),
|
||||
root: path.join(__dirname, "node_modules", ".cache", "metro"),
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,67 +1,66 @@
|
||||
{
|
||||
"name": "@gib/expo",
|
||||
"name": "@acme/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",
|
||||
"dev": "expo start",
|
||||
"dev:android": "expo start --android",
|
||||
"dev:ios": "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} --"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@acme/backend": "workspace:*",
|
||||
"@convex-dev/auth": "catalog:convex",
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@gib/backend": "workspace:*",
|
||||
"@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",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@legendapp/list": "^2.0.14",
|
||||
"@react-navigation/bottom-tabs": "^7.6.0",
|
||||
"@react-navigation/elements": "^2.7.1",
|
||||
"@react-navigation/native": "^7.1.19",
|
||||
"@sentry/react-native": "^7.4.0",
|
||||
"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",
|
||||
"expo": "~54.0.20",
|
||||
"expo-apple-authentication": "~8.0.7",
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-dev-client": "~6.0.16",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-secure-store": "~15.0.7",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "~1.0.7",
|
||||
"expo-system-ui": "~6.0.8",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"nativewind": "5.0.0-preview.2",
|
||||
"react": "catalog:react19",
|
||||
"react-dom": "catalog:react19",
|
||||
"react-native": "~0.81.6",
|
||||
"react-native": "~0.81.5",
|
||||
"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-reanimated": "~4.1.3",
|
||||
"react-native-safe-area-context": "~5.6.1",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.2",
|
||||
"react-native-worklets": "~0.5.2"
|
||||
"react-native-worklets": "~0.5.1",
|
||||
"superjson": "2.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gib/eslint-config": "workspace:*",
|
||||
"@gib/prettier-config": "workspace:*",
|
||||
"@gib/tailwind-config": "workspace:*",
|
||||
"@gib/tsconfig": "workspace:*",
|
||||
"@acme/eslint-config": "workspace:*",
|
||||
"@acme/prettier-config": "workspace:*",
|
||||
"@acme/tailwind-config": "workspace:*",
|
||||
"@acme/tsconfig": "workspace:*",
|
||||
"@types/react": "catalog:react19",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"prettier": "@gib/prettier-config"
|
||||
"prettier": "@acme/prettier-config"
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
module.exports = require('@gib/tailwind-config/postcss-config');
|
||||
module.exports = require("@acme/tailwind-config/postcss-config");
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { ConvexAuthProvider } from '@convex-dev/auth/react';
|
||||
import { useColorScheme } from "react-native";
|
||||
import { Stack } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { convex } from '~/utils/convex';
|
||||
import { queryClient } from "~/utils/api";
|
||||
|
||||
import '../styles.css';
|
||||
import "../styles.css";
|
||||
|
||||
const RootLayout = () => {
|
||||
// This is the main layout of the app
|
||||
// It wraps your pages with the providers they need
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
return (
|
||||
<ConvexAuthProvider client={convex}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{/*
|
||||
The Stack component displays the current page.
|
||||
It also allows you to configure your screens
|
||||
*/}
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: colorScheme === 'dark' ? '#1c1917' : '#faf9f7',
|
||||
backgroundColor: "#c03484",
|
||||
},
|
||||
headerTintColor: colorScheme === 'dark' ? '#fafaf9' : '#1c1917',
|
||||
contentStyle: {
|
||||
backgroundColor: colorScheme === 'dark' ? '#1c1917' : '#faf9f7',
|
||||
backgroundColor: colorScheme == "dark" ? "#09090B" : "#FFFFFF",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<StatusBar style='auto' />
|
||||
</ConvexAuthProvider>
|
||||
<StatusBar />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default RootLayout;
|
||||
}
|
||||
|
||||
@@ -1,54 +1,172 @@
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Stack } from 'expo-router';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { useConvexAuth, useQuery } from 'convex/react';
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, TextInput, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { LegendList } from "@legendapp/list";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api } from '@gib/backend/convex/_generated/api.js';
|
||||
import type { RouterOutputs } from "~/utils/api";
|
||||
import { trpc } from "~/utils/api";
|
||||
import { authClient } from "~/utils/auth";
|
||||
|
||||
const Index = () => {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||
const { signOut } = useAuthActions();
|
||||
const user = useQuery(api.auth.getUser, isAuthenticated ? {} : 'skip');
|
||||
function PostCard(props: {
|
||||
post: RouterOutputs["post"]["all"][number];
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View className="bg-muted flex flex-row rounded-lg p-4">
|
||||
<View className="grow">
|
||||
<Link
|
||||
asChild
|
||||
href={{
|
||||
pathname: "/post/[id]",
|
||||
params: { id: props.post.id },
|
||||
}}
|
||||
>
|
||||
<Pressable className="">
|
||||
<Text className="text-primary text-xl font-semibold">
|
||||
{props.post.title}
|
||||
</Text>
|
||||
<Text className="text-foreground mt-2">{props.post.content}</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
<Pressable onPress={props.onDelete}>
|
||||
<Text className="text-primary font-bold uppercase">Delete</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CreatePost() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
const { mutate, error } = useMutation(
|
||||
trpc.post.create.mutationOptions({
|
||||
async onSuccess() {
|
||||
setTitle("");
|
||||
setContent("");
|
||||
await queryClient.invalidateQueries(trpc.post.all.queryFilter());
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView className='bg-background flex-1'>
|
||||
<Stack.Screen options={{ title: 'Home' }} />
|
||||
<View className='flex-1 items-center justify-center gap-4 p-6'>
|
||||
<Text className='text-foreground text-4xl font-bold'>
|
||||
Convex Monorepo
|
||||
<View className="mt-4 flex gap-2">
|
||||
<TextInput
|
||||
className="border-input bg-background text-foreground items-center rounded-md border px-3 text-lg leading-tight"
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Title"
|
||||
/>
|
||||
{error?.data?.zodError?.fieldErrors.title && (
|
||||
<Text className="text-destructive mb-2">
|
||||
{error.data.zodError.fieldErrors.title}
|
||||
</Text>
|
||||
<Text className='text-muted-foreground text-center text-base'>
|
||||
Your self-hosted Expo + Convex starter
|
||||
)}
|
||||
<TextInput
|
||||
className="border-input bg-background text-foreground items-center rounded-md border px-3 text-lg leading-tight"
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="Content"
|
||||
/>
|
||||
{error?.data?.zodError?.fieldErrors.content && (
|
||||
<Text className="text-destructive mb-2">
|
||||
{error.data.zodError.fieldErrors.content}
|
||||
</Text>
|
||||
)}
|
||||
<Pressable
|
||||
className="bg-primary flex items-center rounded-sm p-2"
|
||||
onPress={() => {
|
||||
mutate({
|
||||
title,
|
||||
content,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text className="text-foreground">Create</Text>
|
||||
</Pressable>
|
||||
{error?.data?.code === "UNAUTHORIZED" && (
|
||||
<Text className="text-destructive mt-2">
|
||||
You need to be logged in to create a post
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileAuth() {
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text className="text-foreground pb-2 text-center text-xl font-semibold">
|
||||
{session?.user.name ? `Hello, ${session.user.name}` : "Not logged in"}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
session
|
||||
? authClient.signOut()
|
||||
: authClient.signIn.social({
|
||||
provider: "discord",
|
||||
callbackURL: "/",
|
||||
})
|
||||
}
|
||||
className="bg-primary flex items-center rounded-sm p-2"
|
||||
>
|
||||
<Text>{session ? "Sign Out" : "Sign In With Discord"}</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const postQuery = useQuery(trpc.post.all.queryOptions());
|
||||
|
||||
const deletePostMutation = useMutation(
|
||||
trpc.post.delete.mutationOptions({
|
||||
onSettled: () =>
|
||||
queryClient.invalidateQueries(trpc.post.all.queryFilter()),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView className="bg-background">
|
||||
{/* Changes page title visible on the header */}
|
||||
<Stack.Screen options={{ title: "Home Page" }} />
|
||||
<View className="bg-background h-full w-full p-4">
|
||||
<Text className="text-foreground pb-2 text-center text-5xl font-bold">
|
||||
Create <Text className="text-primary">T3</Text> Turbo
|
||||
</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<Text className='text-muted-foreground'>Loading...</Text>
|
||||
) : isAuthenticated ? (
|
||||
<View className='w-full gap-3'>
|
||||
<Text className='text-foreground text-center text-lg'>
|
||||
Welcome{user?.name ? `, ${user.name}` : ''}!
|
||||
</Text>
|
||||
<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='w-full gap-3'>
|
||||
<Text className='text-muted-foreground text-center'>
|
||||
Sign in to get started
|
||||
</Text>
|
||||
{/* Add sign-in UI here — see apps/next/src/app/(auth)/sign-in for patterns */}
|
||||
</View>
|
||||
)}
|
||||
<MobileAuth />
|
||||
|
||||
<View className="py-2">
|
||||
<Text className="text-primary font-semibold italic">
|
||||
Press on a post
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<LegendList
|
||||
data={postQuery.data ?? []}
|
||||
estimatedItemSize={20}
|
||||
keyExtractor={(item) => item.id}
|
||||
ItemSeparatorComponent={() => <View className="h-2" />}
|
||||
renderItem={(p) => (
|
||||
<PostCard
|
||||
post={p.item}
|
||||
onDelete={() => deletePostMutation.mutate(p.item.id)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<CreatePost />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||
import { SafeAreaView, Text, View } from "react-native";
|
||||
import { Stack, useGlobalSearchParams } from "expo-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
const Post = () => {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
import { trpc } from "~/utils/api";
|
||||
|
||||
export default function Post() {
|
||||
const { id } = useGlobalSearchParams<{ id: string }>();
|
||||
const { data } = useQuery(trpc.post.byId.queryOptions({ id }));
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
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.
|
||||
<SafeAreaView className="bg-background">
|
||||
<Stack.Screen options={{ title: data.title }} />
|
||||
<View className="h-full w-full p-4">
|
||||
<Text className="text-primary py-2 text-3xl font-bold">
|
||||
{data.title}
|
||||
</Text>
|
||||
<Text className="text-foreground py-4">{data.content}</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Post;
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
declare module '*.css';
|
||||
@@ -1,3 +1,3 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'nativewind/theme';
|
||||
@import '@gib/tailwind-config/theme';
|
||||
@import "tailwindcss";
|
||||
@import "nativewind/theme";
|
||||
@import "@acme/tailwind-config/theme";
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { createTRPCClient, httpBatchLink, loggerLink } from "@trpc/client";
|
||||
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
||||
import superjson from "superjson";
|
||||
|
||||
import type { AppRouter } from "@acme/api";
|
||||
|
||||
import { authClient } from "./auth";
|
||||
import { getBaseUrl } from "./base-url";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// ...
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A set of typesafe hooks for consuming your API.
|
||||
*/
|
||||
export const trpc = createTRPCOptionsProxy<AppRouter>({
|
||||
client: createTRPCClient({
|
||||
links: [
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
process.env.NODE_ENV === "development" ||
|
||||
(opts.direction === "down" && opts.result instanceof Error),
|
||||
colorMode: "ansi",
|
||||
}),
|
||||
httpBatchLink({
|
||||
transformer: superjson,
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
headers() {
|
||||
const headers = new Map<string, string>();
|
||||
headers.set("x-trpc-source", "expo-react");
|
||||
|
||||
const cookies = authClient.getCookie();
|
||||
if (cookies) {
|
||||
headers.set("Cookie", cookies);
|
||||
}
|
||||
return headers;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
queryClient,
|
||||
});
|
||||
|
||||
export type { RouterInputs, RouterOutputs } from "@acme/api";
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { expoClient } from "@better-auth/expo/client";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
import { getBaseUrl } from "./base-url";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: getBaseUrl(),
|
||||
plugins: [
|
||||
expoClient({
|
||||
scheme: "expo",
|
||||
storagePrefix: "expo",
|
||||
storage: SecureStore,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import Constants from 'expo-constants';
|
||||
import Constants from "expo-constants";
|
||||
|
||||
/**
|
||||
* Extend this function when going to production by
|
||||
@@ -14,12 +14,12 @@ export const getBaseUrl = () => {
|
||||
* baseUrl to your production API URL.
|
||||
*/
|
||||
const debuggerHost = Constants.expoConfig?.hostUri;
|
||||
const localhost = debuggerHost?.split(':')[0];
|
||||
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.',
|
||||
"Failed to get localhost. Please point to your production server.",
|
||||
);
|
||||
}
|
||||
return `http://${localhost}:3000`;
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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());
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
|
||||
const key = 'session_token';
|
||||
const key = "session_token";
|
||||
|
||||
export const getToken = () => SecureStore.getItem(key);
|
||||
export const deleteToken = () => SecureStore.deleteItemAsync(key);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"extends": ["@gib/tsconfig/base.json"],
|
||||
"extends": ["@acme/tsconfig/base.json"],
|
||||
"compilerOptions": {
|
||||
"jsx": "react-native",
|
||||
"checkJs": false,
|
||||
"moduleSuffixes": [".ios", ".android", ".native", ""],
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://v2-8-20.turborepo.dev/schema.json",
|
||||
"$schema": "https://turborepo.com/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"dev": {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
|
||||
import { baseConfig, restrictEnvAccess } from '@gib/eslint-config/base';
|
||||
import { nextjsConfig } from '@gib/eslint-config/nextjs';
|
||||
import { reactConfig } from '@gib/eslint-config/react';
|
||||
|
||||
export default defineConfig(
|
||||
{
|
||||
ignores: ['.next/**'],
|
||||
},
|
||||
baseConfig,
|
||||
reactConfig,
|
||||
nextjsConfig,
|
||||
restrictEnvAccess,
|
||||
);
|
||||
@@ -1,57 +0,0 @@
|
||||
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: ['@gib/backend', '@gib/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);
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"name": "@gib/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",
|
||||
"@gib/backend": "workspace:*",
|
||||
"@gib/ui": "workspace:*",
|
||||
"@sentry/nextjs": "^10.46.0",
|
||||
"@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": {
|
||||
"@gib/eslint-config": "workspace:*",
|
||||
"@gib/prettier-config": "workspace:*",
|
||||
"@gib/tailwind-config": "workspace:*",
|
||||
"@gib/tsconfig": "workspace:*",
|
||||
"@gib/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": "@gib/prettier-config"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from '@gib/tailwind-config/postcss-config';
|
||||
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 845 KiB |
@@ -1,12 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -1,12 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -1,12 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 948 B |
@@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 948 B |
@@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 942 B |
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
@@ -1,300 +0,0 @@
|
||||
'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 '@gib/backend/types';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
SubmitButton,
|
||||
} from '@gib/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;
|
||||
@@ -1,25 +0,0 @@
|
||||
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;
|
||||
@@ -1,51 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import {
|
||||
AvatarUpload,
|
||||
ProfileHeader,
|
||||
ResetPasswordForm,
|
||||
SignOutForm,
|
||||
UserInfoForm,
|
||||
} from '@/components/layout/auth/profile';
|
||||
import { preloadQuery } from 'convex/nextjs';
|
||||
|
||||
import { api } from '@gib/backend/convex/_generated/api.js';
|
||||
import { Card, Separator } from '@gib/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;
|
||||
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
@@ -1,460 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { GibsAuthSignInButton } 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 '@gib/backend/types';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
Separator,
|
||||
SubmitButton,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@gib/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('/'));
|
||||
} 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('/'));
|
||||
} 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'>
|
||||
<GibsAuthSignInButton />
|
||||
</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'>
|
||||
<GibsAuthSignInButton type='signUp' />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default SignIn;
|
||||
@@ -1,82 +0,0 @@
|
||||
'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 '@gib/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;
|
||||
@@ -1,70 +0,0 @@
|
||||
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 '@gib/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;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { CTA, Features, Hero, TechStack } from '@/components/landing';
|
||||
|
||||
const Home = () => (
|
||||
<main className='flex min-h-screen flex-col'>
|
||||
<Hero />
|
||||
<Features />
|
||||
<TechStack />
|
||||
<CTA />
|
||||
</main>
|
||||
);
|
||||
|
||||
export default Home;
|
||||
@@ -1,30 +0,0 @@
|
||||
export const CTA = () => (
|
||||
<section className='container mx-auto px-4 py-24'>
|
||||
<div className='mx-auto max-w-4xl'>
|
||||
<div className='border-border/40 from-muted/50 to-muted/30 rounded-2xl border bg-linear-to-br p-8 text-center md:p-12'>
|
||||
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
|
||||
Ready to Build Something Amazing?
|
||||
</h2>
|
||||
<p className='text-muted-foreground mb-8 text-lg'>
|
||||
Clone the repository and start building your next project with
|
||||
everything pre-configured.
|
||||
</p>
|
||||
|
||||
<div className='mt-12'>
|
||||
<p className='text-muted-foreground mb-3 text-sm font-medium'>
|
||||
Quick Start
|
||||
</p>
|
||||
<div className='border-border/40 bg-background mx-auto max-w-2xl rounded-lg border p-4'>
|
||||
<code className='text-sm'>
|
||||
git clone https://git.gbrown.org/gib/convex-monorepo.git
|
||||
<br />
|
||||
cd convex-monorepo
|
||||
<br />
|
||||
bun i
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@gib/ui';
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'Turborepo',
|
||||
description:
|
||||
'Efficient build system with intelligent caching. Share code between web and mobile apps seamlessly.',
|
||||
icon: '⚡',
|
||||
},
|
||||
{
|
||||
title: 'Self-Hosted Convex',
|
||||
description:
|
||||
'Complete control over your data with self-hosted Convex backend. No vendor lock-in, deploy anywhere.',
|
||||
icon: '🏠',
|
||||
},
|
||||
{
|
||||
title: 'Next.js 16 + Expo',
|
||||
description:
|
||||
'Modern Next.js 16 with App Router for web, Expo 54 for mobile. One codebase, multiple platforms.',
|
||||
icon: '📱',
|
||||
},
|
||||
{
|
||||
title: 'Type-Safe Backend',
|
||||
description:
|
||||
'Fully type-safe queries and mutations with Convex. Auto-generated TypeScript types for the entire API.',
|
||||
icon: '🔒',
|
||||
},
|
||||
{
|
||||
title: 'Authentication Included',
|
||||
description:
|
||||
'OAuth with Authentik + custom password auth with email verification. Production-ready auth out of the box.',
|
||||
icon: '🔐',
|
||||
},
|
||||
{
|
||||
title: 'Real-time Updates',
|
||||
description:
|
||||
'Built-in real-time subscriptions with Convex reactive queries. No WebSocket configuration needed.',
|
||||
icon: '⚡',
|
||||
},
|
||||
{
|
||||
title: 'shadcn/ui Components',
|
||||
description:
|
||||
'Beautiful, accessible components from shadcn/ui. Customizable with Tailwind CSS v4.',
|
||||
icon: '🎨',
|
||||
},
|
||||
{
|
||||
title: 'Docker Ready',
|
||||
description:
|
||||
'Production Docker setup included. Deploy to any server with docker-compose up.',
|
||||
icon: '🐳',
|
||||
},
|
||||
{
|
||||
title: 'Developer Experience',
|
||||
description:
|
||||
'Hot reload, TypeScript strict mode, ESLint, Prettier, and Bun for blazing fast installs.',
|
||||
icon: '⚙️',
|
||||
},
|
||||
];
|
||||
|
||||
export const Features = () => (
|
||||
<section id='features' 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'>
|
||||
Everything You Need to Ship Fast
|
||||
</h2>
|
||||
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
|
||||
A complete monorepo template with all the tools and patterns you need
|
||||
for production-ready applications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 md:grid-cols-2 lg:grid-cols-3'>
|
||||
{features.map((feature) => (
|
||||
<Card key={feature.title} className='border-border/40'>
|
||||
<CardHeader className='flex items-center gap-2'>
|
||||
<div className='mb-2 text-3xl'>{feature.icon}</div>
|
||||
<CardTitle className='text-xl'>{feature.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-muted-foreground'>{feature.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Kanit } from 'next/font/google';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@gib/ui';
|
||||
|
||||
const kanitSans = Kanit({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
const highlights = ['TypeScript', 'Self-Hosted', 'Real-time', 'Auth Included'];
|
||||
|
||||
export const Hero = () => (
|
||||
<section className='container mx-auto px-4 py-24 md:py-32 lg:py-40'>
|
||||
<div className='mx-auto flex max-w-5xl flex-col items-center gap-8 text-center'>
|
||||
<div className='border-border/40 bg-muted/50 inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium'>
|
||||
<span className='mr-2'>🚀</span>
|
||||
<span>Production-ready monorepo template</span>
|
||||
</div>
|
||||
|
||||
<h1 className='from-foreground to-foreground/70 bg-linear-to-br bg-clip-text text-4xl font-bold tracking-tight text-transparent sm:text-5xl md:text-6xl lg:text-7xl'>
|
||||
Build Full-Stack Apps with{' '}
|
||||
<span
|
||||
className={`${kanitSans.className} to-accent-foreground bg-linear-to-r from-[#281A65] via-[#363354] bg-clip-text text-transparent sm:text-6xl lg:text-7xl xl:text-8xl dark:from-[#bec8e6] dark:via-[#F0EEE4] dark:to-[#FFF8E7]`}
|
||||
>
|
||||
convex monorepo
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className='text-muted-foreground max-w-2xl text-lg md:text-xl'>
|
||||
A Turborepo starter with Next.js, Expo, and self-hosted Convex. Ship web
|
||||
and mobile apps faster with shared code, type-safe backend, and complete
|
||||
control over your infrastructure.
|
||||
</p>
|
||||
|
||||
<div className='flex flex-col gap-3 sm:flex-row'>
|
||||
<Button size='lg' variant='outline' asChild>
|
||||
<Link
|
||||
href='https://git.gbrown.org/gib/convex-monorepo'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Image
|
||||
src='/misc/gitea/gitea.svg'
|
||||
alt='Gitea'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
View Source Code
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='text-muted-foreground mt-8 flex flex-wrap items-center justify-center gap-6 text-sm'>
|
||||
{highlights.map((highlight) => (
|
||||
<div key={highlight} className='flex items-center gap-2'>
|
||||
<svg
|
||||
className='h-5 w-5 text-green-500'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M5 13l4 4L19 7'
|
||||
/>
|
||||
</svg>
|
||||
<span>{highlight}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -1,4 +0,0 @@
|
||||
export { Hero } from './hero';
|
||||
export { Features } from './features';
|
||||
export { TechStack } from './tech-stack';
|
||||
export { CTA } from './cta';
|
||||
@@ -1,75 +0,0 @@
|
||||
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>
|
||||
);
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import type { ComponentProps } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
|
||||
import type { buttonVariants } from '@gib/ui';
|
||||
import { Button } from '@gib/ui';
|
||||
|
||||
interface Props {
|
||||
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
type?: 'signIn' | 'signUp';
|
||||
}
|
||||
|
||||
export const GibsAuthSignInButton = ({
|
||||
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 space-x-1'>
|
||||
<Image
|
||||
src={'/misc/auth/gibs-auth-logo.png'}
|
||||
className=''
|
||||
alt="Gib's Auth"
|
||||
width={30}
|
||||
height={30}
|
||||
/>
|
||||
<p>{type === 'signIn' ? 'Sign In' : 'Sign Up'} with Gib's Auth</p>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { GibsAuthSignInButton } from './gibs-auth';
|
||||
@@ -1,218 +0,0 @@
|
||||
'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 '@gib/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@gib/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
BasedAvatar,
|
||||
Button,
|
||||
CardContent,
|
||||
ImageCrop,
|
||||
ImageCropApply,
|
||||
ImageCropContent,
|
||||
Input,
|
||||
} from '@gib/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>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { CardDescription, CardHeader, CardTitle } from '@gib/ui';
|
||||
|
||||
const ProfileHeader = () => {
|
||||
return (
|
||||
<CardHeader>
|
||||
<CardTitle className='text-xl'>Account Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Update your profile information and manage your account preferences
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export { ProfileHeader };
|
||||
@@ -1,5 +0,0 @@
|
||||
export { AvatarUpload } from './avatar-upload';
|
||||
export { ProfileHeader } from './header';
|
||||
export { ResetPasswordForm } from './reset-password';
|
||||
export { SignOutForm } from './sign-out';
|
||||
export { UserInfoForm } from './user-info';
|
||||
@@ -1,191 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { Preloaded } from 'convex/react';
|
||||
import { useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAction, usePreloadedQuery } from 'convex/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { api } from '@gib/backend/convex/_generated/api.js';
|
||||
import { PASSWORD_MAX, PASSWORD_MIN, PASSWORD_REGEX } from '@gib/backend/types';
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Separator,
|
||||
SubmitButton,
|
||||
} from '@gib/ui';
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().regex(PASSWORD_REGEX, {
|
||||
message: 'Incorrect current password. Does not meet requirements.',
|
||||
}),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(PASSWORD_MIN, {
|
||||
message: 'New password must be at least 8 characters.',
|
||||
})
|
||||
.max(PASSWORD_MAX, {
|
||||
message: 'New password must be less than 100 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(),
|
||||
})
|
||||
.refine((data) => data.currentPassword !== data.newPassword, {
|
||||
message: 'New password must be different from current password.',
|
||||
path: ['newPassword'],
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: 'Passwords do not match.',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
interface ResetFormProps {
|
||||
preloadedProvider: Preloaded<typeof api.auth.getUserProvider>;
|
||||
}
|
||||
|
||||
export const ResetPasswordForm = ({ preloadedProvider }: ResetFormProps) => {
|
||||
const userProvider = usePreloadedQuery(preloadedProvider);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const changePassword = useAction(api.auth.updateUserPassword);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await changePassword({
|
||||
currentPassword: values.currentPassword,
|
||||
newPassword: values.newPassword,
|
||||
});
|
||||
if (result.success) {
|
||||
form.reset();
|
||||
toast.success('Password updated successfully.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating password:', error);
|
||||
toast.error('Error updating password.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
// Only show password reset for email/password auth users
|
||||
if (userProvider !== 'email') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
<CardHeader>
|
||||
<CardTitle>Change Password</CardTitle>
|
||||
<CardDescription>
|
||||
Update your password to keep your account secure
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='currentPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
{...field}
|
||||
placeholder='Enter current password'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='newPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
{...field}
|
||||
placeholder='Enter new password'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Must be at least 8 characters with uppercase, lowercase,
|
||||
number, and symbol
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='confirmPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
{...field}
|
||||
placeholder='Confirm new password'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className='flex justify-end pt-2'>
|
||||
<SubmitButton disabled={loading} pendingText='Updating...'>
|
||||
Update Password
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { LogOut } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@gib/ui';
|
||||
|
||||
export const SignOutForm = () => {
|
||||
const { signOut } = useAuthActions();
|
||||
const router = useRouter();
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setIsSigningOut(true);
|
||||
try {
|
||||
await signOut();
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
console.error('Sign out error:', error);
|
||||
setIsSigningOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle>Sign Out</CardTitle>
|
||||
<CardDescription>
|
||||
End your current session and return to the home page
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant='destructive'
|
||||
className='w-full'
|
||||
onClick={handleSignOut}
|
||||
disabled={isSigningOut}
|
||||
>
|
||||
<LogOut className='mr-2 h-4 w-4' />
|
||||
{isSigningOut ? 'Signing Out...' : 'Sign Out'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,171 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { Preloaded } from 'convex/react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, usePreloadedQuery } from 'convex/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { api } from '@gib/backend/convex/_generated/api.js';
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
SubmitButton,
|
||||
} from '@gib/ui';
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(5, {
|
||||
message: 'Full name is required & must be at least 5 characters.',
|
||||
})
|
||||
.max(50, {
|
||||
message: 'Full name must be less than 50 characters.',
|
||||
}),
|
||||
email: z.email({
|
||||
message: 'Please enter a valid email address.',
|
||||
}),
|
||||
});
|
||||
|
||||
interface UserInfoFormProps {
|
||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||
preloadedProvider: Preloaded<typeof api.auth.getUserProvider>;
|
||||
}
|
||||
|
||||
export const UserInfoForm = ({
|
||||
preloadedUser,
|
||||
preloadedProvider,
|
||||
}: UserInfoFormProps) => {
|
||||
const user = usePreloadedQuery(preloadedUser);
|
||||
const userProvider = usePreloadedQuery(preloadedProvider);
|
||||
const providerMap: Record<string, string> = {
|
||||
unknown: 'Provider',
|
||||
authentik: "Gib's Auth",
|
||||
};
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const updateUser = useMutation(api.auth.updateUser);
|
||||
|
||||
const initialValues = useMemo<z.infer<typeof formSchema>>(
|
||||
() => ({
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
}),
|
||||
[user?.name, user?.email],
|
||||
);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: initialValues,
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
if (!user) {
|
||||
toast.error('User not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = values.name.trim();
|
||||
const email = values.email.trim().toLowerCase();
|
||||
const patch: Partial<{
|
||||
name: string;
|
||||
email: string;
|
||||
}> = {};
|
||||
if (name !== (user.name ?? '')) patch.name = name;
|
||||
if (email !== (user.email ?? '')) patch.email = email;
|
||||
if (Object.keys(patch).length === 0) {
|
||||
toast.info('No changes to save.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateUser(patch);
|
||||
form.reset(patch);
|
||||
toast.success('Profile updated successfully.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Error updating profile.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Information</CardTitle>
|
||||
<CardDescription>Update your name and email address</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder='John Doe' />
|
||||
</FormControl>
|
||||
<FormDescription>Your public display name</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type='email'
|
||||
placeholder='john@example.com'
|
||||
disabled={userProvider !== 'password'}
|
||||
/>
|
||||
</FormControl>
|
||||
{userProvider === 'password' ? (
|
||||
<FormDescription>
|
||||
Your email address for account notifications
|
||||
</FormDescription>
|
||||
) : (
|
||||
<FormDescription>
|
||||
Email is managed through your{' '}
|
||||
{providerMap[userProvider ?? 'unknown']} account
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className='flex justify-end pt-2'>
|
||||
<SubmitButton disabled={loading} pendingText='Saving...'>
|
||||
Save Changes
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||