with one click
api-crud
// Generate CRUD API endpoints following OpenOrder patterns (Fastify, Prisma, Zod, RBAC)
// Generate CRUD API endpoints following OpenOrder patterns (Fastify, Prisma, Zod, RBAC)
[HINT] Download the complete skill directory including SKILL.md and all related files
| 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