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
handleErrorutility - •Module System: ESM with
.jsextensions 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
sortOrderauto-management? - •What fields from Prisma schema are relevant?
2. Verify Schemas Exist
Check shared-types:
# Read the relevant schema file 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:
/*
* 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:
- •✅ AGPL license header
- •✅ Always verify
restaurantIdownership - •✅ Use
NotFoundErrorfor missing resources - •✅ Use
ValidationErrorfor business rule violations - •✅ No
anytypes - •✅ Import with
.jsextensions
5. Generate Routes Layer
File: /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:
- •✅ JSDoc comments on every endpoint
- •✅
preHandlerarray for auth middleware - •✅ Verify
user.restaurantId === restaurantIdin every handler - •✅ Use
safeParseand 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
// 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;
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) => {
// ... auth checks ...
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) {
// 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);
}
Error Handling
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
Authentication Roles
From highest to lowest permission:
- •OWNER - Full restaurant access
- •MANAGER - Menu, orders, settings
- •STAFF - Orders only
- •KITCHEN - View orders (KDS)
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')] }
Quality Checklist
Before completing, verify:
- • AGPL-3.0 license header on all files
- • All imports use
.jsextensions (ESM) - • No
anytypes anywhere - • Restaurant ownership verified in all operations
- • Zod schemas imported from
@openorder/shared-types - • Authentication middleware on all endpoints
- • Correct roles specified per endpoint
- • Error handling with
handleError() - • Consistent response format:
{ success: true, data: ... } - • TypeScript compiles:
pnpm buildpasses - • Type-check passes:
pnpm type-checkpasses - • Routes registered in
server.ts - • JSDoc comments on all public methods
Anti-Patterns to Avoid
❌ Missing Restaurant Ownership Check
// 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 },
});
❌ Wrong ValidationError Usage
// BAD - ValidationError only takes message
throw new ValidationError('Invalid', zodError.errors);
// GOOD - throw Zod error directly
throw parseResult.error;
❌ Missing .js Extensions
// BAD - ESM requires extensions
import { XService } from './x.service';
// GOOD
import { XService } from './x.service.js';
❌ Using any Types
// BAD
async create(data: any) { }
// GOOD
async create(restaurantId: string, data: CreateXInput) { }
❌ No Auth Verification in Routes
// 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' });
}
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