一键导入
api-crud
Generate CRUD API endpoints following OpenOrder patterns (Fastify, Prisma, Zod, RBAC)
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Generate CRUD API endpoints following OpenOrder patterns (Fastify, Prisma, Zod, RBAC)
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Scaffold new POS or payment adapter implementations. Use when adding support for Square, Toast, Clover, Stripe, or other integrations.
Create conventional commits following OpenOrder standards with AGPL co-authoring
Initialize Docker development environment with database migrations. Use for first-time setup or after pulling major changes.
Run comprehensive checks across the entire monorepo. Use before creating PRs or after making cross-package changes.
Create and validate Prisma database migrations. Use after schema.prisma changes or when adding new database features.
Audit codebase for security vulnerabilities, secret leakage, dependency risks, and configuration issues. Use before commits, when adding dependencies, or reviewing PRs. Enforces zero-secrets policy, dependency isolation, and secure build practices.
| name | api-crud |
| description | Generate CRUD API endpoints following OpenOrder patterns (Fastify, Prisma, Zod, RBAC) |
| disable-model-invocation | false |
| allowed-tools | Read, Write, Edit, Bash, Glob, Grep |
Generate production-ready CRUD API endpoints for OpenOrder following established patterns with Fastify, Prisma ORM, Zod validation, and JWT authentication.
@openorder/shared-typeshandleError utility.js extensions requiredapps/api/src/
├── modules/
│ └── {module}/
│ ├── {module}.service.ts # Business logic + Prisma
│ └── {module}.routes.ts # HTTP layer + auth
├── utils/errors.ts # NotFoundError, ValidationError, etc.
├── config/database.ts # Prisma client singleton
└── server.ts # Route registration
Ask yourself:
sortOrder auto-management?Check shared-types:
# Read the relevant schema file
Read: /packages/shared-types/src/menu.ts
Look for:
createXSchema - Zod schema for POST requestsupdateXSchema - Zod schema for PUT requestsCreateXInput - TypeScript typeUpdateXInput - TypeScript typeIf schemas don't exist, stop and tell the user to add them first.
Check database schema:
Read: /apps/api/prisma/schema.prisma
Find the model and check:
@relation)onDelete: Cascade)File: /apps/api/src/modules/{module}/{module}.service.ts
Pattern:
/*
* OpenOrder - Open-source restaurant ordering platform
* Copyright (C) 2026 Josh Gunning
* AGPL-3.0 License
*/
import { PrismaClient } from '@prisma/client';
import { NotFoundError, ValidationError } from '../../utils/errors.js';
import type { CreateXInput, UpdateXInput } from '@openorder/shared-types';
export class XService {
constructor(private prisma: PrismaClient) {}
async create(restaurantId: string, data: CreateXInput) {
// 1. Verify parent exists (if nested resource)
// 2. Handle sortOrder auto-generation if needed
// 3. Create with Prisma
return await this.prisma.x.create({ data: { ...data, restaurantId } });
}
async getById(id: string, restaurantId: string) {
const resource = await this.prisma.x.findFirst({
where: { id, restaurantId },
include: { /* related data */ },
});
if (!resource) {
throw new NotFoundError('X not found');
}
return resource;
}
async list(restaurantId: string) {
return await this.prisma.x.findMany({
where: { restaurantId },
orderBy: { sortOrder: 'asc' }, // or createdAt
});
}
async update(id: string, restaurantId: string, data: UpdateXInput) {
// Verify ownership
await this.getById(id, restaurantId);
return await this.prisma.x.update({
where: { id },
data,
});
}
async delete(id: string, restaurantId: string) {
// Verify ownership
await this.getById(id, restaurantId);
await this.prisma.x.delete({ where: { id } });
}
}
Key Rules:
restaurantId ownershipNotFoundError for missing resourcesValidationError for business rule violationsany types.js extensionsFile: /apps/api/src/modules/{module}/{module}.routes.ts
Pattern:
/*
* OpenOrder - Open-source restaurant ordering platform
* Copyright (C) 2026 Josh Gunning
* AGPL-3.0 License
*/
import { FastifyPluginAsync } from 'fastify';
import { createXSchema, updateXSchema } from '@openorder/shared-types';
import { XService } from './x.service.js';
import { verifyAuth, requireRole } from '../auth/auth.middleware.js';
import { handleError } from '../../utils/errors.js';
import { prisma } from '../../config/database.js';
import type { JwtPayload } from '../../plugins/jwt.js';
const xService = new XService(prisma);
export const xRoutes: FastifyPluginAsync = async (fastify) => {
/**
* POST /api/restaurants/:restaurantId/x
* Auth: OWNER, MANAGER
*/
fastify.post(
'/restaurants/:restaurantId/x',
{ preHandler: [verifyAuth, requireRole('OWNER', 'MANAGER')] },
async (request, reply) => {
try {
const user = request.user as JwtPayload;
const { restaurantId } = request.params as { restaurantId: string };
if (user.restaurantId !== restaurantId) {
return reply.status(403).send({ error: 'Access denied' });
}
const parseResult = createXSchema.safeParse(request.body);
if (!parseResult.success) {
throw parseResult.error;
}
const resource = await xService.create(restaurantId, parseResult.data);
return reply.status(201).send({ success: true, data: resource });
} catch (error) {
return handleError(error, reply);
}
}
);
/**
* GET /api/restaurants/:restaurantId/x
* Auth: All authenticated
*/
fastify.get(
'/restaurants/:restaurantId/x',
{ preHandler: [verifyAuth] },
async (request, reply) => {
try {
const user = request.user as JwtPayload;
const { restaurantId } = request.params as { restaurantId: string };
if (user.restaurantId !== restaurantId) {
return reply.status(403).send({ error: 'Access denied' });
}
const resources = await xService.list(restaurantId);
return reply.send({ success: true, data: resources });
} catch (error) {
return handleError(error, reply);
}
}
);
/**
* PUT /api/restaurants/:restaurantId/x/:id
* Auth: OWNER, MANAGER
*/
fastify.put(
'/restaurants/:restaurantId/x/:id',
{ preHandler: [verifyAuth, requireRole('OWNER', 'MANAGER')] },
async (request, reply) => {
try {
const user = request.user as JwtPayload;
const { restaurantId, id } = request.params as {
restaurantId: string;
id: string;
};
if (user.restaurantId !== restaurantId) {
return reply.status(403).send({ error: 'Access denied' });
}
const parseResult = updateXSchema.safeParse(request.body);
if (!parseResult.success) {
throw parseResult.error;
}
const resource = await xService.update(id, restaurantId, parseResult.data);
return reply.send({ success: true, data: resource });
} catch (error) {
return handleError(error, reply);
}
}
);
/**
* DELETE /api/restaurants/:restaurantId/x/:id
* Auth: OWNER, MANAGER
*/
fastify.delete(
'/restaurants/:restaurantId/x/:id',
{ preHandler: [verifyAuth, requireRole('OWNER', 'MANAGER')] },
async (request, reply) => {
try {
const user = request.user as JwtPayload;
const { restaurantId, id } = request.params as {
restaurantId: string;
id: string;
};
if (user.restaurantId !== restaurantId) {
return reply.status(403).send({ error: 'Access denied' });
}
await xService.delete(id, restaurantId);
return reply.status(204).send();
} catch (error) {
return handleError(error, reply);
}
}
);
};
Key Rules:
preHandler array for auth middlewareuser.restaurantId === restaurantId in every handlersafeParse and throw Zod error directly{ success: true, data: ... }Edit: /apps/api/src/server.ts
Add import:
import { xRoutes } from './modules/x/x.routes.js';
Register in start() function:
await fastify.register(xRoutes);
cd /Users/brucewayne/openorder/apps/api
pnpm build
pnpm type-check
Must have zero TypeScript errors.
// Get max sortOrder for this restaurant
const maxSortOrder = await this.prisma.menuCategory.aggregate({
where: { restaurantId },
_max: { sortOrder: true },
});
const sortOrder = data.sortOrder ?? (maxSortOrder._max.sortOrder ?? -1) + 1;
async delete(id: string, restaurantId: string) {
await this.getById(id, restaurantId);
await this.prisma.menuItem.update({
where: { id },
data: { isActive: false },
});
}
fastify.patch(
'/restaurants/:restaurantId/items/:id/availability',
{ preHandler: [verifyAuth, requireRole('OWNER', 'MANAGER', 'STAFF')] },
async (request, reply) => {
// ... auth checks ...
const { isAvailable } = request.body;
await itemService.toggleAvailability(id, restaurantId, isAvailable);
return reply.send({ success: true });
}
);
async reorder(restaurantId: string, data: ReorderInput) {
// Verify all belong to restaurant
const items = await this.prisma.menuItem.findMany({
where: { id: { in: data.itemIds }, restaurantId },
});
if (items.length !== data.itemIds.length) {
throw new ValidationError('One or more items not found');
}
// Update in transaction
await this.prisma.$transaction(
data.itemIds.map((itemId: string, index: number) =>
this.prisma.menuItem.update({
where: { id: itemId },
data: { sortOrder: index },
})
)
);
return this.list(restaurantId);
}
Import:
import { NotFoundError, ValidationError, AuthError, ForbiddenError } from '../../utils/errors.js';
Usage:
// 404 - Resource not found
throw new NotFoundError('Category not found');
// 400 - Business rule violation
throw new ValidationError('Max quantity cannot be negative');
// 401 - Authentication required
throw new AuthError('Invalid token');
// 403 - Insufficient permissions
throw new ForbiddenError('Access denied');
Zod Errors:
// Throw Zod error directly - handleError will format it properly
const parseResult = schema.safeParse(request.body);
if (!parseResult.success) {
throw parseResult.error; // ✅ Correct
}
// DON'T do this:
throw new ValidationError('Invalid', parseResult.error); // ❌ Wrong
From highest to lowest permission:
Middleware Examples:
// All authenticated users
{ preHandler: [verifyAuth] }
// OWNER and MANAGER only
{ preHandler: [verifyAuth, requireRole('OWNER', 'MANAGER')] }
// All roles (explicit)
{ preHandler: [verifyAuth, requireRole('OWNER', 'MANAGER', 'STAFF', 'KITCHEN')] }
Before completing, verify:
.js extensions (ESM)any types anywhere@openorder/shared-typeshandleError(){ success: true, data: ... }pnpm build passespnpm type-check passesserver.ts// BAD - anyone can access any restaurant's data
const item = await this.prisma.menuItem.findUnique({ where: { id } });
// GOOD - verify ownership
const item = await this.prisma.menuItem.findFirst({
where: { id, restaurantId },
});
// BAD - ValidationError only takes message
throw new ValidationError('Invalid', zodError.errors);
// GOOD - throw Zod error directly
throw parseResult.error;
.js Extensions// BAD - ESM requires extensions
import { XService } from './x.service';
// GOOD
import { XService } from './x.service.js';
any Types// BAD
async create(data: any) { }
// GOOD
async create(restaurantId: string, data: CreateXInput) { }
// BAD - missing verification
fastify.post('/restaurants/:restaurantId/items', async (request, reply) => {
// Missing: if (user.restaurantId !== restaurantId) { ... }
});
// GOOD
if (user.restaurantId !== restaurantId) {
return reply.status(403).send({ error: 'Access denied' });
}
Study these existing modules:
/apps/api/src/modules/menu/ - Category CRUD (complete reference)/apps/api/src/modules/media/ - Image upload with multipart/apps/api/src/modules/auth/ - Authentication patternsUse /api-crud when you need to:
User: "Create CRUD endpoints for menu items"
You should:
1. Check MenuItem model in Prisma schema
2. Verify schemas in shared-types
3. Generate menu.service.ts with all methods
4. Generate menu.routes.ts with auth
5. Register routes in server.ts
6. Build and verify
7. Report completion