| 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 |
API CRUD Generator
Generate production-ready CRUD API endpoints for OpenOrder following established patterns with Fastify, Prisma ORM, Zod validation, and JWT authentication.
OpenOrder API Stack
- Framework: Fastify with plugin-based modular routes
- Database: Prisma ORM + PostgreSQL
- Validation: Zod schemas from
@openorder/shared-types
- Auth: JWT with role-based access control (OWNER > MANAGER > STAFF > KITCHEN)
- Errors: Custom error classes with
handleError utility
- Module System: ESM with
.js extensions required
Project Structure
apps/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
Workflow
1. Understand the Resource
Ask yourself:
- What is the resource name? (e.g., MenuItem, ModifierGroup)
- What relationships does it have? (belongs to restaurant, has many X)
- Does it need
sortOrder auto-management?
- What fields from Prisma schema are relevant?
2. Verify Schemas Exist
Check shared-types:
Read: /packages/shared-types/src/menu.ts
Look for:
createXSchema - Zod schema for POST requests
updateXSchema - Zod schema for PUT requests
CreateXInput - TypeScript type
UpdateXInput - TypeScript type
If schemas don't exist, stop and tell the user to add them first.
3. Verify Prisma Model
Check database schema:
Read: /apps/api/prisma/schema.prisma
Find the model and check:
- Field names and types
- Relationships (
@relation)
- Cascade delete rules (
onDelete: Cascade)
- Unique constraints
- Indexes
4. Generate Service Layer
File: /apps/api/src/modules/{module}/{module}.service.ts
Pattern:
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) {
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: { },
});
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' },
});
}
async update(id: string, restaurantId: string, data: UpdateXInput) {
await this.getById(id, restaurantId);
return await this.prisma.x.update({
where: { id },
data,
});
}
async delete(id: string, restaurantId: string) {
await this.getById(id, restaurantId);
await this.prisma.x.delete({ where: { id } });
}
}
Key Rules:
- ✅ AGPL license header
- ✅ Always verify
restaurantId ownership
- ✅ Use
NotFoundError for missing resources
- ✅ Use
ValidationError for business rule violations
- ✅ No
any types
- ✅ Import with
.js extensions
5. Generate Routes Layer
File: /apps/api/src/modules/{module}/{module}.routes.ts
Pattern:
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) => {
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);
}
}
);
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);
}
}
);
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);
}
}
);
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:
- ✅ JSDoc comments on every endpoint
- ✅
preHandler array for auth middleware
- ✅ Verify
user.restaurantId === restaurantId in every handler
- ✅ Use
safeParse and throw Zod error directly
- ✅ Response format:
{ success: true, data: ... }
- ✅ Status codes: 201 (create), 200 (success), 204 (delete), 403 (forbidden), 404 (not found)
6. Register Routes
Edit: /apps/api/src/server.ts
Add import:
import { xRoutes } from './modules/x/x.routes.js';
Register in start() function:
await fastify.register(xRoutes);
7. Build & Verify
cd /Users/brucewayne/openorder/apps/api
pnpm build
pnpm type-check
Must have zero TypeScript errors.
Common Patterns
Auto Sort Order
const maxSortOrder = await this.prisma.menuCategory.aggregate({
where: { restaurantId },
_max: { sortOrder: true },
});
const sortOrder = data.sortOrder ?? (maxSortOrder._max.sortOrder ?? -1) + 1;
Soft Delete (isActive pattern)
async delete(id: string, restaurantId: string) {
await this.getById(id, restaurantId);
await this.prisma.menuItem.update({
where: { id },
data: { isActive: false },
});
}
Toggle Endpoint (Quick Actions)
fastify.patch(
'/restaurants/:restaurantId/items/:id/availability',
{ preHandler: [verifyAuth, requireRole('OWNER', 'MANAGER', 'STAFF')] },
async (request, reply) => {
const { isAvailable } = request.body;
await itemService.toggleAvailability(id, restaurantId, isAvailable);
return reply.send({ success: true });
}
);
Reorder Endpoint (sortOrder management)
async reorder(restaurantId: string, data: ReorderInput) {
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');
}
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);
}
Error Handling
Import:
import { NotFoundError, ValidationError, AuthError, ForbiddenError } from '../../utils/errors.js';
Usage:
throw new NotFoundError('Category not found');
throw new ValidationError('Max quantity cannot be negative');
throw new AuthError('Invalid token');
throw new ForbiddenError('Access denied');
Zod Errors:
const parseResult = schema.safeParse(request.body);
if (!parseResult.success) {
throw parseResult.error;
}
throw new ValidationError('Invalid', parseResult.error);
Authentication Roles
From highest to lowest permission:
- OWNER - Full restaurant access
- MANAGER - Menu, orders, settings
- STAFF - Orders only
- KITCHEN - View orders (KDS)
Middleware Examples:
{ preHandler: [verifyAuth] }
{ preHandler: [verifyAuth, requireRole('OWNER', 'MANAGER')] }
{ preHandler: [verifyAuth, requireRole('OWNER', 'MANAGER', 'STAFF', 'KITCHEN')] }
Quality Checklist
Before completing, verify:
Anti-Patterns to Avoid
❌ Missing Restaurant Ownership Check
const item = await this.prisma.menuItem.findUnique({ where: { id } });
const item = await this.prisma.menuItem.findFirst({
where: { id, restaurantId },
});
❌ Wrong ValidationError Usage
throw new ValidationError('Invalid', zodError.errors);
throw parseResult.error;
❌ Missing .js Extensions
import { XService } from './x.service';
import { XService } from './x.service.js';
❌ Using any Types
async create(data: any) { }
async create(restaurantId: string, data: CreateXInput) { }
❌ No Auth Verification in Routes
fastify.post('/restaurants/:restaurantId/items', async (request, reply) => {
});
if (user.restaurantId !== restaurantId) {
return reply.status(403).send({ error: 'Access denied' });
}
Reference Implementations
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 patterns
When to Use This Skill
Use /api-crud when you need to:
- Generate new CRUD endpoints for a resource
- Follow OpenOrder's API patterns consistently
- Ensure authentication, validation, and error handling are correct
- Avoid common mistakes and anti-patterns
- Speed up API development with proven patterns
Example Invocation
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