Initial commit
This commit is contained in:
873
.agents/skills/building-with-medusa/reference/api-routes.md
Normal file
873
.agents/skills/building-with-medusa/reference/api-routes.md
Normal file
@@ -0,0 +1,873 @@
|
||||
# Custom API Routes
|
||||
|
||||
API routes (also called "endpoints") are the primary way to expose custom functionality to storefronts and admin dashboards.
|
||||
|
||||
## Contents
|
||||
- [Path Conventions](#path-conventions)
|
||||
- [Middleware Validation](#middleware-validation)
|
||||
- [Query Parameter Validation](#query-parameter-validation)
|
||||
- [Request Query Config for List Endpoints](#request-query-config-for-list-endpoints)
|
||||
- [API Route Structure](#api-route-structure)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Protected Routes](#protected-routes)
|
||||
- [Using Workflows in API Routes](#using-workflows-in-api-routes)
|
||||
|
||||
## Path Conventions
|
||||
|
||||
### Store API Routes (Storefront)
|
||||
- **Path prefix**: `/store/<rest-of-path>`
|
||||
- **Examples**: `/store/newsletter-signup`, `/store/custom-search`
|
||||
- **Authentication**: SDK automatically includes publishable API key
|
||||
|
||||
### Admin API Routes (Dashboard)
|
||||
- **Path prefix**: `/admin/<rest-of-path>`
|
||||
- **Examples**: `/admin/custom-reports`, `/admin/bulk-operations`
|
||||
- **Authentication**: SDK automatically includes auth headers (bearer/session)
|
||||
|
||||
**Detailed authentication patterns**: See [authentication.md](authentication.md)
|
||||
|
||||
## Middleware Validation
|
||||
|
||||
**⚠️ CRITICAL**: Always validate request bodies using Zod schemas and the `validateAndTransformBody` middleware.
|
||||
|
||||
### Combining Multiple Middlewares
|
||||
|
||||
When you need both authentication AND validation, pass them as an array. **NEVER nest validation inside authenticate:**
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Multiple middlewares in array
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
{
|
||||
matcher: "/store/products/:id/reviews",
|
||||
method: "POST",
|
||||
middlewares: [
|
||||
authenticate("customer", ["session", "bearer"]),
|
||||
validateAndTransformBody(CreateReviewSchema)
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// ❌ WRONG - Don't nest validator inside authenticate
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
{
|
||||
matcher: "/store/products/:id/reviews",
|
||||
method: "POST",
|
||||
middlewares: [authenticate("customer", ["session", "bearer"], {
|
||||
validator: CreateReviewSchema // This doesn't work!
|
||||
})],
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
**Middleware order matters:** Put `authenticate` before `validateAndTransformBody` so authentication happens first.
|
||||
|
||||
### Step 1: Create Middleware File
|
||||
|
||||
```typescript
|
||||
// api/store/[feature]/middlewares.ts
|
||||
import { MiddlewareRoute, validateAndTransformBody } from "@medusajs/framework"
|
||||
import { z } from "zod"
|
||||
|
||||
export const CreateMySchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(2),
|
||||
// other fields
|
||||
})
|
||||
|
||||
// Export the inferred type for use in route handlers
|
||||
export type CreateMySchema = z.infer<typeof CreateMySchema>
|
||||
|
||||
export const myMiddlewares: MiddlewareRoute[] = [
|
||||
{
|
||||
matcher: "/store/my-route",
|
||||
method: "POST",
|
||||
middlewares: [validateAndTransformBody(CreateMySchema)],
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
### Step 2: Register in api/middlewares.ts
|
||||
|
||||
```typescript
|
||||
// api/middlewares.ts
|
||||
import { defineMiddlewares } from "@medusajs/framework/http"
|
||||
import { myMiddlewares } from "./store/[feature]/middlewares"
|
||||
|
||||
export default defineMiddlewares({
|
||||
routes: [...myMiddlewares],
|
||||
})
|
||||
```
|
||||
|
||||
**⚠️ CRITICAL - Middleware Export Pattern:**
|
||||
|
||||
Middlewares are exported as **named arrays**, NOT default exports with config objects:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Named export of MiddlewareRoute array
|
||||
// api/store/reviews/middlewares.ts
|
||||
export const reviewMiddlewares: MiddlewareRoute[] = [
|
||||
{
|
||||
matcher: "/store/reviews",
|
||||
method: "POST",
|
||||
middlewares: [validateAndTransformBody(CreateReviewSchema)],
|
||||
},
|
||||
]
|
||||
|
||||
// ✅ CORRECT - Import and spread the named array
|
||||
// api/middlewares.ts
|
||||
import { reviewMiddlewares } from "./store/reviews/middlewares"
|
||||
|
||||
export default defineMiddlewares({
|
||||
routes: [...reviewMiddlewares],
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Don't use default export with .config
|
||||
// api/store/reviews/middlewares.ts
|
||||
export default {
|
||||
config: {
|
||||
routes: [...], // This is NOT the middleware pattern!
|
||||
},
|
||||
}
|
||||
|
||||
// ❌ WRONG - Don't access .config.routes
|
||||
// api/middlewares.ts
|
||||
import reviewMiddlewares from "./store/reviews/middlewares"
|
||||
export default defineMiddlewares({
|
||||
routes: [...reviewMiddlewares.config.routes], // This doesn't work!
|
||||
})
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
- Middleware files export arrays directly, not config objects
|
||||
- Route files (like `route.ts`) use `export const config = defineRouteConfig(...)`
|
||||
- Don't confuse the two patterns - middlewares are simpler (just an array)
|
||||
|
||||
### Step 3: Use Typed req.validatedBody in Route
|
||||
|
||||
**⚠️ CRITICAL**: When using `req.validatedBody`, you MUST pass the inferred Zod schema type as a type argument to `MedusaRequest`. Otherwise, you'll get TypeScript errors when accessing `req.validatedBody`.
|
||||
|
||||
```typescript
|
||||
// api/store/my-route/route.ts
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
import { CreateMySchema } from "./middlewares"
|
||||
|
||||
// ✅ CORRECT: Pass the Zod schema type as type argument
|
||||
export async function POST(
|
||||
req: MedusaRequest<CreateMySchema>,
|
||||
res: MedusaResponse
|
||||
) {
|
||||
// Now req.validatedBody is properly typed
|
||||
const { email, name } = req.validatedBody
|
||||
|
||||
// ... rest of implementation
|
||||
}
|
||||
|
||||
// ❌ WRONG: Without type argument, req.validatedBody will have type errors
|
||||
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
||||
const { email, name } = req.validatedBody // Type error!
|
||||
}
|
||||
```
|
||||
|
||||
## Query Parameter Validation
|
||||
|
||||
For API routes that accept query parameters, use the `validateAndTransformQuery` middleware to validate them.
|
||||
|
||||
**⚠️ IMPORTANT**: When using `validateAndTransformQuery`, access query parameters via `req.validatedQuery` instead of `req.query`.
|
||||
|
||||
### Step 1: Create Validation Schema
|
||||
|
||||
Create a Zod schema for the query parameters. Since query parameters are originally strings or arrays of strings, use `z.preprocess` to transform them to other types:
|
||||
|
||||
```typescript
|
||||
// api/custom/validators.ts
|
||||
import { z } from "zod"
|
||||
|
||||
export const GetMyRouteSchema = z.object({
|
||||
cart_id: z.string(), // String parameters don't need preprocessing
|
||||
limit: z.preprocess(
|
||||
(val) => {
|
||||
if (val && typeof val === "string") {
|
||||
return parseInt(val)
|
||||
}
|
||||
return val
|
||||
},
|
||||
z.number().optional()
|
||||
),
|
||||
status: z.enum(["active", "pending", "completed"]).optional(),
|
||||
})
|
||||
```
|
||||
|
||||
### Step 2: Add Middleware
|
||||
|
||||
```typescript
|
||||
// api/middlewares.ts
|
||||
import {
|
||||
validateAndTransformQuery,
|
||||
defineMiddlewares,
|
||||
} from "@medusajs/framework/http"
|
||||
import { GetMyRouteSchema } from "./custom/validators"
|
||||
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
{
|
||||
matcher: "/store/my-route",
|
||||
method: "GET",
|
||||
middlewares: [
|
||||
validateAndTransformQuery(GetMyRouteSchema, {}),
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Step 3: Use Validated Query in Route
|
||||
|
||||
```typescript
|
||||
// api/store/my-route/route.ts
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
// Access validated query parameters (not req.query!)
|
||||
const { cart_id, limit, status } = req.validatedQuery
|
||||
|
||||
// cart_id is string, limit is number, status is enum
|
||||
const query = req.scope.resolve("query")
|
||||
|
||||
const { data } = await query.graph({
|
||||
entity: "my_entity",
|
||||
fields: ["id", "name"],
|
||||
filters: { cart_id, status },
|
||||
})
|
||||
|
||||
return res.json({ items: data })
|
||||
}
|
||||
```
|
||||
|
||||
## Request Query Config for List Endpoints
|
||||
|
||||
**⚠️ BEST PRACTICE**: For API routes that retrieve lists of resources, use request query config to allow clients to control fields, pagination, and ordering.
|
||||
|
||||
This pattern:
|
||||
- Allows clients to specify which fields/relations to retrieve
|
||||
- Enables client-controlled pagination
|
||||
- Supports custom ordering
|
||||
- Provides sensible defaults
|
||||
|
||||
### Step 1: Add Middleware with createFindParams
|
||||
|
||||
```typescript
|
||||
// api/middlewares.ts
|
||||
import {
|
||||
validateAndTransformQuery,
|
||||
defineMiddlewares,
|
||||
} from "@medusajs/framework/http"
|
||||
import { createFindParams } from "@medusajs/medusa/api/utils/validators"
|
||||
|
||||
// createFindParams() generates a schema that accepts:
|
||||
// - fields: Select specific fields/relations
|
||||
// - offset: Skip N items
|
||||
// - limit: Max items to return
|
||||
// - order: Order by field(s) ASC/DESC
|
||||
export const GetProductsSchema = createFindParams()
|
||||
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
{
|
||||
matcher: "/store/products",
|
||||
method: "GET",
|
||||
middlewares: [
|
||||
validateAndTransformQuery(
|
||||
GetProductsSchema,
|
||||
{
|
||||
defaults: [
|
||||
"id",
|
||||
"title",
|
||||
"variants.*", // Include all variant fields by default
|
||||
],
|
||||
isList: true, // Indicates this returns a list
|
||||
defaultLimit: 15, // Default pagination limit
|
||||
}
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
**Configuration Options:**
|
||||
- `defaults`: Array of default fields and relations to retrieve
|
||||
- `isList`: Boolean indicating if this returns a list (affects pagination)
|
||||
- `allowed`: (Optional) Array of fields/relations allowed in the `fields` query param
|
||||
- `defaultLimit`: (Optional) Default limit if not provided (default: 50)
|
||||
|
||||
### Step 2: Use Query Config in Route
|
||||
|
||||
**⚠️ CRITICAL**: When using `req.queryConfig`, do NOT explicitly set the `fields` property in your query. The `queryConfig` already contains the fields configuration, and setting it explicitly will cause TypeScript errors.
|
||||
|
||||
```typescript
|
||||
// api/store/products/route.ts
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const query = req.scope.resolve("query")
|
||||
|
||||
// ✅ CORRECT: Only use ...req.queryConfig (includes fields, pagination, etc.)
|
||||
const { data: products } = await query.graph({
|
||||
entity: "product",
|
||||
...req.queryConfig, // Contains fields, select, limit, offset, order
|
||||
})
|
||||
|
||||
return res.json({ products })
|
||||
}
|
||||
|
||||
// ❌ WRONG: Don't set fields explicitly when using queryConfig
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const query = req.scope.resolve("query")
|
||||
|
||||
const { data: products } = await query.graph({
|
||||
entity: "product",
|
||||
fields: ["id", "title"], // ❌ Type error! queryConfig already sets fields
|
||||
...req.queryConfig,
|
||||
})
|
||||
|
||||
return res.json({ products })
|
||||
}
|
||||
```
|
||||
|
||||
**If you need additional filters**, only add those - not fields:
|
||||
|
||||
```typescript
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const query = req.scope.resolve("query")
|
||||
const { id } = req.params
|
||||
|
||||
// ✅ CORRECT: Add filters while using queryConfig
|
||||
const { data: products } = await query.graph({
|
||||
entity: "product",
|
||||
filters: { id }, // Additional filters are OK
|
||||
...req.queryConfig, // Fields come from here
|
||||
})
|
||||
|
||||
return res.json({ products })
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Client Usage Examples
|
||||
|
||||
Clients can now control the API response:
|
||||
|
||||
```typescript
|
||||
// Default response (uses middleware defaults)
|
||||
GET /store/products
|
||||
// Returns: id, title, variants.*
|
||||
|
||||
// Custom fields selection
|
||||
GET /store/products?fields=id,title,description
|
||||
// Returns: only id, title, description
|
||||
|
||||
// Pagination
|
||||
GET /store/products?limit=10&offset=20
|
||||
// Returns: 10 items, skipping first 20
|
||||
|
||||
// Ordering
|
||||
GET /store/products?order=title
|
||||
// Returns: products ordered by title ascending
|
||||
|
||||
GET /store/products?order=-created_at
|
||||
// Returns: products ordered by created_at descending (- prefix)
|
||||
|
||||
// Combined
|
||||
GET /store/products?fields=id,title,brand.*&limit=5&order=-created_at
|
||||
// Returns: 5 items with custom fields, newest first
|
||||
```
|
||||
|
||||
### Advanced: Custom Query Param + Query Config
|
||||
|
||||
You can combine custom query parameters with query config:
|
||||
|
||||
```typescript
|
||||
// validators.ts
|
||||
import { z } from "zod"
|
||||
import { createFindParams } from "@medusajs/medusa/api/utils/validators"
|
||||
|
||||
export const GetProductsSchema = createFindParams().merge(
|
||||
z.object({
|
||||
category_id: z.string().optional(),
|
||||
in_stock: z.preprocess(
|
||||
(val) => val === "true",
|
||||
z.boolean().optional()
|
||||
),
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
```typescript
|
||||
// route.ts
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const query = req.scope.resolve("query")
|
||||
const { category_id, in_stock } = req.validatedQuery
|
||||
|
||||
const filters: any = {}
|
||||
if (category_id) filters.category_id = category_id
|
||||
if (in_stock !== undefined) filters.in_stock = in_stock
|
||||
|
||||
const { data: products } = await query.graph({
|
||||
entity: "product",
|
||||
filters,
|
||||
...req.queryConfig, // Still get fields, pagination, order
|
||||
})
|
||||
|
||||
return res.json({ products })
|
||||
}
|
||||
```
|
||||
|
||||
## Import Organization
|
||||
|
||||
**⚠️ CRITICAL**: Always import workflows, modules, and other dependencies at the TOP of the file, never inside the route handler function body.
|
||||
|
||||
### ✅ CORRECT - Imports at Top
|
||||
|
||||
```typescript
|
||||
// api/store/reviews/route.ts
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
import { createReviewWorkflow } from "../../../workflows/create-review"
|
||||
import { CreateReviewSchema } from "./middlewares"
|
||||
|
||||
export async function POST(
|
||||
req: MedusaRequest<CreateReviewSchema>,
|
||||
res: MedusaResponse
|
||||
) {
|
||||
const { result } = await createReviewWorkflow(req.scope).run({
|
||||
input: req.validatedBody
|
||||
})
|
||||
|
||||
return res.json({ review: result })
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ WRONG - Dynamic Imports in Route Body
|
||||
|
||||
```typescript
|
||||
// api/store/reviews/route.ts
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
|
||||
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
||||
// ❌ WRONG: Don't use dynamic imports in route handlers
|
||||
const { createReviewWorkflow } = await import("../../../workflows/create-review")
|
||||
|
||||
const { result } = await createReviewWorkflow(req.scope).run({
|
||||
input: req.validatedBody
|
||||
})
|
||||
|
||||
return res.json({ review: result })
|
||||
}
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
- Dynamic imports add unnecessary overhead to every request
|
||||
- Makes code harder to read and maintain
|
||||
- Breaks static analysis and TypeScript checking
|
||||
- Can cause module resolution issues in production
|
||||
|
||||
## API Route Structure
|
||||
|
||||
**⚠️ IMPORTANT**: Medusa uses only GET, POST and DELETE as a convention.
|
||||
- **GET** for reads
|
||||
- **POST** for mutations (create/update)
|
||||
- **DELETE** for deletions
|
||||
|
||||
Don't use PUT or PATCH.
|
||||
|
||||
### Basic API Route
|
||||
|
||||
```typescript
|
||||
// api/store/my-route/route.ts
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
import { MedusaError } from "@medusajs/framework/utils"
|
||||
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const query = req.scope.resolve("query")
|
||||
|
||||
// Query data
|
||||
const { data: items } = await query.graph({
|
||||
entity: "entity_name",
|
||||
fields: ["id", "name"],
|
||||
})
|
||||
|
||||
return res.status(200).json({ items })
|
||||
}
|
||||
|
||||
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
||||
const { field } = req.validatedBody
|
||||
|
||||
// Execute workflow (mutations should always use workflows)
|
||||
const { result } = await myWorkflow(req.scope).run({
|
||||
input: { field },
|
||||
})
|
||||
|
||||
return res.status(200).json({ result })
|
||||
}
|
||||
```
|
||||
|
||||
### Accessing Request Data
|
||||
|
||||
```typescript
|
||||
// Validated body (from middleware)
|
||||
const { email, name } = req.validatedBody
|
||||
|
||||
// Query parameters
|
||||
const { page, limit } = req.query
|
||||
|
||||
// Route parameters
|
||||
const { id } = req.params
|
||||
|
||||
// Resolve services
|
||||
const query = req.scope.resolve("query")
|
||||
const myService = req.scope.resolve("my-module")
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Use `MedusaError` for consistent error responses:
|
||||
|
||||
```typescript
|
||||
import { MedusaError } from "@medusajs/framework/utils"
|
||||
|
||||
// Not found
|
||||
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Resource not found")
|
||||
|
||||
// Invalid data
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, "Invalid input provided")
|
||||
|
||||
// Unauthorized
|
||||
throw new MedusaError(MedusaError.Types.UNAUTHORIZED, "Authentication required")
|
||||
|
||||
// Conflict
|
||||
throw new MedusaError(MedusaError.Types.CONFLICT, "Resource already exists")
|
||||
|
||||
// Other types: INVALID_STATE, NOT_ALLOWED, DUPLICATE_ERROR
|
||||
```
|
||||
|
||||
### Error Response Format
|
||||
|
||||
Medusa automatically formats errors:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "not_found",
|
||||
"message": "Resource not found"
|
||||
}
|
||||
```
|
||||
|
||||
## Protected Routes
|
||||
|
||||
### Default Protected Routes
|
||||
|
||||
All routes under these prefixes are automatically protected:
|
||||
- `/admin/*` - Requires authenticated admin user
|
||||
- `/store/customers/me/*` - Requires authenticated customer
|
||||
|
||||
### Custom Protected Routes
|
||||
|
||||
To protect routes under different prefixes, use the `authenticate` middleware:
|
||||
|
||||
```typescript
|
||||
// api/middlewares.ts
|
||||
import {
|
||||
defineMiddlewares,
|
||||
authenticate,
|
||||
} from "@medusajs/framework/http"
|
||||
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
// Only allow authenticated admin users
|
||||
{
|
||||
matcher: "/custom/admin*",
|
||||
middlewares: [authenticate("user", ["session", "bearer", "api-key"])],
|
||||
},
|
||||
// Only allow authenticated customers
|
||||
{
|
||||
matcher: "/store/reviews*",
|
||||
middlewares: [authenticate("customer", ["session", "bearer"])],
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Accessing Authenticated User
|
||||
|
||||
**⚠️ CRITICAL**: For routes protected with `authenticate` middleware, you MUST use `AuthenticatedMedusaRequest` instead of `MedusaRequest` to avoid type errors when accessing `req.auth_context.actor_id`.
|
||||
|
||||
```typescript
|
||||
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
|
||||
// ✅ CORRECT - Use AuthenticatedMedusaRequest for protected routes
|
||||
export async function POST(
|
||||
req: AuthenticatedMedusaRequest,
|
||||
res: MedusaResponse
|
||||
) {
|
||||
// For admin routes
|
||||
const userId = req.auth_context.actor_id // Admin user ID
|
||||
|
||||
// For customer routes
|
||||
const customerId = req.auth_context.actor_id // Customer ID
|
||||
|
||||
// Your logic here
|
||||
}
|
||||
|
||||
// ❌ WRONG - Don't use MedusaRequest for protected routes
|
||||
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
||||
const userId = req.auth_context.actor_id // Type error!
|
||||
}
|
||||
```
|
||||
|
||||
**See [authentication.md](authentication.md) for complete authentication patterns.**
|
||||
|
||||
## Using Workflows in API Routes
|
||||
|
||||
**⚠️ BEST PRACTICE**: Workflows are the standard way to perform mutations (create, update, delete) in Medusa. API routes should execute workflows and return their response.
|
||||
|
||||
### Example: Create Workflow
|
||||
|
||||
```typescript
|
||||
import { createCustomersWorkflow } from "@medusajs/medusa/core-flows"
|
||||
|
||||
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
||||
const { email } = req.validatedBody
|
||||
|
||||
const { result } = await createCustomersWorkflow(req.scope).run({
|
||||
input: {
|
||||
customersData: [
|
||||
{
|
||||
email,
|
||||
has_account: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
return res.json({ customer: result[0] })
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Custom Workflow
|
||||
|
||||
```typescript
|
||||
import { myCustomWorkflow } from "../../workflows/my-workflow"
|
||||
|
||||
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
||||
const { data } = req.validatedBody
|
||||
|
||||
try {
|
||||
const { result } = await myCustomWorkflow(req.scope).run({
|
||||
input: { data },
|
||||
})
|
||||
|
||||
return res.json({ result })
|
||||
} catch (error) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
error.message
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Built-in Workflows
|
||||
|
||||
Ask MedusaDocs for specific workflow names and their input parameters:
|
||||
- Customer workflows: create, update, delete customers
|
||||
- Product workflows: create, update, delete products
|
||||
- Order workflows: create, cancel, fulfill orders
|
||||
- Cart workflows: create, update, complete carts
|
||||
- And many more...
|
||||
|
||||
## API Route Organization
|
||||
|
||||
Organize routes by feature or domain:
|
||||
|
||||
```
|
||||
src/api/
|
||||
├── admin/
|
||||
│ ├── custom-reports/
|
||||
│ │ ├── route.ts
|
||||
│ │ └── middlewares.ts
|
||||
│ └── bulk-operations/
|
||||
│ ├── route.ts
|
||||
│ └── middlewares.ts
|
||||
└── store/
|
||||
├── newsletter/
|
||||
│ ├── route.ts
|
||||
│ └── middlewares.ts
|
||||
└── reviews/
|
||||
├── route.ts
|
||||
├── [id]/
|
||||
│ └── route.ts
|
||||
└── middlewares.ts
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: List with Query Config (Recommended)
|
||||
|
||||
```typescript
|
||||
// middlewares.ts
|
||||
import { validateAndTransformQuery } from "@medusajs/framework/http"
|
||||
import { createFindParams } from "@medusajs/medusa/api/utils/validators"
|
||||
|
||||
export const GetMyEntitiesSchema = createFindParams()
|
||||
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
{
|
||||
matcher: "/store/my-entities",
|
||||
method: "GET",
|
||||
middlewares: [
|
||||
validateAndTransformQuery(GetMyEntitiesSchema, {
|
||||
defaults: ["id", "name", "created_at"],
|
||||
isList: true,
|
||||
defaultLimit: 15,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// route.ts
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const query = req.scope.resolve("query")
|
||||
|
||||
const { data, metadata } = await query.graph({
|
||||
entity: "my_entity",
|
||||
...req.queryConfig, // Handles fields, pagination automatically
|
||||
})
|
||||
|
||||
return res.json({
|
||||
items: data,
|
||||
count: metadata.count,
|
||||
limit: req.queryConfig.pagination.take,
|
||||
offset: req.queryConfig.pagination.skip,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Retrieve Single Resource with Relations
|
||||
|
||||
```typescript
|
||||
// For single resource endpoints, you can still use query config
|
||||
// middlewares.ts
|
||||
export const GetMyEntitySchema = createFindParams()
|
||||
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
{
|
||||
matcher: "/store/my-entities/:id",
|
||||
method: "GET",
|
||||
middlewares: [
|
||||
validateAndTransformQuery(GetMyEntitySchema, {
|
||||
defaults: ["id", "name", "variants.*", "brand.*"],
|
||||
isList: false, // Single resource
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// route.ts
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const query = req.scope.resolve("query")
|
||||
const { id } = req.params
|
||||
|
||||
const { data } = await query.graph({
|
||||
entity: "my_entity",
|
||||
filters: { id },
|
||||
...req.queryConfig,
|
||||
})
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Resource not found")
|
||||
}
|
||||
|
||||
return res.json({ item: data[0] })
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Search with Custom Filters + Query Config
|
||||
|
||||
```typescript
|
||||
// validators.ts
|
||||
export const GetMyEntitiesSchema = createFindParams().merge(
|
||||
z.object({
|
||||
q: z.string().optional(), // Search query
|
||||
status: z.enum(["active", "pending", "completed"]).optional(),
|
||||
})
|
||||
)
|
||||
|
||||
// middlewares.ts
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
{
|
||||
matcher: "/store/my-entities",
|
||||
method: "GET",
|
||||
middlewares: [
|
||||
validateAndTransformQuery(GetMyEntitiesSchema, {
|
||||
defaults: ["id", "name", "status"],
|
||||
isList: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// route.ts
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const query = req.scope.resolve("query")
|
||||
const { q, status } = req.validatedQuery
|
||||
|
||||
const filters: any = {}
|
||||
|
||||
if (q) {
|
||||
filters.name = { $like: `%${q}%` }
|
||||
}
|
||||
|
||||
if (status) {
|
||||
filters.status = status
|
||||
}
|
||||
|
||||
const { data } = await query.graph({
|
||||
entity: "my_entity",
|
||||
filters,
|
||||
...req.queryConfig, // Client can still control fields, pagination
|
||||
})
|
||||
|
||||
return res.json({ items: data })
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Manual Query (When Query Config Not Needed)
|
||||
|
||||
For simple queries where you don't need client-controlled fields/pagination:
|
||||
|
||||
```typescript
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const query = req.scope.resolve("query")
|
||||
|
||||
const { data } = await query.graph({
|
||||
entity: "my_entity",
|
||||
fields: ["id", "name"],
|
||||
filters: { status: "active" },
|
||||
pagination: {
|
||||
take: 10,
|
||||
skip: 0,
|
||||
},
|
||||
})
|
||||
|
||||
return res.json({ items: data })
|
||||
}
|
||||
```
|
||||
556
.agents/skills/building-with-medusa/reference/authentication.md
Normal file
556
.agents/skills/building-with-medusa/reference/authentication.md
Normal file
@@ -0,0 +1,556 @@
|
||||
# Authentication in Medusa
|
||||
|
||||
Authentication in Medusa secures API routes and ensures only authorized users can access protected resources.
|
||||
|
||||
## Contents
|
||||
- [Default Protected Routes](#default-protected-routes)
|
||||
- [Authentication Methods](#authentication-methods)
|
||||
- [Custom Protected Routes](#custom-protected-routes)
|
||||
- [Accessing Authenticated User](#accessing-authenticated-user)
|
||||
- [Authentication Patterns](#authentication-patterns)
|
||||
|
||||
## Default Protected Routes
|
||||
|
||||
Medusa automatically protects certain route prefixes:
|
||||
|
||||
### Admin Routes (`/admin/*`)
|
||||
- **Who can access**: Authenticated admin users only
|
||||
- **Authentication methods**: Session, Bearer token, API key
|
||||
- **Example**: `/admin/products`, `/admin/custom-reports`
|
||||
|
||||
### Customer Routes (`/store/customers/me/*`)
|
||||
- **Who can access**: Authenticated customers only
|
||||
- **Authentication methods**: Session, Bearer token
|
||||
- **Example**: `/store/customers/me/orders`, `/store/customers/me/addresses`
|
||||
|
||||
**These routes require no additional configuration** - authentication is handled automatically by Medusa.
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
### Session Authentication
|
||||
- Used after login via email/password
|
||||
- Cookie-based session management
|
||||
- Automatically handled by Medusa SDK
|
||||
|
||||
### Bearer Token (JWT)
|
||||
- Token-based authentication
|
||||
- Passed in `Authorization: Bearer <token>` header
|
||||
- Used by frontend applications
|
||||
|
||||
### API Key
|
||||
- Admin-only authentication method
|
||||
- Used for server-to-server communication
|
||||
- Passed in `x-medusa-access-token` header
|
||||
|
||||
## Custom Protected Routes
|
||||
|
||||
**⚠️ CRITICAL: Only add `authenticate` middleware to routes OUTSIDE the default prefixes.**
|
||||
|
||||
Routes with these prefixes are automatically authenticated - **do NOT add middleware:**
|
||||
- `/admin/*` - Already requires authenticated admin user
|
||||
- `/store/customers/me/*` - Already requires authenticated customer
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Custom route needs authenticate middleware
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
{
|
||||
matcher: "/store/reviews*", // Not a default protected prefix
|
||||
middlewares: [authenticate("customer", ["session", "bearer"])],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// ❌ WRONG - /admin routes are automatically authenticated
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
{
|
||||
matcher: "/admin/reports*", // Already protected!
|
||||
middlewares: [authenticate("user", ["session", "bearer"])], // Redundant!
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
To protect custom routes outside the default prefixes, use the `authenticate` middleware.
|
||||
|
||||
### Protecting Custom Admin Routes
|
||||
|
||||
```typescript
|
||||
// api/middlewares.ts
|
||||
import {
|
||||
defineMiddlewares,
|
||||
authenticate,
|
||||
} from "@medusajs/framework/http"
|
||||
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
{
|
||||
matcher: "/custom/admin*",
|
||||
middlewares: [
|
||||
authenticate("user", ["session", "bearer", "api-key"])
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- First parameter: `"user"` for admin users, `"customer"` for customers
|
||||
- Second parameter: Array of allowed authentication methods
|
||||
|
||||
### Protecting Custom Customer Routes
|
||||
|
||||
```typescript
|
||||
// api/middlewares.ts
|
||||
import {
|
||||
defineMiddlewares,
|
||||
authenticate,
|
||||
} from "@medusajs/framework/http"
|
||||
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
{
|
||||
matcher: "/store/reviews*",
|
||||
middlewares: [
|
||||
authenticate("customer", ["session", "bearer"])
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Multiple Protected Routes
|
||||
|
||||
```typescript
|
||||
// api/middlewares.ts
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
// Protect custom admin routes
|
||||
{
|
||||
matcher: "/custom/admin*",
|
||||
middlewares: [authenticate("user", ["session", "bearer", "api-key"])],
|
||||
},
|
||||
// Protect custom customer routes
|
||||
{
|
||||
matcher: "/store/reviews*",
|
||||
middlewares: [authenticate("customer", ["session", "bearer"])],
|
||||
},
|
||||
// Protect wishlist routes
|
||||
{
|
||||
matcher: "/store/wishlists*",
|
||||
middlewares: [authenticate("customer", ["session", "bearer"])],
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Accessing Authenticated User
|
||||
|
||||
Once a route is protected with the `authenticate` middleware, you can access the authenticated user's information via `req.auth_context`.
|
||||
|
||||
**⚠️ CRITICAL - Type Safety**: For protected routes, you MUST use `AuthenticatedMedusaRequest` instead of `MedusaRequest` to avoid type errors when accessing `req.auth_context.actor_id`.
|
||||
|
||||
**⚠️ CRITICAL - Manual Validation**: Do NOT manually validate authentication in your route handlers when using the `authenticate` middleware. The middleware already ensures the user is authenticated - manual checks are redundant and indicate a misunderstanding of how middleware works.
|
||||
|
||||
### ✅ CORRECT - Using AuthenticatedMedusaRequest
|
||||
|
||||
```typescript
|
||||
// api/store/reviews/[id]/route.ts
|
||||
// Middleware already applied: authenticate("customer", ["session", "bearer"])
|
||||
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
import { deleteReviewWorkflow } from "../../../../workflows/delete-review"
|
||||
|
||||
export async function DELETE(
|
||||
req: AuthenticatedMedusaRequest, // ✅ Use AuthenticatedMedusaRequest for protected routes
|
||||
res: MedusaResponse
|
||||
) {
|
||||
const { id } = req.params
|
||||
// ✅ CORRECT: Just use req.auth_context.actor_id directly
|
||||
// The authenticate middleware guarantees this exists
|
||||
const customerId = req.auth_context.actor_id // No type error!
|
||||
|
||||
// Pass to workflow - let the workflow handle business logic validation
|
||||
const { result } = await deleteReviewWorkflow(req.scope).run({
|
||||
input: {
|
||||
reviewId: id,
|
||||
customerId, // Workflow will validate if review belongs to customer
|
||||
},
|
||||
})
|
||||
|
||||
return res.json({ success: true })
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ WRONG - Using MedusaRequest for Protected Routes
|
||||
|
||||
```typescript
|
||||
// api/store/reviews/[id]/route.ts
|
||||
// Middleware already applied: authenticate("customer", ["session", "bearer"])
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
|
||||
export async function DELETE(
|
||||
req: MedusaRequest, // ❌ WRONG: Should use AuthenticatedMedusaRequest
|
||||
res: MedusaResponse
|
||||
) {
|
||||
const { id } = req.params
|
||||
const customerId = req.auth_context.actor_id // ❌ Type error: auth_context might be undefined
|
||||
|
||||
return res.json({ success: true })
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ WRONG - Manual Authentication Check
|
||||
|
||||
```typescript
|
||||
// api/store/reviews/[id]/route.ts
|
||||
// Middleware already applied: authenticate("customer", ["session", "bearer"])
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
import { MedusaError } from "@medusajs/framework/utils"
|
||||
|
||||
export async function DELETE(req: MedusaRequest, res: MedusaResponse) {
|
||||
const { id } = req.params
|
||||
|
||||
// ❌ WRONG: Don't manually check if user is authenticated
|
||||
// The authenticate middleware already did this!
|
||||
if (!req.auth_context?.actor_id) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.UNAUTHORIZED,
|
||||
"You must be authenticated"
|
||||
)
|
||||
}
|
||||
|
||||
const customerId = req.auth_context.actor_id
|
||||
|
||||
// Also wrong: don't validate business logic in routes
|
||||
// (see workflows.md for why this should be in the workflow)
|
||||
|
||||
return res.json({ success: true })
|
||||
}
|
||||
```
|
||||
|
||||
**Why manual checks are wrong:**
|
||||
- The `authenticate` middleware already validates authentication
|
||||
- If authentication failed, the request never reaches your handler
|
||||
- Manual checks suggest you don't trust or understand the middleware
|
||||
- Adds unnecessary code and potential bugs
|
||||
|
||||
### In Admin Routes
|
||||
|
||||
```typescript
|
||||
// api/admin/custom/route.ts
|
||||
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
|
||||
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
||||
// Get authenticated admin user ID
|
||||
const userId = req.auth_context.actor_id
|
||||
|
||||
const logger = req.scope.resolve("logger")
|
||||
logger.info(`Request from admin user: ${userId}`)
|
||||
|
||||
// Use userId to filter data or track actions
|
||||
// ...
|
||||
|
||||
return res.json({ success: true })
|
||||
}
|
||||
```
|
||||
|
||||
### In Customer Routes
|
||||
|
||||
```typescript
|
||||
// api/store/reviews/route.ts
|
||||
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
|
||||
export async function POST(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
||||
// Get authenticated customer ID
|
||||
const customerId = req.auth_context.actor_id
|
||||
|
||||
const { product_id, rating, comment } = req.validatedBody
|
||||
|
||||
// Create review associated with the authenticated customer
|
||||
const { result } = await createReviewWorkflow(req.scope).run({
|
||||
input: {
|
||||
customer_id: customerId, // From authenticated context
|
||||
product_id,
|
||||
rating,
|
||||
comment,
|
||||
},
|
||||
})
|
||||
|
||||
return res.json({ review: result })
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication Patterns
|
||||
|
||||
### Pattern: User-Specific Data
|
||||
|
||||
```typescript
|
||||
// api/admin/my-reports/route.ts
|
||||
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
||||
const userId = req.auth_context.actor_id
|
||||
const query = req.scope.resolve("query")
|
||||
|
||||
// Get reports created by this admin user
|
||||
const { data: reports } = await query.graph({
|
||||
entity: "report",
|
||||
fields: ["id", "title", "created_at"],
|
||||
filters: {
|
||||
created_by: userId,
|
||||
},
|
||||
})
|
||||
|
||||
return res.json({ reports })
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Ownership Validation
|
||||
|
||||
**⚠️ IMPORTANT**: Ownership validation is business logic and should be done in workflow steps, not API routes. The route should only pass the authenticated user ID to the workflow, and the workflow validates ownership.
|
||||
|
||||
```typescript
|
||||
// api/store/reviews/[id]/route.ts
|
||||
// ✅ CORRECT - Pass user ID to workflow, let workflow validate ownership
|
||||
export async function DELETE(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
||||
const customerId = req.auth_context.actor_id
|
||||
const { id } = req.params
|
||||
|
||||
// Pass to workflow - workflow will validate ownership
|
||||
const { result } = await deleteReviewWorkflow(req.scope).run({
|
||||
input: {
|
||||
reviewId: id,
|
||||
customerId, // Workflow validates this review belongs to this customer
|
||||
},
|
||||
})
|
||||
|
||||
return res.json({ success: true })
|
||||
}
|
||||
|
||||
// ❌ WRONG - Don't validate ownership in the route
|
||||
export async function DELETE(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
||||
const customerId = req.auth_context.actor_id
|
||||
const { id } = req.params
|
||||
const query = req.scope.resolve("query")
|
||||
|
||||
// ❌ WRONG: Don't check ownership in the route
|
||||
const { data: reviews } = await query.graph({
|
||||
entity: "review",
|
||||
fields: ["id", "customer_id"],
|
||||
filters: { id },
|
||||
})
|
||||
|
||||
if (!reviews || reviews.length === 0) {
|
||||
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Review not found")
|
||||
}
|
||||
|
||||
if (reviews[0].customer_id !== customerId) {
|
||||
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Not your review")
|
||||
}
|
||||
|
||||
// This bypasses workflow validation
|
||||
await deleteReviewWorkflow(req.scope).run({
|
||||
input: { id },
|
||||
})
|
||||
|
||||
return res.status(204).send()
|
||||
}
|
||||
```
|
||||
|
||||
**See [workflows.md](workflows.md#business-logic-and-validation-placement) for the complete pattern of validating ownership in workflow steps.**
|
||||
|
||||
### Pattern: Customer Profile Routes
|
||||
|
||||
```typescript
|
||||
// api/store/customers/me/wishlist/route.ts
|
||||
// Automatically protected because it's under /store/customers/me/*
|
||||
|
||||
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
||||
const customerId = req.auth_context.actor_id
|
||||
const query = req.scope.resolve("query")
|
||||
|
||||
// Get customer's wishlist
|
||||
const { data: wishlists } = await query.graph({
|
||||
entity: "wishlist",
|
||||
fields: ["id", "products.*"],
|
||||
filters: {
|
||||
customer_id: customerId,
|
||||
},
|
||||
})
|
||||
|
||||
return res.json({ wishlist: wishlists[0] || null })
|
||||
}
|
||||
|
||||
export async function POST(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
||||
const customerId = req.auth_context.actor_id
|
||||
const { product_id } = req.validatedBody
|
||||
|
||||
// Add product to customer's wishlist
|
||||
const { result } = await addToWishlistWorkflow(req.scope).run({
|
||||
input: {
|
||||
customer_id: customerId,
|
||||
product_id,
|
||||
},
|
||||
})
|
||||
|
||||
return res.json({ wishlist: result })
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Admin Action Tracking
|
||||
|
||||
```typescript
|
||||
// api/admin/products/[id]/archive/route.ts
|
||||
export async function POST(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
||||
const adminUserId = req.auth_context.actor_id
|
||||
const { id } = req.params
|
||||
|
||||
// Archive product and track who did it
|
||||
const { result } = await archiveProductWorkflow(req.scope).run({
|
||||
input: {
|
||||
product_id: id,
|
||||
archived_by: adminUserId,
|
||||
archived_at: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
const logger = req.scope.resolve("logger")
|
||||
logger.info(`Product ${id} archived by admin user ${adminUserId}`)
|
||||
|
||||
return res.json({ product: result })
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Optional Authentication
|
||||
|
||||
Some routes may benefit from authentication but don't require it. Use the `authenticate` middleware with `allowUnauthenticated: true`:
|
||||
|
||||
```typescript
|
||||
// api/middlewares.ts
|
||||
import {
|
||||
defineMiddlewares,
|
||||
authenticate,
|
||||
} from "@medusajs/framework/http"
|
||||
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
{
|
||||
matcher: "/store/products/*/reviews",
|
||||
middlewares: [
|
||||
authenticate("customer", ["session", "bearer"], {
|
||||
allowUnauthenticated: true, // Allows access without authentication
|
||||
})
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
// api/store/products/[id]/reviews/route.ts
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const customerId = req.auth_context?.actor_id // May be undefined
|
||||
const { id } = req.params
|
||||
const query = req.scope.resolve("query")
|
||||
|
||||
// Get all reviews
|
||||
const { data: reviews } = await query.graph({
|
||||
entity: "review",
|
||||
fields: ["id", "rating", "comment", "customer_id"],
|
||||
filters: {
|
||||
product_id: id,
|
||||
},
|
||||
})
|
||||
|
||||
// If authenticated, mark customer's own reviews
|
||||
if (customerId) {
|
||||
reviews.forEach(review => {
|
||||
review.is_own = review.customer_id === customerId
|
||||
})
|
||||
}
|
||||
|
||||
return res.json({ reviews })
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
### Store (Customer) Authentication
|
||||
|
||||
When using the Medusa JS SDK in storefronts:
|
||||
|
||||
```typescript
|
||||
// Frontend code
|
||||
import { sdk } from "./lib/sdk"
|
||||
|
||||
// Login
|
||||
await sdk.auth.login("customer", "emailpass", {
|
||||
email: "customer@example.com",
|
||||
password: "password",
|
||||
})
|
||||
|
||||
// SDK automatically includes auth headers in subsequent requests
|
||||
const { customer } = await sdk.store.customer.retrieve()
|
||||
|
||||
// Access protected routes
|
||||
const { orders } = await sdk.store.customer.listOrders()
|
||||
```
|
||||
|
||||
### Admin Authentication
|
||||
|
||||
When using the Medusa JS SDK in admin applications:
|
||||
|
||||
```typescript
|
||||
// Admin frontend code
|
||||
import { sdk } from "./lib/sdk"
|
||||
|
||||
// Login
|
||||
await sdk.auth.login("user", "emailpass", {
|
||||
email: "admin@example.com",
|
||||
password: "password",
|
||||
})
|
||||
|
||||
// SDK automatically includes JWT in Authorization header
|
||||
const { products } = await sdk.admin.product.list()
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### 1. Use Actor ID from Context
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Uses authenticated context
|
||||
const customerId = req.auth_context.actor_id
|
||||
|
||||
// ❌ BAD: Takes user ID from request
|
||||
const { customer_id } = req.validatedBody // ❌ Can be spoofed
|
||||
```
|
||||
|
||||
### 2. Appropriate Authentication Methods
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Admin routes support all methods
|
||||
authenticate("user", ["session", "bearer", "api-key"])
|
||||
|
||||
// ✅ GOOD: Customer routes use session/bearer only
|
||||
authenticate("customer", ["session", "bearer"])
|
||||
|
||||
// ❌ BAD: Customer routes with API key
|
||||
authenticate("customer", ["api-key"]) // API keys are for admin only
|
||||
```
|
||||
|
||||
### 3. Don't Expose Sensitive Data
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Filters sensitive fields
|
||||
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
||||
const customerId = req.auth_context.actor_id
|
||||
|
||||
const customer = await getCustomer(customerId)
|
||||
|
||||
// Remove sensitive data before sending
|
||||
delete customer.password_hash
|
||||
delete customer.metadata?.internal_notes
|
||||
|
||||
return res.json({ customer })
|
||||
}
|
||||
```
|
||||
240
.agents/skills/building-with-medusa/reference/custom-modules.md
Normal file
240
.agents/skills/building-with-medusa/reference/custom-modules.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# Custom Modules
|
||||
|
||||
## Contents
|
||||
- [When to Create a Custom Module](#when-to-create-a-custom-module)
|
||||
- [Module Structure](#module-structure)
|
||||
- [Creating a Custom Module - Implementation Checklist](#creating-a-custom-module---implementation-checklist)
|
||||
- [Step 1: Create the Data Model](#step-1-create-the-data-model)
|
||||
- [Step 2: Create the Service](#step-2-create-the-service)
|
||||
- [Step 3: Export Module Definition](#step-3-export-module-definition)
|
||||
- [Step 4: Register in Configuration](#step-4-register-in-configuration)
|
||||
- [Steps 5-6: Generate and Run Migrations](#steps-5-6-generate-and-run-migrations)
|
||||
- [Resolving Services from Container](#resolving-services-from-container)
|
||||
- [Auto-Generated CRUD Methods](#auto-generated-crud-methods)
|
||||
- [Loaders](#loaders)
|
||||
|
||||
A module is a reusable package of functionalities related to a single domain or integration. Modules contain data models (database tables) and a service class that provides methods to manage them.
|
||||
|
||||
## When to Create a Custom Module
|
||||
|
||||
- **New domain concepts**: Brands, wishlists, reviews, loyalty points
|
||||
- **Third-party integrations**: ERPs, CMSs, custom services
|
||||
- **Isolated business logic**: Features that don't fit existing commerce modules
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
src/modules/blog/
|
||||
├── models/
|
||||
│ └── post.ts # Data model definitions
|
||||
├── service.ts # Main service class
|
||||
└── index.ts # Module definition export
|
||||
```
|
||||
|
||||
## Creating a Custom Module - Implementation Checklist
|
||||
|
||||
**IMPORTANT FOR CLAUDE CODE**: When implementing custom modules, use the TodoWrite tool to track your progress through these steps. This ensures you don't miss any critical steps (especially migrations!) and provides visibility to the user.
|
||||
|
||||
Create these tasks in your todo list:
|
||||
|
||||
- Create data model in src/modules/[name]/models/
|
||||
- Create service extending MedusaService
|
||||
- Export module definition in index.ts
|
||||
- **CRITICAL: Register module in medusa-config.ts** (do this before using the module)
|
||||
- **CRITICAL: Generate migrations: npx medusa db:generate [module-name]** (Never skip!)
|
||||
- **CRITICAL: Run migrations: npx medusa db:migrate** (Never skip!)
|
||||
- Use module service in API routes/workflows
|
||||
- **CRITICAL: Run build to validate implementation** (catches type errors and issues)
|
||||
|
||||
## Step 1: Create the Data Model
|
||||
|
||||
```typescript
|
||||
// src/modules/blog/models/post.ts
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
|
||||
const Post = model.define("post", {
|
||||
id: model.id().primaryKey(),
|
||||
title: model.text(),
|
||||
content: model.text().nullable(),
|
||||
published: model.boolean().default(false),
|
||||
})
|
||||
|
||||
// note models automatically get created_at, updated_at and deleted_at added - don't add these explicitly
|
||||
|
||||
export default Post
|
||||
```
|
||||
|
||||
**Data model reference**: See [data-models.md](data-models.md)
|
||||
|
||||
## Step 2: Create the Service
|
||||
|
||||
```typescript
|
||||
// src/modules/blog/service.ts
|
||||
import { MedusaService } from "@medusajs/framework/utils"
|
||||
import Post from "./models/post"
|
||||
|
||||
class BlogModuleService extends MedusaService({
|
||||
Post,
|
||||
}) {}
|
||||
|
||||
export default BlogModuleService
|
||||
```
|
||||
|
||||
The service extends `MedusaService` which auto-generates CRUD methods for each data model.
|
||||
|
||||
## Step 3: Export Module Definition
|
||||
|
||||
```typescript
|
||||
// src/modules/blog/index.ts
|
||||
import BlogModuleService from "./service"
|
||||
import { Module } from "@medusajs/framework/utils"
|
||||
|
||||
export const BLOG_MODULE = "blog"
|
||||
|
||||
export default Module(BLOG_MODULE, {
|
||||
service: BlogModuleService,
|
||||
})
|
||||
```
|
||||
|
||||
**⚠️ CRITICAL - Module Name Format:**
|
||||
- Module names MUST be in camelCase
|
||||
- **NEVER use dashes (kebab-case)** in module names
|
||||
- ✅ CORRECT: `"blog"`, `"productReview"`, `"orderTracking"`
|
||||
- ❌ WRONG: `"product-review"`, `"order-tracking"` (will cause runtime errors)
|
||||
|
||||
**Example of common mistake:**
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - dashes will break the module
|
||||
export const PRODUCT_REVIEW_MODULE = "product-review" // Don't do this!
|
||||
export default Module("product-review", { service: ProductReviewService })
|
||||
|
||||
// ✅ CORRECT - use camelCase
|
||||
export const PRODUCT_REVIEW_MODULE = "productReview"
|
||||
export default Module("productReview", { service: ProductReviewService })
|
||||
```
|
||||
|
||||
**Why this matters:** Medusa's internal module resolution uses property access syntax (e.g., `container.resolve("productReview")`), and dashes would break this.
|
||||
|
||||
## Step 4: Register in Configuration
|
||||
|
||||
**IMPORTANT**: You MUST register the module in the configurations BEFORE using it anywhere or generating migrations.
|
||||
|
||||
```typescript
|
||||
// medusa-config.ts
|
||||
module.exports = defineConfig({
|
||||
// ...
|
||||
modules: [{ resolve: "./src/modules/blog" }],
|
||||
})
|
||||
```
|
||||
|
||||
## Steps 5-6: Generate and Run Migrations
|
||||
|
||||
**⚠️ CRITICAL - DO NOT SKIP**: After creating a module and registering it in medusa-config.ts, you MUST run TWO SEPARATE commands. Without this step, the module's database tables won't exist and you will get runtime errors.
|
||||
|
||||
```bash
|
||||
# Step 5: Generate migrations (creates migration files)
|
||||
# Command format: npx medusa db:generate <module-name>
|
||||
npx medusa db:generate blog
|
||||
|
||||
# Step 6: Run migrations (applies changes to database)
|
||||
# This command takes NO arguments
|
||||
npx medusa db:migrate
|
||||
```
|
||||
|
||||
**⚠️ CRITICAL: These are TWO separate commands:**
|
||||
- ✅ CORRECT: Run `npx medusa db:generate blog` then `npx medusa db:migrate`
|
||||
- ❌ WRONG: `npx medusa db:generate blog "create blog module"` (no description parameter!)
|
||||
- ❌ WRONG: Combining into one command
|
||||
|
||||
**Why this matters:**
|
||||
- Migrations create the database tables for your module's data models
|
||||
- Without migrations, the module service methods (createPosts, listPosts, etc.) will fail
|
||||
- You must generate migrations BEFORE running them
|
||||
- This step is REQUIRED before using the module anywhere in your code
|
||||
|
||||
**Common mistake:** Creating a module and immediately trying to use it in a workflow or API route without running migrations first. Always run migrations immediately after registering the module.
|
||||
|
||||
## Resolving Services from Container
|
||||
|
||||
Access your module service in different contexts:
|
||||
|
||||
```typescript
|
||||
// In API routes
|
||||
const blogService = req.scope.resolve("blog")
|
||||
const post = await blogService.createPosts({ title: "Hello World" })
|
||||
|
||||
// In workflow steps
|
||||
const blogService = container.resolve("blog")
|
||||
const posts = await blogService.listPosts({ published: true })
|
||||
```
|
||||
|
||||
The module name used in `Module("blog", ...)` becomes the container resolution key.
|
||||
|
||||
## Auto-Generated CRUD Methods
|
||||
|
||||
The service auto-generates methods for each data model:
|
||||
|
||||
```typescript
|
||||
// Create - pass object or array of objects
|
||||
const post = await blogService.createPosts({ title: "Hello" })
|
||||
const posts = await blogService.createPosts([
|
||||
{ title: "One" },
|
||||
{ title: "Two" },
|
||||
])
|
||||
|
||||
// Retrieve - by ID, with optional select/relations
|
||||
const post = await blogService.retrievePost("post_123")
|
||||
const post = await blogService.retrievePost("post_123", {
|
||||
select: ["id", "title"],
|
||||
})
|
||||
|
||||
// List - with filters and options
|
||||
const posts = await blogService.listPosts()
|
||||
const posts = await blogService.listPosts({ published: true })
|
||||
const posts = await blogService.listPosts(
|
||||
{ published: true }, // filters
|
||||
{ take: 20, skip: 0, order: { created_at: "DESC" } } // options
|
||||
)
|
||||
|
||||
// List with count - returns [records, totalCount]
|
||||
const [posts, count] = await blogService.listAndCountPosts({ published: true })
|
||||
|
||||
// Update - by ID or with selector/data pattern
|
||||
const post = await blogService.updatePosts({ id: "post_123", title: "Updated" })
|
||||
const posts = await blogService.updatePosts({
|
||||
selector: { published: false },
|
||||
data: { published: true },
|
||||
})
|
||||
|
||||
// Delete - by ID, array of IDs, or filter object
|
||||
await blogService.deletePosts("post_123")
|
||||
await blogService.deletePosts(["post_123", "post_456"])
|
||||
await blogService.deletePosts({ published: false })
|
||||
|
||||
// Soft delete / restore
|
||||
await blogService.softDeletePosts("post_123")
|
||||
await blogService.restorePosts("post_123")
|
||||
```
|
||||
|
||||
## Loaders
|
||||
|
||||
Loaders run when the Medusa application starts. Use them to initialize connections, seed data (relevant to the Module), or register resources.
|
||||
|
||||
```typescript
|
||||
// src/modules/blog/loaders/hello-world.ts
|
||||
import { LoaderOptions } from "@medusajs/framework/types"
|
||||
|
||||
export default async function helloWorldLoader({ container }: LoaderOptions) {
|
||||
const logger = container.resolve("logger")
|
||||
logger.info("[BLOG MODULE] Started!")
|
||||
}
|
||||
|
||||
// Export in module definition (src/modules/blog/index.ts)
|
||||
import helloWorldLoader from "./loaders/hello-world"
|
||||
|
||||
export default Module("blog", {
|
||||
service: BlogModuleService,
|
||||
loaders: [helloWorldLoader],
|
||||
})
|
||||
```
|
||||
103
.agents/skills/building-with-medusa/reference/data-models.md
Normal file
103
.agents/skills/building-with-medusa/reference/data-models.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Data Models
|
||||
|
||||
Data models represent tables in the database. Use Medusa's Data Model Language (DML) to define them.
|
||||
|
||||
## Property Types
|
||||
|
||||
```typescript
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
|
||||
const MyModel = model.define("my_model", {
|
||||
// Primary key (required)
|
||||
id: model.id().primaryKey(),
|
||||
|
||||
// Text
|
||||
name: model.text(),
|
||||
description: model.text().nullable(),
|
||||
|
||||
// Numbers
|
||||
quantity: model.number(),
|
||||
price: model.bigNumber(), // For high precision
|
||||
|
||||
// Boolean
|
||||
is_active: model.boolean().default(true),
|
||||
|
||||
// Enum
|
||||
status: model.enum(["draft", "published", "archived"]).default("draft"),
|
||||
|
||||
// Date/Time
|
||||
published_at: model.dateTime().nullable(),
|
||||
|
||||
// JSON (for flexible data)
|
||||
metadata: model.json().nullable(),
|
||||
|
||||
// Array
|
||||
tags: model.array().nullable(),
|
||||
})
|
||||
```
|
||||
|
||||
## Property Modifiers
|
||||
|
||||
```typescript
|
||||
model.text() // Required by default
|
||||
model.text().nullable() // Allow null values
|
||||
model.text().default("value") // Set default value
|
||||
model.text().unique() // Unique constraint
|
||||
model.text().primaryKey() // Set as primary key
|
||||
```
|
||||
|
||||
## Relationships Within a Module
|
||||
|
||||
Define relationships between data models in the same module:
|
||||
|
||||
```typescript
|
||||
// src/modules/blog/models/post.ts
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
import { Comment } from "./comment"
|
||||
|
||||
export const Post = model.define("post", {
|
||||
id: model.id().primaryKey(),
|
||||
title: model.text(),
|
||||
comments: model.hasMany(() => Comment, {
|
||||
mappedBy: "post",
|
||||
}),
|
||||
})
|
||||
|
||||
// src/modules/blog/models/comment.ts
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
import { Post } from "./post"
|
||||
|
||||
export const Comment = model.define("comment", {
|
||||
id: model.id().primaryKey(),
|
||||
content: model.text(),
|
||||
post: model.belongsTo(() => Post, {
|
||||
mappedBy: "comments",
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
## Relationship Types
|
||||
|
||||
- `model.hasMany()` - One-to-many (post has many comments)
|
||||
- `model.belongsTo()` - Many-to-one (comment belongs to post)
|
||||
- `model.hasOne()` - One-to-one
|
||||
- `model.manyToMany()` - Many-to-many
|
||||
|
||||
## Automatic Properties
|
||||
|
||||
Data models automatically include:
|
||||
|
||||
- `created_at` - Creation timestamp
|
||||
- `updated_at` - Last update timestamp
|
||||
- `deleted_at` - Soft delete timestamp
|
||||
|
||||
**Important**: Never add these properties explicitly to your model definitions.
|
||||
|
||||
## Generate and Run Migrations After Changes
|
||||
|
||||
After making changes to a data model, such as adding a property, you MUST generate migrations BEFORE running migrations:
|
||||
|
||||
```bash
|
||||
npx medusa db:generate blog
|
||||
npx medusa db:migrate
|
||||
```
|
||||
254
.agents/skills/building-with-medusa/reference/error-handling.md
Normal file
254
.agents/skills/building-with-medusa/reference/error-handling.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Error Handling in Medusa
|
||||
|
||||
Medusa provides the `MedusaError` class for consistent error responses across your API routes and custom code.
|
||||
|
||||
## Contents
|
||||
- [Using MedusaError](#using-medusaerror)
|
||||
- [Error Types](#error-types)
|
||||
- [Error Response Format](#error-response-format)
|
||||
- [Best Practices](#best-practices)
|
||||
|
||||
## Using MedusaError
|
||||
|
||||
Use `MedusaError` in API routes, workflows, and custom modules to throw errors that Medusa will automatically format and return to clients:
|
||||
|
||||
```typescript
|
||||
import { MedusaError } from "@medusajs/framework/utils"
|
||||
|
||||
// Throw an error
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
"Product not found"
|
||||
)
|
||||
```
|
||||
|
||||
## Error Types
|
||||
|
||||
### NOT_FOUND
|
||||
Use when a requested resource doesn't exist:
|
||||
|
||||
```typescript
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
"Product with ID 'prod_123' not found"
|
||||
)
|
||||
```
|
||||
|
||||
**HTTP Status**: 404
|
||||
|
||||
### INVALID_DATA
|
||||
Use when request data fails validation or is malformed:
|
||||
|
||||
```typescript
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Email address is invalid"
|
||||
)
|
||||
```
|
||||
|
||||
**HTTP Status**: 400
|
||||
|
||||
### UNAUTHORIZED
|
||||
Use when authentication is required but not provided:
|
||||
|
||||
```typescript
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.UNAUTHORIZED,
|
||||
"Authentication required to access this resource"
|
||||
)
|
||||
```
|
||||
|
||||
**HTTP Status**: 401
|
||||
|
||||
### NOT_ALLOWED
|
||||
Use when the user is authenticated but doesn't have permission:
|
||||
|
||||
```typescript
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
"You don't have permission to delete this product"
|
||||
)
|
||||
```
|
||||
|
||||
**HTTP Status**: 403
|
||||
|
||||
### CONFLICT
|
||||
Use when the operation conflicts with existing data:
|
||||
|
||||
```typescript
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.CONFLICT,
|
||||
"A product with this handle already exists"
|
||||
)
|
||||
```
|
||||
|
||||
**HTTP Status**: 409
|
||||
|
||||
### DUPLICATE_ERROR
|
||||
Use when trying to create a duplicate resource:
|
||||
|
||||
```typescript
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.DUPLICATE_ERROR,
|
||||
"Email address is already registered"
|
||||
)
|
||||
```
|
||||
|
||||
**HTTP Status**: 422
|
||||
|
||||
### INVALID_STATE
|
||||
Use when the resource is in an invalid state for the operation:
|
||||
|
||||
```typescript
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_STATE,
|
||||
"Cannot cancel an order that has already been fulfilled"
|
||||
)
|
||||
```
|
||||
|
||||
**HTTP Status**: 400
|
||||
|
||||
## Error Response Format
|
||||
|
||||
Medusa automatically formats errors into a consistent JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "not_found",
|
||||
"message": "Product with ID 'prod_123' not found"
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Specific Error Types
|
||||
|
||||
Choose the most appropriate error type for the situation:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Uses specific error types
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const { id } = req.params
|
||||
const query = req.scope.resolve("query")
|
||||
|
||||
const { data } = await query.graph({
|
||||
entity: "product",
|
||||
fields: ["id", "title"],
|
||||
filters: { id },
|
||||
})
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Product with ID '${id}' not found`
|
||||
)
|
||||
}
|
||||
|
||||
return res.json({ product: data[0] })
|
||||
}
|
||||
|
||||
// ❌ BAD: Uses generic error
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const { id } = req.params
|
||||
const query = req.scope.resolve("query")
|
||||
|
||||
const { data } = await query.graph({
|
||||
entity: "product",
|
||||
fields: ["id", "title"],
|
||||
filters: { id },
|
||||
})
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
throw new Error("Product not found") // Generic error
|
||||
}
|
||||
|
||||
return res.json({ product: data[0] })
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Provide Clear Error Messages
|
||||
|
||||
Error messages should be descriptive and help users understand what went wrong:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Clear, specific message
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Cannot create product: title must be at least 3 characters long"
|
||||
)
|
||||
|
||||
// ❌ BAD: Vague message
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Invalid input"
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Include Context in Error Messages
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Includes relevant context
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Product with ID '${productId}' not found`
|
||||
)
|
||||
|
||||
// ✅ GOOD: Includes field name
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Invalid email format: '${email}'`
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Handle Workflow Errors
|
||||
|
||||
When calling workflows from API routes, catch and transform errors:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Catches and transforms workflow errors
|
||||
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
||||
const { data } = req.validatedBody
|
||||
|
||||
try {
|
||||
const { result } = await myWorkflow(req.scope).run({
|
||||
input: { data },
|
||||
})
|
||||
|
||||
return res.json({ result })
|
||||
} catch (error) {
|
||||
// Transform workflow errors into API errors
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Failed to create resource: ${error.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Use Validation Middleware
|
||||
|
||||
Let validation middleware handle input validation errors:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Middleware handles validation
|
||||
// middlewares.ts
|
||||
const MySchema = z.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
age: z.number().min(18, "Must be at least 18 years old"),
|
||||
})
|
||||
|
||||
export const myMiddlewares: MiddlewareRoute[] = [
|
||||
{
|
||||
matcher: "/store/my-route",
|
||||
method: "POST",
|
||||
middlewares: [validateAndTransformBody(MySchema)],
|
||||
},
|
||||
]
|
||||
|
||||
// route.ts - No need to validate again
|
||||
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
||||
const { email, age } = req.validatedBody // Already validated
|
||||
|
||||
// Your logic here
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,229 @@
|
||||
# Frontend SDK Integration
|
||||
|
||||
## Contents
|
||||
- [Frontend SDK Pattern](#frontend-sdk-pattern)
|
||||
- [Locating the SDK](#locating-the-sdk)
|
||||
- [Using sdk.client.fetch()](#using-sdkclientfetch)
|
||||
- [React Query Pattern](#react-query-pattern)
|
||||
- [Query Key Best Practices](#query-key-best-practices)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Optimistic Updates](#optimistic-updates)
|
||||
|
||||
This guide covers how to integrate Medusa custom API routes with frontend applications using the Medusa SDK and React Query.
|
||||
|
||||
**Note:** API routes are also referred to as "endpoints" - these terms are interchangeable.
|
||||
|
||||
## Frontend SDK Pattern
|
||||
|
||||
### Locating the SDK
|
||||
|
||||
**IMPORTANT:** Never hardcode SDK import paths. Always locate where the SDK is instantiated in the project first.
|
||||
|
||||
Look for `@medusajs/js-sdk`
|
||||
|
||||
The SDK instance is typically exported as `sdk`:
|
||||
|
||||
```typescript
|
||||
import { sdk } from "[LOCATE IN PROJECT]"
|
||||
```
|
||||
|
||||
### Using sdk.client.fetch()
|
||||
|
||||
**⚠️ CRITICAL: ALWAYS use the Medusa JS SDK for ALL API requests - NEVER use regular fetch()**
|
||||
|
||||
**Why this is critical:**
|
||||
- **Store API routes** require the publishable API key in headers
|
||||
- **Admin API routes** require authentication headers
|
||||
- **Regular fetch()** without these headers will cause errors
|
||||
- The SDK automatically handles all required headers for you
|
||||
|
||||
**When to use what:**
|
||||
- **Existing endpoints** (built-in Medusa routes): Use existing SDK methods like `sdk.store.product.list()`, `sdk.admin.order.retrieve()`
|
||||
- **Custom endpoints** (your custom API routes): Use `sdk.client.fetch()` for custom routes
|
||||
|
||||
**⚠️ CRITICAL: The SDK handles JSON serialization automatically. NEVER use JSON.stringify() on the body.**
|
||||
|
||||
Call custom API routes using the SDK:
|
||||
|
||||
```typescript
|
||||
import { sdk } from "[LOCATE SDK INSTANCE IN PROJECT]"
|
||||
|
||||
// ✅ CORRECT - Pass object directly
|
||||
const result = await sdk.client.fetch("/store/my-route", {
|
||||
method: "POST",
|
||||
body: {
|
||||
email: "user@example.com",
|
||||
name: "John Doe",
|
||||
},
|
||||
})
|
||||
|
||||
// ❌ WRONG - Don't use JSON.stringify
|
||||
const result = await sdk.client.fetch("/store/my-route", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ // ❌ DON'T DO THIS!
|
||||
email: "user@example.com",
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- **The SDK handles JSON serialization automatically** - just pass plain objects
|
||||
- **NEVER use JSON.stringify()** - this will break the request
|
||||
- No need to set Content-Type headers - SDK adds them
|
||||
- Session/JWT authentication is handled automatically
|
||||
- Publishable API key is automatically added
|
||||
|
||||
### Built-in Endpoints vs Custom Endpoints
|
||||
|
||||
**⚠️ CRITICAL: Use the appropriate SDK method based on endpoint type**
|
||||
|
||||
```typescript
|
||||
import { sdk } from "[LOCATE SDK INSTANCE IN PROJECT]"
|
||||
|
||||
// ✅ CORRECT - Built-in endpoint: Use existing SDK method
|
||||
const products = await sdk.store.product.list({
|
||||
limit: 10,
|
||||
offset: 0
|
||||
})
|
||||
|
||||
// ✅ CORRECT - Custom endpoint: Use sdk.client.fetch()
|
||||
const reviews = await sdk.client.fetch("/store/products/prod_123/reviews")
|
||||
|
||||
// ❌ WRONG - Using regular fetch for ANY endpoint
|
||||
const products = await fetch("http://localhost:9000/store/products")
|
||||
// ❌ Error: Missing publishable API key header!
|
||||
|
||||
// ❌ WRONG - Using regular fetch for custom endpoint
|
||||
const reviews = await fetch("http://localhost:9000/store/products/prod_123/reviews")
|
||||
// ❌ Error: Missing publishable API key header!
|
||||
|
||||
// ❌ WRONG - Using sdk.client.fetch() for built-in endpoint when SDK method exists
|
||||
const products = await sdk.client.fetch("/store/products")
|
||||
// ❌ Less type-safe than using sdk.store.product.list()
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
- **Store routes** require `x-publishable-api-key` header - SDK adds it automatically
|
||||
- **Admin routes** require `Authorization` and session cookie headers - SDK adds them automatically
|
||||
- **Regular fetch()** doesn't include these headers → API returns authentication/authorization errors
|
||||
- Using existing SDK methods provides **better type safety** and autocomplete
|
||||
|
||||
## React Query Pattern
|
||||
|
||||
Use `useQuery` for GET requests and `useMutation` for POST/DELETE:
|
||||
|
||||
```typescript
|
||||
import { sdk } from "[LOCATE SDK INSTANCE IN PROJECT]"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
function MyComponent({ userId }: { userId: string }) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// GET request - fetching data
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["my-data", userId],
|
||||
queryFn: () => sdk.client.fetch(`/store/my-route?userId=${userId}`),
|
||||
enabled: !!userId,
|
||||
})
|
||||
|
||||
// POST request - mutation with cache invalidation
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input: { email: string }) =>
|
||||
sdk.client.fetch("/store/my-route", { method: "POST", body: input }),
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch related queries
|
||||
queryClient.invalidateQueries({ queryKey: ["my-data"] })
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) return <p>Loading...</p>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{data?.title}</p>
|
||||
<button
|
||||
onClick={() => mutation.mutate({ email: "test@example.com" })}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? "Loading..." : "Submit"}
|
||||
</button>
|
||||
{mutation.isError && <p>Error occurred</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Key states:** `isLoading`, `isPending`, `isSuccess`, `isError`, `error`
|
||||
|
||||
## Query Key Best Practices
|
||||
|
||||
Structure query keys for effective cache management:
|
||||
|
||||
```typescript
|
||||
// Good: Hierarchical structure
|
||||
queryKey: ["products", productId]
|
||||
queryKey: ["products", "list", { page, filters }]
|
||||
|
||||
// Invalidate all product queries
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
|
||||
// Invalidate specific product
|
||||
queryClient.invalidateQueries({ queryKey: ["products", productId] })
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Handle API errors gracefully:
|
||||
|
||||
```typescript
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input) => sdk.client.fetch("/store/my-route", {
|
||||
method: "POST",
|
||||
body: input
|
||||
}),
|
||||
onError: (error) => {
|
||||
console.error("Mutation failed:", error)
|
||||
// Show error message to user
|
||||
},
|
||||
})
|
||||
|
||||
// In component
|
||||
{mutation.isError && (
|
||||
<p className="error">
|
||||
{mutation.error?.message || "An error occurred"}
|
||||
</p>
|
||||
)}
|
||||
```
|
||||
|
||||
## Optimistic Updates
|
||||
|
||||
Update UI immediately before server confirms:
|
||||
|
||||
```typescript
|
||||
const mutation = useMutation({
|
||||
mutationFn: (newItem) =>
|
||||
sdk.client.fetch("/store/items", { method: "POST", body: newItem }),
|
||||
onMutate: async (newItem) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ["items"] })
|
||||
|
||||
// Snapshot previous value
|
||||
const previousItems = queryClient.getQueryData(["items"])
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData(["items"], (old) => [...old, newItem])
|
||||
|
||||
// Return context with snapshot
|
||||
return { previousItems }
|
||||
},
|
||||
onError: (err, newItem, context) => {
|
||||
// Rollback on error
|
||||
queryClient.setQueryData(["items"], context.previousItems)
|
||||
},
|
||||
onSettled: () => {
|
||||
// Refetch after mutation
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
||||
},
|
||||
})
|
||||
```
|
||||
384
.agents/skills/building-with-medusa/reference/module-links.md
Normal file
384
.agents/skills/building-with-medusa/reference/module-links.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# Module Links
|
||||
|
||||
## Contents
|
||||
- [When to Use Links](#when-to-use-links)
|
||||
- [Implementing Module Links - Workflow Checklist](#implementing-module-links---workflow-checklist)
|
||||
- [Step 1: Defining a Link](#step-1-defining-a-link)
|
||||
- [Step 2: Link Configuration Options](#step-2-link-configuration-options)
|
||||
- [List Links (One-to-Many)](#list-links-one-to-many)
|
||||
- [Delete Cascades](#delete-cascades)
|
||||
- [Step 3: Sync Links (Run Migrations)](#step-3-sync-links-run-migrations)
|
||||
- [Step 4: Managing Links](#step-4-managing-links)
|
||||
- [Step 5: Querying Linked Data](#step-5-querying-linked-data)
|
||||
- [Advanced: Link with Custom Columns](#advanced-link-with-custom-columns)
|
||||
|
||||
Module links create associations between data models in different modules while maintaining module isolation. Use links to connect your custom models to Commerce Module models (products, customers, orders, etc.).
|
||||
|
||||
## When to Use Links
|
||||
|
||||
- **Extend commerce entities**: Add brands to products, wishlists to customers
|
||||
- **Cross-module associations**: Connect custom modules to each other
|
||||
- **Maintain isolation**: Keep modules independent and reusable
|
||||
|
||||
## Implementing Module Links - Workflow Checklist
|
||||
|
||||
**IMPORTANT FOR CLAUDE CODE**: When implementing module links, use the TodoWrite tool to track your progress through these steps. This ensures you don't miss any critical steps and provides visibility to the user.
|
||||
|
||||
Create these tasks in your todo list:
|
||||
|
||||
- Optional: Add linked ID in custom data model (if one-to-one or one-to-many)
|
||||
- Define the link in src/links/
|
||||
- Configure list or delete cascade options if needed
|
||||
- **CRITICAL: Run migrations: npx medusa db:migrate** (Never skip this step!)
|
||||
- Create links in code using link.create() or createRemoteLinkStep
|
||||
- Query linked data using query.graph()
|
||||
- **CRITICAL: Run build to validate implementation** (catches type errors and issues)
|
||||
|
||||
## Optional: Add Linked ID in Custom Data Model
|
||||
|
||||
Add the ID of a linked data model in the custom data model if the custom data model belongs to it or extends it. Otherwise, skip this step.
|
||||
|
||||
For example, add ID of customer and product to custom product review model:
|
||||
|
||||
```typescript
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
|
||||
const Review = model.define("review", {
|
||||
// other properties...
|
||||
// ID of linked customer
|
||||
customer_id: model.text(),
|
||||
// ID of linked product
|
||||
product_id: model.text()
|
||||
})
|
||||
|
||||
export default Review
|
||||
```
|
||||
|
||||
## Step 1: Defining a Link
|
||||
|
||||
**⚠️ CRITICAL RULE: Create ONE link definition per file.** Do NOT export an array of links from a single file.
|
||||
|
||||
Create link files in `src/links/`:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - src/links/product-brand.ts (one link per file)
|
||||
import { defineLink } from "@medusajs/framework/utils"
|
||||
import ProductModule from "@medusajs/medusa/product"
|
||||
import BrandModule from "../modules/brand"
|
||||
|
||||
export default defineLink(
|
||||
ProductModule.linkable.product,
|
||||
BrandModule.linkable.brand
|
||||
)
|
||||
```
|
||||
|
||||
**If one model links to multiple others, create multiple files:**
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - src/links/review-product.ts
|
||||
export default defineLink(
|
||||
ReviewModule.linkable.review,
|
||||
ProductModule.linkable.product
|
||||
)
|
||||
|
||||
// ✅ CORRECT - src/links/review-customer.ts
|
||||
export default defineLink(
|
||||
ReviewModule.linkable.review,
|
||||
CustomerModule.linkable.customer
|
||||
)
|
||||
|
||||
// ❌ WRONG - Don't export array of links from one file
|
||||
export default [
|
||||
defineLink(ReviewModule.linkable.review, ProductModule.linkable.product),
|
||||
defineLink(ReviewModule.linkable.review, CustomerModule.linkable.customer),
|
||||
] // This doesn't work!
|
||||
```
|
||||
|
||||
**IMPORTANT:** The `.linkable` property is **automatically added** to all modules by Medusa. You do NOT need to add `.linkable()` or any linkable definition to your data models. Simply use `ModuleName.linkable.modelName` when defining links.
|
||||
|
||||
For example, if you have a `Review` data model in a `ReviewModule`:
|
||||
- ✅ CORRECT: `ReviewModule.linkable.review` (works automatically)
|
||||
- ❌ WRONG: Adding `.linkable()` method to the Review model definition (not needed, causes errors)
|
||||
|
||||
**⚠️ NEXT STEP**: After defining a link, you MUST immediately proceed to Step 3 to run migrations (`npx medusa db:migrate`). Do not skip this step!
|
||||
|
||||
## Step 2: Link Configuration Options
|
||||
|
||||
### List Links (One-to-Many)
|
||||
|
||||
Allow multiple records to link to one record:
|
||||
|
||||
```typescript
|
||||
// A brand can have many products
|
||||
export default defineLink(
|
||||
{
|
||||
linkable: ProductModule.linkable.product,
|
||||
isList: true,
|
||||
},
|
||||
BrandModule.linkable.brand
|
||||
)
|
||||
```
|
||||
|
||||
### Delete Cascades
|
||||
|
||||
Automatically delete links when a record is deleted:
|
||||
|
||||
```typescript
|
||||
export default defineLink(ProductModule.linkable.product, {
|
||||
linkable: BrandModule.linkable.brand,
|
||||
deleteCascade: true,
|
||||
})
|
||||
```
|
||||
|
||||
## Step 3: Sync Links (Run Migrations)
|
||||
|
||||
**⚠️ CRITICAL - DO NOT SKIP**: After defining links, you MUST run migrations to sync the link to the database. Without this step, the link will not work and you will get runtime errors.
|
||||
|
||||
```bash
|
||||
npx medusa db:migrate
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
- Links create database tables that store the relationships between modules
|
||||
- Without migrations, these tables don't exist and link operations will fail
|
||||
- This step is REQUIRED before creating any links in code or querying linked data
|
||||
|
||||
**Common mistake:** Defining a link in `src/links/` and immediately trying to use it in a workflow or query without running migrations first. Always run migrations immediately after defining a link.
|
||||
|
||||
## Step 4: Managing Links
|
||||
|
||||
**⚠️ CRITICAL - Link Order (Direction):** When creating or dismissing links, the order of modules MUST match the order in `defineLink()`. Mismatched order causes runtime errors.
|
||||
|
||||
```typescript
|
||||
// Example link definition: product FIRST, then brand
|
||||
export default defineLink(
|
||||
ProductModule.linkable.product,
|
||||
BrandModule.linkable.brand
|
||||
)
|
||||
```
|
||||
|
||||
### In Workflow Composition Functions
|
||||
|
||||
To create a link between records in workflow composition functions, use the `createRemoteLinkStep`:
|
||||
|
||||
```typescript
|
||||
import { Modules } from "@medusajs/framework/utils"
|
||||
import { createRemoteLinkStep } from "@medusajs/medusa/core-flows"
|
||||
import {
|
||||
createWorkflow,
|
||||
transform,
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
|
||||
const BRAND_MODULE = "brand"
|
||||
|
||||
export const myWorkflow = createWorkflow(
|
||||
"my-workflow",
|
||||
function (input) {
|
||||
// ...
|
||||
// ✅ CORRECT - Order matches defineLink (product first, then brand)
|
||||
const linkData = transform({ input }, ({ input }) => {
|
||||
return [
|
||||
{
|
||||
[Modules.PRODUCT]: {
|
||||
product_id: input.product_id,
|
||||
},
|
||||
[BRAND_MODULE]: {
|
||||
brand_id: input.brand_id,
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
createRemoteLinkStep(linkData)
|
||||
// ...
|
||||
}
|
||||
)
|
||||
|
||||
// ❌ WRONG - Order doesn't match defineLink
|
||||
const linkData = transform({ input }, ({ input }) => {
|
||||
return [
|
||||
{
|
||||
[BRAND_MODULE]: {
|
||||
brand_id: input.brand_id,
|
||||
},
|
||||
[Modules.PRODUCT]: {
|
||||
product_id: input.product_id,
|
||||
},
|
||||
},
|
||||
]
|
||||
}) // Runtime error: link direction mismatch!
|
||||
```
|
||||
|
||||
To dismiss (remove) a link between records in workflow composition functions, use the `dismissRemoteLinkStep`:
|
||||
|
||||
```typescript
|
||||
import { Modules } from "@medusajs/framework/utils"
|
||||
import { dismissRemoteLinkStep } from "@medusajs/medusa/core-flows"
|
||||
import {
|
||||
createWorkflow,
|
||||
transform,
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
|
||||
const BRAND_MODULE = "brand"
|
||||
|
||||
export const myWorkflow = createWorkflow(
|
||||
"my-workflow",
|
||||
function (input) {
|
||||
// ...
|
||||
// Order MUST match defineLink (product first, then brand)
|
||||
const linkData = transform({ input }, ({ input }) => {
|
||||
return [
|
||||
{
|
||||
[Modules.PRODUCT]: {
|
||||
product_id: input.product_id,
|
||||
},
|
||||
[BRAND_MODULE]: {
|
||||
brand_id: input.brand_id,
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
dismissRemoteLinkStep(linkData)
|
||||
// ...
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Outside Workflows
|
||||
|
||||
Outside workflows or in workflow steps, use the `link` utility to create and manage links between records. **Order MUST match `defineLink()` here too:**
|
||||
|
||||
```typescript
|
||||
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
|
||||
|
||||
// In an API route or workflow step
|
||||
const link = container.resolve(ContainerRegistrationKeys.LINK)
|
||||
|
||||
const BRAND_MODULE = "brand"
|
||||
|
||||
// ✅ CORRECT - Create a link (order matches defineLink: product first, then brand)
|
||||
await link.create({
|
||||
[Modules.PRODUCT]: { product_id: "prod_123" },
|
||||
[BRAND_MODULE]: { brand_id: "brand_456" },
|
||||
})
|
||||
|
||||
// ✅ CORRECT - Dismiss (remove) a link (same order: product first, then brand)
|
||||
await link.dismiss({
|
||||
[Modules.PRODUCT]: { product_id: "prod_123" },
|
||||
[BRAND_MODULE]: { brand_id: "brand_456" },
|
||||
})
|
||||
|
||||
// ❌ WRONG - Order doesn't match defineLink
|
||||
await link.create({
|
||||
[BRAND_MODULE]: { brand_id: "brand_456" },
|
||||
[Modules.PRODUCT]: { product_id: "prod_123" },
|
||||
}) // Runtime error: link direction mismatch!
|
||||
```
|
||||
|
||||
## Step 5: Querying Linked Data
|
||||
|
||||
### Using query.graph() - Retrieve Linked Data
|
||||
|
||||
Use `query.graph()` to fetch data across linked modules. **Note**: `query.graph()` can retrieve linked data but **cannot filter by properties of linked modules** (data models in separate modules).
|
||||
|
||||
```typescript
|
||||
const query = container.resolve("query")
|
||||
|
||||
// ✅ Get products with their linked brands (no cross-module filtering)
|
||||
const { data: products } = await query.graph({
|
||||
entity: "product",
|
||||
fields: ["id", "title", "brand.*"], // brand.* fetches linked brand data
|
||||
filters: {
|
||||
id: "prod_123", // ✅ Filter by product properties only
|
||||
},
|
||||
})
|
||||
|
||||
// ✅ Get brands with their linked products
|
||||
const { data: brands } = await query.graph({
|
||||
entity: "brand",
|
||||
fields: ["id", "name", "products.*"],
|
||||
})
|
||||
|
||||
// ❌ DOES NOT WORK: Cannot filter products by linked brand properties
|
||||
const { data: products } = await query.graph({
|
||||
entity: "product",
|
||||
fields: ["id", "title", "brand.*"],
|
||||
filters: {
|
||||
brand: {
|
||||
name: "Nike" // ❌ Fails: brand is in a different module
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Using query.index() - Filter Across Linked Modules
|
||||
|
||||
To filter by properties of linked modules (separate modules with module links), use `query.index()` from the Index Module:
|
||||
|
||||
```typescript
|
||||
const query = container.resolve("query")
|
||||
|
||||
// ✅ Filter products by linked brand name using Index Module
|
||||
const { data: products } = await query.index({
|
||||
entity: "product",
|
||||
fields: ["*", "brand.*"],
|
||||
filters: {
|
||||
brand: {
|
||||
name: "Nike" // ✅ Works with Index Module!
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Key Distinction:**
|
||||
- **Same module relations** (e.g., Product → ProductVariant): Use `query.graph()` - filtering works ✅
|
||||
- **Different module links** (e.g., Product → Brand): Use `query.index()` for filtering ✅
|
||||
|
||||
**Index Module Requirements:**
|
||||
1. Install `@medusajs/index` package
|
||||
2. Add to `medusa-config.ts`
|
||||
3. Enable `MEDUSA_FF_INDEX_ENGINE=true` in `.env`
|
||||
4. Run `npx medusa db:migrate`
|
||||
5. Mark properties as `filterable` in link definition:
|
||||
|
||||
```typescript
|
||||
// src/links/product-brand.ts
|
||||
defineLink(
|
||||
{ linkable: ProductModule.linkable.product, isList: true },
|
||||
{ linkable: BrandModule.linkable.brand, filterable: ["id", "name"] }
|
||||
)
|
||||
```
|
||||
|
||||
See the [Querying Data reference](querying-data.md#querying-linked-data) for complete details on both methods.
|
||||
|
||||
## Advanced: Link with Custom Columns
|
||||
|
||||
Add extra data to the link table:
|
||||
|
||||
```typescript
|
||||
export default defineLink(
|
||||
ProductModule.linkable.product,
|
||||
BrandModule.linkable.brand,
|
||||
{
|
||||
database: {
|
||||
extraColumns: {
|
||||
featured: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Set custom column values when creating links:
|
||||
|
||||
```typescript
|
||||
await link.create({
|
||||
product: { product_id: "prod_123" },
|
||||
brand: { brand_id: "brand_456" },
|
||||
data: { featured: true },
|
||||
})
|
||||
```
|
||||
1014
.agents/skills/building-with-medusa/reference/querying-data.md
Normal file
1014
.agents/skills/building-with-medusa/reference/querying-data.md
Normal file
File diff suppressed because it is too large
Load Diff
417
.agents/skills/building-with-medusa/reference/scheduled-jobs.md
Normal file
417
.agents/skills/building-with-medusa/reference/scheduled-jobs.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# Scheduled Jobs
|
||||
|
||||
Scheduled jobs are asynchronous functions that run automatically at specified intervals during the Medusa application's runtime. Use them for tasks like syncing products to third-party services, sending periodic reports, or cleaning up stale data.
|
||||
|
||||
## Contents
|
||||
- [When to Use Scheduled Jobs](#when-to-use-scheduled-jobs)
|
||||
- [Creating a Scheduled Job](#creating-a-scheduled-job)
|
||||
- [Configuration Options](#configuration-options)
|
||||
- [Executing Workflows in Scheduled Jobs](#executing-workflows-in-scheduled-jobs)
|
||||
- [Cron Expression Examples](#cron-expression-examples)
|
||||
- [Best Practices](#best-practices)
|
||||
|
||||
## When to Use Scheduled Jobs
|
||||
|
||||
Use scheduled jobs when you need to perform actions **periodically**:
|
||||
|
||||
- ✅ Syncing data with third-party services on a schedule
|
||||
- ✅ Sending periodic reports (daily, weekly)
|
||||
- ✅ Cleaning up stale data (expired carts, old sessions)
|
||||
- ✅ Generating batch exports
|
||||
- ✅ Recalculating aggregated data
|
||||
|
||||
**Don't use scheduled jobs for:**
|
||||
- ❌ Reacting to events (use [subscribers](subscribers-and-events.md) instead)
|
||||
- ❌ One-time tasks (use workflows directly)
|
||||
- ❌ Real-time processing (use API routes + workflows)
|
||||
|
||||
**Scheduled Jobs vs Subscribers:**
|
||||
- **Scheduled Job**: Finds carts updated >24h ago and sends emails (polling pattern)
|
||||
- **Subscriber**: Reacts to `order.created` and sends an email (event-driven)
|
||||
|
||||
For most use cases, subscribers are preferred when you need to react to specific events.
|
||||
|
||||
## Creating a Scheduled Job
|
||||
|
||||
Create a TypeScript file in the `src/jobs/` directory:
|
||||
|
||||
```typescript
|
||||
// src/jobs/sync-products.ts
|
||||
import { MedusaContainer } from "@medusajs/framework/types"
|
||||
|
||||
export default async function syncProductsJob(container: MedusaContainer) {
|
||||
const logger = container.resolve("logger")
|
||||
|
||||
logger.info("Starting product sync...")
|
||||
|
||||
// Resolve services from container
|
||||
const productService = container.resolve("product")
|
||||
const myService = container.resolve("my-custom-service")
|
||||
|
||||
try {
|
||||
// Your job logic here
|
||||
const products = await productService.listProducts({ active: true })
|
||||
|
||||
for (const product of products) {
|
||||
// Process each product
|
||||
await myService.syncToExternalSystem(product)
|
||||
}
|
||||
|
||||
logger.info("Product sync completed successfully")
|
||||
} catch (error) {
|
||||
logger.error(`Product sync failed: ${error.message}`)
|
||||
// Don't throw - let the job complete and retry on next schedule
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
name: "sync-products-daily", // Unique name for the job
|
||||
schedule: "0 0 * * *", // Cron expression: midnight daily
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
```typescript
|
||||
export const config = {
|
||||
name: "my-job", // Required: unique identifier
|
||||
schedule: "* * * * *", // Required: cron expression
|
||||
numberOfExecutions: 3, // Optional: limit total scheduled executions
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Properties
|
||||
|
||||
- **name** (required): Unique identifier for the job across your application
|
||||
- **schedule** (required): Cron expression defining when to run
|
||||
- **numberOfExecutions** (optional): Maximum number of times to execute the job **according to its schedule**
|
||||
|
||||
**⚠️ CRITICAL - Understanding numberOfExecutions:**
|
||||
|
||||
`numberOfExecutions` limits how many times the job runs **on its schedule**, NOT immediately on server start.
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG UNDERSTANDING: This will NOT run immediately on server start
|
||||
export const config = {
|
||||
name: "test-job",
|
||||
schedule: "0 0 * * *", // Daily at midnight
|
||||
numberOfExecutions: 1, // Will run ONCE at the next midnight, not now!
|
||||
}
|
||||
|
||||
// ✅ CORRECT: To test a job immediately, use a frequent schedule
|
||||
export const config = {
|
||||
name: "test-job",
|
||||
schedule: "* * * * *", // Every minute
|
||||
numberOfExecutions: 1, // Will run once at the next minute
|
||||
}
|
||||
|
||||
// ✅ CORRECT: Testing with multiple runs
|
||||
export const config = {
|
||||
name: "test-job",
|
||||
schedule: "*/5 * * * *", // Every 5 minutes
|
||||
numberOfExecutions: 3, // Will run 3 times (at 0, 5, 10 minutes), then stop
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- The job waits for the first scheduled time before executing
|
||||
- `numberOfExecutions: 1` with a daily schedule means it runs once the next day
|
||||
- To test immediately, use a frequent schedule like `"* * * * *"` (every minute)
|
||||
- After reaching `numberOfExecutions`, the job stops running permanently
|
||||
|
||||
## Executing Workflows in Scheduled Jobs
|
||||
|
||||
**⚠️ BEST PRACTICE**: Use workflows for mutations in scheduled jobs. This ensures proper error handling and rollback capabilities.
|
||||
|
||||
```typescript
|
||||
// src/jobs/send-weekly-newsletter.ts
|
||||
import { MedusaContainer } from "@medusajs/framework/types"
|
||||
import { sendNewsletterWorkflow } from "../workflows/send-newsletter"
|
||||
|
||||
export default async function sendNewsletterJob(container: MedusaContainer) {
|
||||
const logger = container.resolve("logger")
|
||||
const query = container.resolve("query")
|
||||
|
||||
logger.info("Sending weekly newsletter...")
|
||||
|
||||
try {
|
||||
// Query for data
|
||||
const { data: customers } = await query.graph({
|
||||
entity: "customer",
|
||||
fields: ["id", "email"],
|
||||
filters: {
|
||||
newsletter_subscribed: true,
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`Found ${customers.length} subscribers`)
|
||||
|
||||
// Execute workflow
|
||||
await sendNewsletterWorkflow(container).run({
|
||||
input: {
|
||||
customer_ids: customers.map((c) => c.id),
|
||||
},
|
||||
})
|
||||
|
||||
logger.info("Newsletter sent successfully")
|
||||
} catch (error) {
|
||||
logger.error(`Newsletter job failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
name: "send-weekly-newsletter",
|
||||
schedule: "0 0 * * 0", // Every Sunday at midnight
|
||||
}
|
||||
```
|
||||
|
||||
## Cron Expression Examples
|
||||
|
||||
Cron format: `minute hour day-of-month month day-of-week`
|
||||
|
||||
```typescript
|
||||
// Every minute
|
||||
schedule: "* * * * *"
|
||||
|
||||
// Every 5 minutes
|
||||
schedule: "*/5 * * * *"
|
||||
|
||||
// Every hour at minute 0
|
||||
schedule: "0 * * * *"
|
||||
|
||||
// Every day at midnight (00:00)
|
||||
schedule: "0 0 * * *"
|
||||
|
||||
// Every day at 2:30 AM
|
||||
schedule: "30 2 * * *"
|
||||
|
||||
// Every Sunday at midnight
|
||||
schedule: "0 0 * * 0"
|
||||
|
||||
// Every Monday at 9 AM
|
||||
schedule: "0 9 * * 1"
|
||||
|
||||
// First day of every month at midnight
|
||||
schedule: "0 0 1 * *"
|
||||
|
||||
// Every weekday (Mon-Fri) at 6 PM
|
||||
schedule: "0 18 * * 1-5"
|
||||
|
||||
// Every 6 hours
|
||||
schedule: "0 */6 * * *"
|
||||
```
|
||||
|
||||
**Tip**: Use [crontab.guru](https://crontab.guru) to build and validate cron expressions.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Use Logging
|
||||
|
||||
```typescript
|
||||
export default async function myJob(container: MedusaContainer) {
|
||||
const logger = container.resolve("logger")
|
||||
|
||||
logger.info("Job started")
|
||||
|
||||
try {
|
||||
// Job logic
|
||||
logger.info("Job completed successfully")
|
||||
} catch (error) {
|
||||
logger.error(`Job failed: ${error.message}`, { error })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Handle Errors Gracefully
|
||||
|
||||
Don't throw errors at the top level - log them and let the job complete:
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Throws and stops execution
|
||||
export default async function myJob(container: MedusaContainer) {
|
||||
const service = container.resolve("my-service")
|
||||
const items = await service.getItems() // Might throw
|
||||
// Job stops if this throws
|
||||
}
|
||||
|
||||
// ✅ GOOD: Catches errors and logs
|
||||
export default async function myJob(container: MedusaContainer) {
|
||||
const logger = container.resolve("logger")
|
||||
|
||||
try {
|
||||
const service = container.resolve("my-service")
|
||||
const items = await service.getItems()
|
||||
// Process items
|
||||
} catch (error) {
|
||||
logger.error(`Job failed: ${error.message}`)
|
||||
// Job completes, will retry on next schedule
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Make Jobs Idempotent
|
||||
|
||||
Design jobs to be safely re-runnable:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Idempotent job
|
||||
export default async function syncProducts(container: MedusaContainer) {
|
||||
const logger = container.resolve("logger")
|
||||
const myService = container.resolve("my-service")
|
||||
|
||||
// Check what's already synced
|
||||
const lastSyncTime = await myService.getLastSyncTime()
|
||||
|
||||
// Only sync products updated since last sync
|
||||
const { data: products } = await query.graph({
|
||||
entity: "product",
|
||||
filters: {
|
||||
updated_at: { $gte: lastSyncTime },
|
||||
},
|
||||
})
|
||||
|
||||
// Sync products (upsert, don't insert)
|
||||
for (const product of products) {
|
||||
await myService.upsertToExternalSystem(product)
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
await myService.setLastSyncTime(new Date())
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Use Workflows for Mutations
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Uses workflow for mutations
|
||||
import { deleteCartsWorkflow } from "../workflows/delete-carts"
|
||||
|
||||
export default async function cleanupExpiredCarts(container: MedusaContainer) {
|
||||
const logger = container.resolve("logger")
|
||||
const query = container.resolve("query")
|
||||
|
||||
// Find expired carts
|
||||
const { data: carts } = await query.graph({
|
||||
entity: "cart",
|
||||
fields: ["id"],
|
||||
filters: {
|
||||
updated_at: {
|
||||
$lte: new Date(Date.now() - 24 * 60 * 60 * 1000), // 24 hours ago
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`Found ${carts.length} expired carts`)
|
||||
|
||||
// Use workflow for deletion (import at top of file)
|
||||
await deleteCartsWorkflow(container).run({
|
||||
input: {
|
||||
cart_ids: carts.map((c) => c.id),
|
||||
},
|
||||
})
|
||||
|
||||
logger.info("Expired carts cleaned up")
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Add Metrics/Monitoring
|
||||
|
||||
```typescript
|
||||
export default async function myJob(container: MedusaContainer) {
|
||||
const logger = container.resolve("logger")
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// Job logic
|
||||
const processed = 100 // Track what you processed
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
logger.info(`Job completed: ${processed} items in ${duration}ms`)
|
||||
} catch (error) {
|
||||
logger.error(`Job failed after ${Date.now() - startTime}ms`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Test with Limited Executions
|
||||
|
||||
When testing, use a frequent schedule with limited executions:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT: Frequent schedule for immediate testing
|
||||
export const config = {
|
||||
name: "test-job",
|
||||
schedule: "* * * * *", // Every minute
|
||||
numberOfExecutions: 3, // Run 3 times (next 3 minutes), then stop
|
||||
}
|
||||
|
||||
// ❌ WRONG: This won't help with testing
|
||||
export const config = {
|
||||
name: "test-job",
|
||||
schedule: "0 0 * * *", // Daily at midnight
|
||||
numberOfExecutions: 1, // Will only run ONCE at next midnight, not useful for testing
|
||||
}
|
||||
```
|
||||
|
||||
**Remember**: `numberOfExecutions` doesn't make the job run immediately - it limits how many times it runs on its schedule.
|
||||
|
||||
## Complete Example: Abandoned Cart Email Job
|
||||
|
||||
```typescript
|
||||
// src/jobs/send-abandoned-cart-emails.ts
|
||||
import { MedusaContainer } from "@medusajs/framework/types"
|
||||
import { sendAbandonedCartEmailWorkflow } from "../workflows/send-abandoned-cart-email"
|
||||
|
||||
export default async function abandonedCartEmailJob(
|
||||
container: MedusaContainer
|
||||
) {
|
||||
const logger = container.resolve("logger")
|
||||
const query = container.resolve("query")
|
||||
|
||||
logger.info("Starting abandoned cart email job...")
|
||||
|
||||
try {
|
||||
// Find carts updated more than 24 hours ago that haven't completed
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
|
||||
const { data: carts } = await query.graph({
|
||||
entity: "cart",
|
||||
fields: ["id", "email", "customer_id"],
|
||||
filters: {
|
||||
updated_at: {
|
||||
$lte: twentyFourHoursAgo,
|
||||
},
|
||||
completed_at: null,
|
||||
email: { $ne: null }, // Must have email
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`Found ${carts.length} abandoned carts`)
|
||||
|
||||
// Process in batches
|
||||
for (const cart of carts) {
|
||||
try {
|
||||
await sendAbandonedCartEmailWorkflow(container).run({
|
||||
input: {
|
||||
cart_id: cart.id,
|
||||
email: cart.email,
|
||||
},
|
||||
})
|
||||
logger.info(`Sent email for cart ${cart.id}`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to send email for cart ${cart.id}: ${error.message}`)
|
||||
// Continue with other carts
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Abandoned cart email job completed")
|
||||
} catch (error) {
|
||||
logger.error(`Abandoned cart job failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
name: "send-abandoned-cart-emails",
|
||||
schedule: "0 */6 * * *", // Every 6 hours
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,544 @@
|
||||
# Subscribers and Events
|
||||
|
||||
Subscribers are asynchronous functions that execute when specific events are emitted. Use them to perform actions after commerce operations, like sending confirmation emails when an order is placed.
|
||||
|
||||
## Contents
|
||||
- [When to Use Subscribers](#when-to-use-subscribers)
|
||||
- [Creating a Subscriber](#creating-a-subscriber)
|
||||
- [Common Commerce Events](#common-commerce-events)
|
||||
- [Accessing Event Data](#accessing-event-data)
|
||||
- [Triggering Custom Events](#triggering-custom-events)
|
||||
- [Best Practices](#best-practices)
|
||||
|
||||
## When to Use Subscribers
|
||||
|
||||
Use subscribers when you need to **react to events** that happen in your application:
|
||||
|
||||
- ✅ Send confirmation emails when orders are placed
|
||||
- ✅ Sync data to external systems when products are updated
|
||||
- ✅ Trigger webhooks when entities change
|
||||
- ✅ Update analytics when customers are created
|
||||
- ✅ Perform non-blocking side effects
|
||||
|
||||
**Don't use subscribers for:**
|
||||
- ❌ Periodic tasks (use [scheduled jobs](scheduled-jobs.md) instead)
|
||||
- ❌ Operations that must block the main flow (use workflows instead)
|
||||
- ❌ Scheduling future tasks (subscribers execute immediately)
|
||||
|
||||
**Subscribers vs Scheduled Jobs:**
|
||||
- **Subscriber**: Reacts to `order.placed` event and sends confirmation email (event-driven)
|
||||
- **Scheduled Job**: Finds abandoned carts every 6 hours and sends emails (polling pattern)
|
||||
|
||||
## Creating a Subscriber
|
||||
|
||||
Create a TypeScript file in the `src/subscribers/` directory:
|
||||
|
||||
```typescript
|
||||
// src/subscribers/order-placed.ts
|
||||
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
|
||||
|
||||
export default async function orderPlacedHandler({
|
||||
event: { eventName, data },
|
||||
container,
|
||||
}: SubscriberArgs<{ id: string }>) {
|
||||
const logger = container.resolve("logger")
|
||||
|
||||
logger.info(`Order ${data.id} was placed`)
|
||||
|
||||
// Resolve services
|
||||
const orderService = container.resolve("order")
|
||||
const notificationService = container.resolve("notification")
|
||||
|
||||
// Retrieve full order data
|
||||
const order = await orderService.retrieveOrder(data.id, {
|
||||
relations: ["customer", "items"],
|
||||
})
|
||||
|
||||
// Send confirmation email
|
||||
await notificationService.createNotifications({
|
||||
to: order.customer.email,
|
||||
template: "order-confirmation",
|
||||
channel: "email",
|
||||
data: { order },
|
||||
})
|
||||
|
||||
logger.info(`Confirmation email sent for order ${data.id}`)
|
||||
}
|
||||
|
||||
export const config: SubscriberConfig = {
|
||||
event: "order.placed", // Single event
|
||||
}
|
||||
```
|
||||
|
||||
### Listening to Multiple Events
|
||||
|
||||
```typescript
|
||||
// src/subscribers/product-changes.ts
|
||||
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
|
||||
|
||||
export default async function productChangesHandler({
|
||||
event: { eventName, data },
|
||||
container,
|
||||
}: SubscriberArgs<{ id: string }>) {
|
||||
const logger = container.resolve("logger")
|
||||
|
||||
logger.info(`Product event: ${eventName} for product ${data.id}`)
|
||||
|
||||
// Handle different events
|
||||
switch (eventName) {
|
||||
case "product.created":
|
||||
// Handle product creation
|
||||
break
|
||||
case "product.updated":
|
||||
// Handle product update
|
||||
break
|
||||
case "product.deleted":
|
||||
// Handle product deletion
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
export const config: SubscriberConfig = {
|
||||
event: ["product.created", "product.updated", "product.deleted"],
|
||||
}
|
||||
```
|
||||
|
||||
## Common Commerce Events
|
||||
|
||||
**⚠️ IMPORTANT**: Event data typically contains only the ID of the affected entity. You must retrieve the full data if needed.
|
||||
|
||||
### Order Events
|
||||
|
||||
```typescript
|
||||
"order.placed" // Order was placed
|
||||
"order.updated" // Order was updated
|
||||
"order.canceled" // Order was canceled
|
||||
"order.completed" // Order was completed
|
||||
"order.shipment_created" // Shipment was created for order
|
||||
```
|
||||
|
||||
### Product Events
|
||||
|
||||
```typescript
|
||||
"product.created" // Product was created
|
||||
"product.updated" // Product was updated
|
||||
"product.deleted" // Product was deleted
|
||||
```
|
||||
|
||||
### Customer Events
|
||||
|
||||
```typescript
|
||||
"customer.created" // Customer was created
|
||||
"customer.updated" // Customer was updated
|
||||
```
|
||||
|
||||
### Cart Events
|
||||
|
||||
```typescript
|
||||
"cart.created" // Cart was created
|
||||
"cart.updated" // Cart was updated
|
||||
```
|
||||
|
||||
### Auth Events
|
||||
|
||||
```typescript
|
||||
"auth.password_reset" // Password reset was requested
|
||||
```
|
||||
|
||||
### Invite Events
|
||||
|
||||
```typescript
|
||||
"invite.created" // Invite was created (for admin users)
|
||||
```
|
||||
|
||||
**For a complete list of events**, ask MedusaDocs for the specific module's events.
|
||||
|
||||
## Accessing Event Data
|
||||
|
||||
### Event Data Structure
|
||||
|
||||
```typescript
|
||||
interface SubscriberArgs<T> {
|
||||
event: {
|
||||
eventName: string // e.g., "order.placed"
|
||||
data: T // Event payload (usually contains { id: string })
|
||||
}
|
||||
container: MedusaContainer // DI container
|
||||
}
|
||||
```
|
||||
|
||||
### Retrieving Full Entity Data
|
||||
|
||||
**⚠️ IMPORTANT**: The `data` object typically only contains the entity ID. Retrieve the full entity data using services or query:
|
||||
|
||||
```typescript
|
||||
// src/subscribers/order-placed.ts
|
||||
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
|
||||
|
||||
export default async function orderPlacedHandler({
|
||||
event: { data },
|
||||
container,
|
||||
}: SubscriberArgs<{ id: string }>) {
|
||||
const logger = container.resolve("logger")
|
||||
const query = container.resolve("query")
|
||||
|
||||
// data.id contains the order ID
|
||||
logger.info(`Handling order.placed event for order: ${data.id}`)
|
||||
|
||||
// Retrieve full order data with relations
|
||||
const { data: orders } = await query.graph({
|
||||
entity: "order",
|
||||
fields: [
|
||||
"id",
|
||||
"email",
|
||||
"total",
|
||||
"customer.*",
|
||||
"items.*",
|
||||
"items.product.*",
|
||||
],
|
||||
filters: {
|
||||
id: data.id,
|
||||
},
|
||||
})
|
||||
|
||||
const order = orders[0]
|
||||
|
||||
// Now you have the full order data
|
||||
logger.info(`Order total: ${order.total}`)
|
||||
logger.info(`Customer email: ${order.customer.email}`)
|
||||
}
|
||||
|
||||
export const config: SubscriberConfig = {
|
||||
event: "order.placed",
|
||||
}
|
||||
```
|
||||
|
||||
### Using Module Services
|
||||
|
||||
```typescript
|
||||
export default async function productUpdatedHandler({
|
||||
event: { data },
|
||||
container,
|
||||
}: SubscriberArgs<{ id: string }>) {
|
||||
const productService = container.resolve("product")
|
||||
|
||||
// Retrieve product using service
|
||||
const product = await productService.retrieveProduct(data.id, {
|
||||
select: ["id", "title", "status"],
|
||||
relations: ["variants"],
|
||||
})
|
||||
|
||||
// Process product
|
||||
}
|
||||
```
|
||||
|
||||
## Triggering Custom Events
|
||||
|
||||
Emit custom events from workflows using the `emitEventStep`:
|
||||
|
||||
```typescript
|
||||
// src/workflows/create-review.ts
|
||||
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
|
||||
import { emitEventStep } from "@medusajs/medusa/core-flows"
|
||||
|
||||
const createReviewWorkflow = createWorkflow(
|
||||
"create-review",
|
||||
function (input: { product_id: string; rating: number }) {
|
||||
// Create review step
|
||||
const review = createReviewStep(input)
|
||||
|
||||
// Emit custom event
|
||||
emitEventStep({
|
||||
eventName: "review.created",
|
||||
data: {
|
||||
id: review.id,
|
||||
product_id: input.product_id,
|
||||
rating: input.rating,
|
||||
},
|
||||
})
|
||||
|
||||
return new WorkflowResponse({ review })
|
||||
}
|
||||
)
|
||||
|
||||
export default createReviewWorkflow
|
||||
```
|
||||
|
||||
Then create a subscriber for the custom event:
|
||||
|
||||
```typescript
|
||||
// src/subscribers/review-created.ts
|
||||
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
|
||||
|
||||
export default async function reviewCreatedHandler({
|
||||
event: { data },
|
||||
container,
|
||||
}: SubscriberArgs<{ id: string; product_id: string; rating: number }>) {
|
||||
const logger = container.resolve("logger")
|
||||
const query = container.resolve("query")
|
||||
|
||||
logger.info(`Review ${data.id} created for product ${data.product_id}`)
|
||||
|
||||
// If rating is low, notify support
|
||||
if (data.rating <= 2) {
|
||||
const notificationService = container.resolve("notification")
|
||||
await notificationService.createNotifications({
|
||||
to: "support@example.com",
|
||||
template: "low-rating-alert",
|
||||
channel: "email",
|
||||
data: {
|
||||
review_id: data.id,
|
||||
product_id: data.product_id,
|
||||
rating: data.rating,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const config: SubscriberConfig = {
|
||||
event: "review.created",
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Use Logging
|
||||
|
||||
```typescript
|
||||
export default async function mySubscriber({
|
||||
event: { eventName, data },
|
||||
container,
|
||||
}: SubscriberArgs<{ id: string }>) {
|
||||
const logger = container.resolve("logger")
|
||||
|
||||
logger.info(`Handling ${eventName} for ${data.id}`)
|
||||
|
||||
try {
|
||||
// Subscriber logic
|
||||
logger.info(`Successfully handled ${eventName}`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to handle ${eventName}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Handle Errors Gracefully
|
||||
|
||||
Subscribers run asynchronously and don't block the main flow. Log errors but don't throw:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Catches errors and logs
|
||||
export default async function mySubscriber({
|
||||
event: { data },
|
||||
container,
|
||||
}: SubscriberArgs<{ id: string }>) {
|
||||
const logger = container.resolve("logger")
|
||||
|
||||
try {
|
||||
// Subscriber logic that might fail
|
||||
await sendEmail(data.id)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to send email: ${error.message}`)
|
||||
// Don't throw - subscriber completes gracefully
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Keep Subscribers Fast and Non-Blocking
|
||||
|
||||
Subscribers should perform quick operations. For long-running tasks, consider:
|
||||
- Queuing the task for background processing
|
||||
- Using scheduled jobs instead
|
||||
- Breaking the work into smaller steps
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Quick operation
|
||||
export default async function orderPlacedHandler({
|
||||
event: { data },
|
||||
container,
|
||||
}: SubscriberArgs<{ id: string }>) {
|
||||
const notificationService = container.resolve("notification")
|
||||
|
||||
// Quick: Queue email for sending
|
||||
await notificationService.createNotifications({
|
||||
to: "customer@example.com",
|
||||
template: "order-confirmation",
|
||||
channel: "email",
|
||||
data: { order_id: data.id },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Use Workflows for Mutations
|
||||
|
||||
If your subscriber needs to perform mutations, use workflows:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Uses workflow for mutations
|
||||
import { syncProductWorkflow } from "../workflows/sync-product"
|
||||
|
||||
export default async function productCreatedHandler({
|
||||
event: { data },
|
||||
container,
|
||||
}: SubscriberArgs<{ id: string }>) {
|
||||
const logger = container.resolve("logger")
|
||||
|
||||
// Execute workflow to sync to external system
|
||||
try {
|
||||
await syncProductWorkflow(container).run({
|
||||
input: { product_id: data.id },
|
||||
})
|
||||
logger.info(`Product ${data.id} synced successfully`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to sync product ${data.id}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Avoid Infinite Event Loops
|
||||
|
||||
Be careful when subscribing to events that trigger more events:
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Can cause infinite loop
|
||||
export default async function productUpdatedHandler({
|
||||
event: { data },
|
||||
container,
|
||||
}: SubscriberArgs<{ id: string }>) {
|
||||
const productService = container.resolve("product")
|
||||
|
||||
// This triggers another product.updated event!
|
||||
await productService.updateProducts({
|
||||
id: data.id,
|
||||
metadata: { last_updated: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
// ✅ GOOD: Add guard condition
|
||||
export default async function productUpdatedHandler({
|
||||
event: { data },
|
||||
container,
|
||||
}: SubscriberArgs<{ id: string }>) {
|
||||
const logger = container.resolve("logger")
|
||||
const query = container.resolve("query")
|
||||
|
||||
// Retrieve product to check if we should update
|
||||
const { data: products } = await query.graph({
|
||||
entity: "product",
|
||||
fields: ["id", "metadata"],
|
||||
filters: { id: data.id },
|
||||
})
|
||||
|
||||
const product = products[0]
|
||||
|
||||
// Guard: Only update if not already processed
|
||||
if (!product.metadata?.processed) {
|
||||
const productService = container.resolve("product")
|
||||
await productService.updateProducts({
|
||||
id: data.id,
|
||||
metadata: { processed: true },
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Make Subscribers Idempotent
|
||||
|
||||
Subscribers might be called multiple times for the same event. Design them to handle this:
|
||||
|
||||
```typescript
|
||||
export default async function orderPlacedHandler({
|
||||
event: { data },
|
||||
container,
|
||||
}: SubscriberArgs<{ id: string }>) {
|
||||
const logger = container.resolve("logger")
|
||||
const myService = container.resolve("my-service")
|
||||
|
||||
// Check if we've already processed this order
|
||||
const processed = await myService.isOrderProcessed(data.id)
|
||||
|
||||
if (processed) {
|
||||
logger.info(`Order ${data.id} already processed, skipping`)
|
||||
return
|
||||
}
|
||||
|
||||
// Process order
|
||||
await myService.processOrder(data.id)
|
||||
|
||||
// Mark as processed
|
||||
await myService.markOrderAsProcessed(data.id)
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example: Order Confirmation Email
|
||||
|
||||
```typescript
|
||||
// src/subscribers/order-placed.ts
|
||||
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
|
||||
|
||||
export default async function sendOrderConfirmationEmail({
|
||||
event: { data },
|
||||
container,
|
||||
}: SubscriberArgs<{ id: string }>) {
|
||||
const logger = container.resolve("logger")
|
||||
|
||||
logger.info(`Sending order confirmation for order: ${data.id}`)
|
||||
|
||||
try {
|
||||
const query = container.resolve("query")
|
||||
|
||||
// Retrieve full order data
|
||||
const { data: orders } = await query.graph({
|
||||
entity: "order",
|
||||
fields: [
|
||||
"id",
|
||||
"display_id",
|
||||
"email",
|
||||
"total",
|
||||
"currency_code",
|
||||
"customer.first_name",
|
||||
"customer.last_name",
|
||||
"items.*",
|
||||
"items.product.title",
|
||||
"shipping_address.*",
|
||||
],
|
||||
filters: {
|
||||
id: data.id,
|
||||
},
|
||||
})
|
||||
|
||||
if (!orders || orders.length === 0) {
|
||||
logger.error(`Order ${data.id} not found`)
|
||||
return
|
||||
}
|
||||
|
||||
const order = orders[0]
|
||||
|
||||
// Send confirmation email
|
||||
const notificationService = container.resolve("notification")
|
||||
await notificationService.createNotifications({
|
||||
to: order.email,
|
||||
template: "order-confirmation",
|
||||
channel: "email",
|
||||
data: {
|
||||
order_id: order.display_id,
|
||||
customer_name: `${order.customer.first_name} ${order.customer.last_name}`,
|
||||
items: order.items,
|
||||
total: order.total,
|
||||
currency: order.currency_code,
|
||||
shipping_address: order.shipping_address,
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`Order confirmation email sent to ${order.email}`)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to send order confirmation for ${data.id}: ${error.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const config: SubscriberConfig = {
|
||||
event: "order.placed",
|
||||
}
|
||||
```
|
||||
225
.agents/skills/building-with-medusa/reference/troubleshooting.md
Normal file
225
.agents/skills/building-with-medusa/reference/troubleshooting.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Troubleshooting Common Medusa Backend Issues
|
||||
|
||||
This guide covers common errors and their solutions when building with Medusa.
|
||||
|
||||
## Contents
|
||||
- [Module Registration Errors](#module-registration-errors)
|
||||
- [API Route Errors](#api-route-errors)
|
||||
- [Authentication Errors](#authentication-errors)
|
||||
- [General Debugging Tips](#general-debugging-tips)
|
||||
|
||||
## Module Registration Errors
|
||||
|
||||
### Error: Module "X" not registered
|
||||
|
||||
```
|
||||
Error: Module "my-module" is not registered in the container
|
||||
```
|
||||
|
||||
**Cause**: Module not added to `medusa-config.ts` or server not restarted.
|
||||
|
||||
**Solution**:
|
||||
1. Add module to `medusa-config.ts`:
|
||||
```typescript
|
||||
module.exports = defineConfig({
|
||||
modules: [
|
||||
{ resolve: "./src/modules/my-module" }
|
||||
],
|
||||
})
|
||||
```
|
||||
2. Restart the Medusa server
|
||||
|
||||
### Error: Cannot find module './modules/X'
|
||||
|
||||
```
|
||||
Error: Cannot find module './modules/my-module'
|
||||
```
|
||||
|
||||
**Cause**: Module path is incorrect or module structure is incomplete.
|
||||
|
||||
**Solution**:
|
||||
1. Verify module structure:
|
||||
```
|
||||
src/modules/my-module/
|
||||
├── models/
|
||||
│ └── my-model.ts
|
||||
├── service.ts
|
||||
└── index.ts
|
||||
```
|
||||
2. Ensure `index.ts` exports the module correctly
|
||||
3. Check path in `medusa-config.ts` matches actual directory
|
||||
|
||||
## API Route Errors
|
||||
|
||||
### Error: validatedBody is undefined
|
||||
|
||||
```
|
||||
TypeError: Cannot read property 'email' of undefined
|
||||
```
|
||||
|
||||
**Cause**: Forgot to add validation middleware or accessing `req.validatedBody` instead of `req.body`.
|
||||
|
||||
**Solution**:
|
||||
1. Add validation middleware:
|
||||
```typescript
|
||||
// middlewares.ts
|
||||
export const myMiddlewares: MiddlewareRoute[] = [
|
||||
{
|
||||
matcher: "/store/my-route",
|
||||
method: "POST",
|
||||
middlewares: [validateAndTransformBody(MySchema)],
|
||||
},
|
||||
]
|
||||
```
|
||||
2. Access `req.validatedBody` not `req.body`
|
||||
|
||||
### Error: queryConfig is undefined
|
||||
|
||||
```
|
||||
TypeError: Cannot spread undefined
|
||||
```
|
||||
|
||||
**Cause**: Using `...req.queryConfig` without setting up query config middleware.
|
||||
|
||||
**Solution**:
|
||||
Add `validateAndTransformQuery` middleware:
|
||||
```typescript
|
||||
import { createFindParams } from "@medusajs/medusa/api/utils/validators"
|
||||
|
||||
export const GetMyItemsSchema = createFindParams()
|
||||
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
{
|
||||
matcher: "/store/my-items",
|
||||
method: "GET",
|
||||
middlewares: [
|
||||
validateAndTransformQuery(GetMyItemsSchema, {
|
||||
defaults: ["id", "name"],
|
||||
isList: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Error: MedusaError not being formatted
|
||||
|
||||
```
|
||||
Error: [object Object]
|
||||
```
|
||||
|
||||
**Cause**: Throwing regular `Error` instead of `MedusaError`.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
throw new Error("Not found")
|
||||
|
||||
// ✅ CORRECT
|
||||
import { MedusaError } from "@medusajs/framework/utils"
|
||||
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Not found")
|
||||
```
|
||||
|
||||
### Error: Middleware not applying
|
||||
|
||||
```
|
||||
Error: Route is not being validated
|
||||
```
|
||||
|
||||
**Cause**: Middleware matcher doesn't match route path or middleware not registered.
|
||||
|
||||
**Solution**:
|
||||
1. Check matcher pattern matches your route:
|
||||
```typescript
|
||||
// For route: /store/my-route
|
||||
matcher: "/store/my-route" // Exact match
|
||||
|
||||
// For multiple routes: /store/my-route, /store/my-route/123
|
||||
matcher: "/store/my-route*" // Wildcard
|
||||
```
|
||||
2. Ensure middleware is exported and registered in `api/middlewares.ts`
|
||||
|
||||
## Authentication Errors
|
||||
|
||||
### Error: auth_context is undefined
|
||||
|
||||
```
|
||||
TypeError: Cannot read property 'actor_id' of undefined
|
||||
```
|
||||
|
||||
**Cause**: Route is not protected or user is not authenticated.
|
||||
|
||||
**Solution**:
|
||||
1. Check if route is under protected prefix (`/admin/*` or `/store/customers/me/*`)
|
||||
2. If custom prefix, add authentication middleware:
|
||||
```typescript
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
{
|
||||
matcher: "/custom/admin*",
|
||||
middlewares: [authenticate("user", ["session", "bearer", "api-key"])],
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
3. For optional auth, check if `auth_context` exists:
|
||||
```typescript
|
||||
const userId = req.auth_context?.actor_id
|
||||
if (!userId) {
|
||||
// Handle unauthenticated case
|
||||
}
|
||||
```
|
||||
|
||||
## General Debugging Tips
|
||||
|
||||
### Enable Debug Logging
|
||||
|
||||
```bash
|
||||
# Set log level to debug
|
||||
LOG_LEVEL=debug npx medusa develop
|
||||
```
|
||||
|
||||
### Log Values In Workflows with Transform
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createStep,
|
||||
createWorkflow,
|
||||
StepResponse,
|
||||
WorkflowResponse,
|
||||
transform,
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
|
||||
const step1 = createStep(
|
||||
"step-1",
|
||||
async () => {
|
||||
const message = "Hello from step 1!"
|
||||
|
||||
return new StepResponse(
|
||||
message
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export const myWorkflow = createWorkflow(
|
||||
"my-workflow",
|
||||
() => {
|
||||
const response = step1()
|
||||
|
||||
const transformedMessage = transform(
|
||||
{ response },
|
||||
(data) => {
|
||||
const upperCase = data.response.toUpperCase()
|
||||
console.log("Transformed Data:", upperCase)
|
||||
return upperCase
|
||||
}
|
||||
)
|
||||
|
||||
return new WorkflowResponse({
|
||||
response: transformedMessage,
|
||||
})
|
||||
}
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,63 @@
|
||||
# Workflow Hooks (Advanced)
|
||||
|
||||
Workflow hooks let you inject custom logic into existing Medusa workflows without recreating them. Use them to extend core commerce flows.
|
||||
|
||||
**Note:** Hooks run in-band (synchronously within the workflow). If your task can run in the background, use a subscriber instead for better performance.
|
||||
|
||||
## Basic Hook Pattern
|
||||
|
||||
```typescript
|
||||
// src/workflows/hooks/product-created.ts
|
||||
import { createProductsWorkflow } from "@medusajs/medusa/core-flows"
|
||||
import { StepResponse } from "@medusajs/framework/workflows-sdk"
|
||||
|
||||
createProductsWorkflow.hooks.productsCreated(
|
||||
// Hook handler
|
||||
async ({ products, additional_data }, { container }) => {
|
||||
if (!additional_data?.brand_id) {
|
||||
return new StepResponse([], [])
|
||||
}
|
||||
|
||||
const link = container.resolve("link")
|
||||
|
||||
// Link products to brand
|
||||
const linkData = products.map((product) => ({
|
||||
product: { product_id: product.id },
|
||||
brand: { brand_id: additional_data.brand_id },
|
||||
}))
|
||||
|
||||
await link.create(linkData)
|
||||
return new StepResponse(linkData, linkData)
|
||||
},
|
||||
// Compensation (runs if workflow fails after this point)
|
||||
async (linkData, { container }) => {
|
||||
const link = container.resolve("link")
|
||||
await link.dismiss(linkData)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Common Workflow Hooks
|
||||
|
||||
- `createProductsWorkflow.hooks.productsCreated` - After products are created
|
||||
- `createOrderWorkflow.hooks.orderCreated` - After an order is created
|
||||
- Ask MedusaDocs for specific workflow hooks and their input parameters
|
||||
|
||||
## When to Use Hooks vs Subscribers
|
||||
|
||||
**Use workflow hooks when:**
|
||||
- The logic must complete before the workflow finishes
|
||||
- You need rollback/compensation capabilities
|
||||
- The operation is critical to the workflow's success
|
||||
|
||||
**Use subscribers when:**
|
||||
- The logic can run asynchronously in the background
|
||||
- You don't need to block the main workflow
|
||||
- Better performance is needed (hooks are synchronous)
|
||||
|
||||
## Hook Best Practices
|
||||
|
||||
1. **Return StepResponse**: Always wrap your return value
|
||||
2. **Implement compensation**: Provide rollback logic for the compensation function
|
||||
3. **Handle missing data gracefully**: Check for optional data and return early if not present
|
||||
4. **Keep hooks lightweight**: For heavy operations, consider using subscribers instead
|
||||
516
.agents/skills/building-with-medusa/reference/workflows.md
Normal file
516
.agents/skills/building-with-medusa/reference/workflows.md
Normal file
@@ -0,0 +1,516 @@
|
||||
# Creating Workflows
|
||||
|
||||
Workflows are the standard way to perform mutations (create, update, delete) in modules in Medusa. If you have built a custom module and need to perform mutations on models in the module, you should create a workflow.
|
||||
|
||||
## Creating Workflows - Implementation Checklist
|
||||
|
||||
**IMPORTANT FOR CLAUDE CODE**: When implementing workflows, use the TodoWrite tool to track your progress through these steps. This ensures you don't miss any critical steps and provides visibility to the user.
|
||||
|
||||
Create these tasks in your todo list:
|
||||
|
||||
- Define the input type for your workflow
|
||||
- Create step function (one mutation per step)
|
||||
- Add compensation function to steps for rollback
|
||||
- Create workflow composition function
|
||||
- Follow workflow composition rules (no async, no arrow functions, etc.)
|
||||
- Return WorkflowResponse with results
|
||||
- Test idempotency (workflow can be retried safely)
|
||||
- **CRITICAL: Run build to validate implementation** (catches type errors and issues)
|
||||
|
||||
## Basic Workflow Structure
|
||||
|
||||
**File Organization:**
|
||||
- **Recommended**: Create workflow steps in `src/workflows/steps/[step-name].ts`
|
||||
- Workflow composition functions go in `src/workflows/[workflow-name].ts`
|
||||
- This keeps steps reusable and organized
|
||||
|
||||
```typescript
|
||||
// src/workflows/steps/create-my-model.ts
|
||||
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
||||
|
||||
type Input = {
|
||||
my_key: string
|
||||
}
|
||||
|
||||
// Note: a step should only do one mutation this ensures rollback mechanisms work
|
||||
// For workflows that retry build your steps to be idempotent
|
||||
export const createMyModelStep = createStep(
|
||||
"create-my-model",
|
||||
async (input: Input, { container }) => {
|
||||
const myModule = container.resolve("my")
|
||||
|
||||
const [newMy] = await myModule.createMyModels({
|
||||
...input,
|
||||
})
|
||||
|
||||
return new StepResponse(
|
||||
newMy,
|
||||
newMy.id // explicit compensation input - otherwise defaults to step's output
|
||||
)
|
||||
},
|
||||
// Optional compensation function
|
||||
async (id, { container }) => {
|
||||
const myModule = container.resolve("my")
|
||||
await myModule.deleteMyModels(id)
|
||||
}
|
||||
)
|
||||
|
||||
// src/workflows/create-my-model.ts
|
||||
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
|
||||
import { createMyModelStep } from "./steps/create-my-model"
|
||||
|
||||
type Input = {
|
||||
my_key: string
|
||||
}
|
||||
|
||||
const createMyModel = createWorkflow(
|
||||
"create-my-model",
|
||||
// Note: See "Workflow Composition Rules" section below for important constraints
|
||||
// The workflow function must be a regular synchronous function (not async/arrow)
|
||||
// No direct variable manipulation, conditionals, or date creation - use transform/when instead
|
||||
function (input: Input) {
|
||||
const newMy = createMyModelStep(input)
|
||||
|
||||
return new WorkflowResponse({
|
||||
newMy,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export default createMyModel
|
||||
```
|
||||
|
||||
## Workflow Composition Rules
|
||||
|
||||
The workflow composition function runs at application load time and has important limitations:
|
||||
|
||||
### Function Declaration
|
||||
- ✅ Use regular synchronous functions
|
||||
- ❌ No `async` functions
|
||||
- ❌ No arrow functions (use `function` keyword)
|
||||
|
||||
### Using Steps Multiple Times
|
||||
|
||||
**⚠️ CRITICAL**: When using the same step multiple times in a workflow, you MUST rename each invocation AFTER the first invocation using `.config()` to avoid conflicts.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Rename each step invocation with .config()
|
||||
export const processCustomersWorkflow = createWorkflow(
|
||||
"process-customers",
|
||||
function (input) {
|
||||
const customers = transform({ ids: input.customer_ids }, (input) => input.ids)
|
||||
|
||||
// First invocation - no need to rename
|
||||
const customer1 = fetchCustomerStep(customers[0])
|
||||
|
||||
// Second invocation - different name
|
||||
const customer2 = fetchCustomerStep(customers[1]).config({
|
||||
name: "fetch-customer-2"
|
||||
})
|
||||
|
||||
const result = transform({ customer1, customer2 }, (data) => ({
|
||||
customers: [data.customer1, data.customer2]
|
||||
}))
|
||||
|
||||
return new WorkflowResponse(result)
|
||||
}
|
||||
)
|
||||
|
||||
// ❌ WRONG - Calling the same step multiple times without renaming
|
||||
export const processCustomersWorkflow = createWorkflow(
|
||||
"process-customers",
|
||||
function (input) {
|
||||
const customers = transform({ ids: input.customer_ids }, (input) => input.ids)
|
||||
|
||||
// This will cause runtime errors - duplicate step names
|
||||
const customer1 = fetchCustomerStep(customers[0])
|
||||
const customer2 = fetchCustomerStep(customers[1]) // ❌ Conflict!
|
||||
|
||||
return new WorkflowResponse({ customers: [customer1, customer2] })
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
- Medusa uses step names to track execution state
|
||||
- Duplicate names cause conflicts in the workflow execution engine
|
||||
- Each step invocation needs a unique identifier
|
||||
- The workflow will fail at runtime if steps aren't renamed
|
||||
|
||||
### Variable Operations
|
||||
- ❌ No direct variable manipulation or concatenation → Use `transform({ in }, ({ in }) => \`Transformed: ${in}\`)` instead
|
||||
- Variables lack values until execution time - all operations must use `transform()`
|
||||
|
||||
### Date/Time Operations
|
||||
- ❌ No `new Date()` (will be fixed to load time) → Wrap in `transform()` for execution-time evaluation
|
||||
|
||||
### Conditional Logic
|
||||
- ❌ No `if`/`else` statements → Use `when(input, (input) => input.is_active).then(() => { /* steps */ })` instead
|
||||
- ❌ No ternary operators (`? :`) → Use `transform()` instead
|
||||
- ❌ No nullish coalescing (`??`) → Use `transform()` instead
|
||||
- ❌ No logical OR (`||`) → Use `transform()` instead
|
||||
- ❌ No optional chaining (`?.`) → Use `transform()` instead
|
||||
- ❌ No double negation (`!!`) → Use `transform()` instead
|
||||
|
||||
### Object Operations
|
||||
- ❌ No object spreading (`...`) for destructuring or spreading properties → Use `transform()` to create new objects with desired properties
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Object spreading in workflow
|
||||
const myWorkflow = createWorkflow(
|
||||
"process-data",
|
||||
function (input: WorkflowInput) {
|
||||
const updatedData = {
|
||||
...input.data,
|
||||
newField: "value"
|
||||
} // Won't work - spread operator not allowed
|
||||
|
||||
step1(updatedData)
|
||||
})
|
||||
|
||||
// ✅ CORRECT - Use transform to create new objects
|
||||
import { transform } from "@medusajs/framework/workflows-sdk"
|
||||
|
||||
const myWorkflow = createWorkflow(
|
||||
"process-data",
|
||||
function (input: WorkflowInput) {
|
||||
const updatedData = transform(
|
||||
{ input },
|
||||
(data) => ({
|
||||
...data.input.data,
|
||||
newField: "value"
|
||||
})
|
||||
)
|
||||
|
||||
step1(updatedData)
|
||||
})
|
||||
```
|
||||
|
||||
### Loops
|
||||
- ❌ No `for`/`while` loops → Use alternatives below based on your use case
|
||||
|
||||
Workflow composition functions run at application load time to define the workflow structure, not to execute logic. Loops cannot be used directly in the composition function. Instead, use these patterns:
|
||||
|
||||
**Alternative 1: Loop in Calling Code (Repeat entire workflow)**
|
||||
|
||||
When you need to execute a workflow multiple times (e.g., once per item in an array), wrap the workflow execution in a loop in the code that calls the workflow:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Loop inside workflow composition
|
||||
const myWorkflow = createWorkflow(
|
||||
"hello-world",
|
||||
function (input: WorkflowInput) {
|
||||
for (const item of input.items) {
|
||||
step1(item) // Won't work - loop runs at load time, not execution time
|
||||
}
|
||||
})
|
||||
|
||||
// ✅ CORRECT - Loop in calling code
|
||||
// API route that calls the workflow
|
||||
import {
|
||||
MedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "@medusajs/framework/http"
|
||||
import myWorkflow from "../../workflows/my-workflow"
|
||||
|
||||
export async function POST(
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse
|
||||
) {
|
||||
const { items } = req.body
|
||||
|
||||
// Execute the workflow once for each item
|
||||
for (const item of items) {
|
||||
await myWorkflow(req.scope)
|
||||
.run({ item })
|
||||
}
|
||||
|
||||
res.status(200).send({ success: true })
|
||||
}
|
||||
|
||||
// Workflow definition - processes a single item
|
||||
const myWorkflow = createWorkflow(
|
||||
"hello-world",
|
||||
function (input: WorkflowInput) {
|
||||
step1(input.item)
|
||||
})
|
||||
```
|
||||
|
||||
**Alternative 2: Use `transform` for Array Operations (Prepare step inputs)**
|
||||
|
||||
When you need to iterate over an array to prepare inputs for a step, use `transform()` to map over the array:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Loop to build array
|
||||
const myWorkflow = createWorkflow(
|
||||
"hello-world",
|
||||
function (input: WorkflowInput) {
|
||||
const stepInputs = []
|
||||
for (const item of input.items) {
|
||||
stepInputs.push({ id: item.id }) // Won't work - loop runs at load time
|
||||
}
|
||||
step1(stepInputs)
|
||||
})
|
||||
|
||||
// ✅ CORRECT - Use transform to map array
|
||||
import { transform } from "@medusajs/framework/workflows-sdk"
|
||||
|
||||
const myWorkflow = createWorkflow(
|
||||
"hello-world",
|
||||
function (input: WorkflowInput) {
|
||||
const stepInputs = transform(
|
||||
{
|
||||
input,
|
||||
},
|
||||
(data) => {
|
||||
// This function runs at execution time
|
||||
return data.input.items.map((item) => ({ id: item.id }))
|
||||
}
|
||||
)
|
||||
|
||||
step1(stepInputs)
|
||||
})
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
- The workflow composition function runs once at application load time to define the structure
|
||||
- Loops would execute at load time with no data, not at execution time with actual input
|
||||
- Alternative 1 repeats the entire workflow (including rollback capability) for each item
|
||||
- Alternative 2 processes arrays within a single workflow execution using `transform()`
|
||||
|
||||
### Error Handling
|
||||
- ❌ No `try-catch` blocks → See error handling patterns in Medusa documentation
|
||||
|
||||
### Return Values
|
||||
- ✅ Only return serializable values (primitives, plain objects)
|
||||
- ❌ No non-serializable types (Maps, Sets, etc.)
|
||||
- For buffers: Return as object property, then recreate with `Buffer.from()` when processing results
|
||||
|
||||
## Step Best Practices
|
||||
|
||||
1. **One mutation per step**: Ensures rollback mechanisms work correctly
|
||||
2. **Idempotency**: Design steps to be safely retryable
|
||||
3. **Explicit compensation input**: Specify what data the compensation function needs if different from step output
|
||||
4. **Return StepResponse**: Always wrap your return value in `StepResponse`
|
||||
|
||||
## Reusing Built-in Medusa Steps
|
||||
|
||||
**⚠️ IMPORTANT**: Before creating custom steps, check if Medusa provides a built-in step for your use case. Reusing built-in steps is preferred over creating custom ones.
|
||||
|
||||
### Common Built-in Steps to Reuse
|
||||
|
||||
**Creating Links Between Modules:**
|
||||
|
||||
**⚠️ CRITICAL - Link Order (Direction):** When creating links, the order of modules in `createRemoteLinkStep` MUST match the order in `defineLink()`. Mismatched order causes runtime errors.
|
||||
|
||||
```typescript
|
||||
// Link definition in src/links/review-product.ts
|
||||
import { defineLink } from "@medusajs/framework/utils"
|
||||
import ReviewModule from "../modules/review"
|
||||
import ProductModule from "@medusajs/medusa/product"
|
||||
|
||||
// Order: review FIRST, then product
|
||||
export default defineLink(
|
||||
{
|
||||
linkable: ReviewModule.linkable.review,
|
||||
isList: true,
|
||||
},
|
||||
ProductModule.linkable.product
|
||||
)
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Order matches defineLink (review first, then product)
|
||||
import { createRemoteLinkStep } from "@medusajs/medusa/core-flows"
|
||||
import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
|
||||
import { Modules } from "@medusajs/framework/utils"
|
||||
import { REVIEW_MODULE } from "../modules/review"
|
||||
|
||||
export const createReviewWorkflow = createWorkflow(
|
||||
"create-review",
|
||||
function (input) {
|
||||
const review = createReviewStep(input)
|
||||
|
||||
// Order MUST match defineLink: review first, then product
|
||||
const linkData = transform({ review, input }, ({ review, input }) => [{
|
||||
[REVIEW_MODULE]: {
|
||||
review_id: review.id,
|
||||
},
|
||||
[Modules.PRODUCT]: {
|
||||
product_id: input.product_id,
|
||||
},
|
||||
}])
|
||||
|
||||
createRemoteLinkStep(linkData)
|
||||
|
||||
return new WorkflowResponse({ review })
|
||||
}
|
||||
)
|
||||
|
||||
// ❌ WRONG - Order doesn't match defineLink (product first, then review)
|
||||
const linkData = transform({ review, input }, ({ review, input }) => [{
|
||||
[Modules.PRODUCT]: {
|
||||
product_id: input.product_id,
|
||||
},
|
||||
[REVIEW_MODULE]: {
|
||||
review_id: review.id,
|
||||
},
|
||||
}]) // Runtime error: link direction mismatch!
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Don't create custom link steps
|
||||
const createReviewLinkStep = createStep(
|
||||
"create-review-link",
|
||||
async ({ reviewId, productId }, { container }) => {
|
||||
const link = container.resolve("link")
|
||||
await link.create({
|
||||
product: { product_id: productId },
|
||||
review: { review_id: reviewId },
|
||||
})
|
||||
// This duplicates functionality that createRemoteLinkStep provides
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**Removing Links:**
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use Medusa's built-in dismissRemoteLinkStep
|
||||
import { dismissRemoteLinkStep } from "@medusajs/medusa/core-flows"
|
||||
|
||||
export const deleteReviewWorkflow = createWorkflow(
|
||||
"delete-review",
|
||||
function (input) {
|
||||
const linkData = transform({ input }, ({ input }) => [{
|
||||
[Modules.PRODUCT]: { product_id: input.product_id },
|
||||
review: { review_id: input.review_id },
|
||||
}])
|
||||
|
||||
dismissRemoteLinkStep(linkData)
|
||||
deleteReviewStep(input)
|
||||
|
||||
return new WorkflowResponse({ success: true })
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**Querying Data in Workflows:**
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use Medusa's built-in useQueryGraphStep
|
||||
import { useQueryGraphStep } from "@medusajs/medusa/core-flows"
|
||||
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
|
||||
|
||||
export const getProductReviewsWorkflow = createWorkflow(
|
||||
"get-product-reviews",
|
||||
function (input) {
|
||||
// Query product with reviews using built-in step
|
||||
const { data: products } = useQueryGraphStep({
|
||||
entity: "product",
|
||||
fields: ["id", "title", "reviews.*"],
|
||||
filters: {
|
||||
id: input.product_id,
|
||||
},
|
||||
})
|
||||
|
||||
return new WorkflowResponse({ product: products[0] })
|
||||
}
|
||||
)
|
||||
|
||||
// ❌ WRONG - Don't create custom query steps
|
||||
const queryProductStep = createStep(
|
||||
"query-product",
|
||||
async ({ productId }, { container }) => {
|
||||
const query = container.resolve("query")
|
||||
const { data } = await query.graph({
|
||||
entity: "product",
|
||||
fields: ["id", "title", "reviews.*"],
|
||||
filters: { id: productId },
|
||||
})
|
||||
return new StepResponse(data[0])
|
||||
}
|
||||
)
|
||||
// This duplicates functionality that useQueryGraphStep provides
|
||||
```
|
||||
|
||||
**Why reuse built-in steps:**
|
||||
- Already tested and optimized by Medusa
|
||||
- Handles edge cases and error scenarios
|
||||
- Maintains consistency with Medusa's internal workflows
|
||||
- Includes proper compensation/rollback logic
|
||||
- Less code to maintain
|
||||
|
||||
**Other common built-in steps to look for:**
|
||||
- Event emission steps
|
||||
- Notification steps
|
||||
- Inventory management steps
|
||||
- Payment processing steps
|
||||
|
||||
Check Medusa documentation or `@medusajs/medusa/core-flows` for available built-in steps before creating custom ones.
|
||||
|
||||
## Business Logic and Validation Placement
|
||||
|
||||
**CRITICAL**: All business logic and validation must be performed inside workflow steps, NOT in API routes.
|
||||
|
||||
### ✅ CORRECT - Validation in Workflow Step
|
||||
|
||||
```typescript
|
||||
// src/workflows/steps/delete-review.ts
|
||||
export const deleteReviewStep = createStep(
|
||||
"delete-review",
|
||||
async ({ reviewId, customerId }: Input, { container }) => {
|
||||
const reviewModule = container.resolve("review")
|
||||
|
||||
// Validation happens inside the step
|
||||
const review = await reviewModule.retrieveReview(reviewId)
|
||||
|
||||
if (review.customer_id !== customerId) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
"You can only delete your own reviews"
|
||||
)
|
||||
}
|
||||
|
||||
await reviewModule.deleteReviews(reviewId)
|
||||
|
||||
return new StepResponse({ id: reviewId }, reviewId)
|
||||
},
|
||||
async (reviewId, { container }) => {
|
||||
// Compensation: restore the review if needed
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### ❌ WRONG - Validation in API Route
|
||||
|
||||
```typescript
|
||||
// src/api/store/reviews/[id]/route.ts
|
||||
export async function DELETE(req: MedusaRequest, res: MedusaResponse) {
|
||||
const { id } = req.params
|
||||
const customerId = req.auth_context.actor_id
|
||||
|
||||
// ❌ WRONG: Don't validate business rules in the route
|
||||
const reviewModule = req.scope.resolve("review")
|
||||
const review = await reviewModule.retrieveReview(id)
|
||||
|
||||
if (review.customer_id !== customerId) {
|
||||
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Not your review")
|
||||
}
|
||||
|
||||
// ❌ WRONG: Don't call workflows after manual validation
|
||||
const { result } = await deleteReviewWorkflow(req.scope).run({
|
||||
input: { reviewId: id }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
- Workflows are the single source of truth for business logic
|
||||
- Validation in routes bypasses workflow rollback mechanisms
|
||||
- Makes testing harder and logic harder to reuse
|
||||
- Breaks the Module → Workflow → API Route architecture
|
||||
|
||||
## Advanced Features
|
||||
|
||||
Workflows have advanced options to define retries, async behavior, pausing for human confirmation, and much more. Ask MedusaDocs for more details if these are relevant to your use case.
|
||||
Reference in New Issue
Block a user