Skip to main content
api-crud Generate CRUD API endpoints following OpenOrder patterns (Fastify, Prisma, Zod, RBAC)
Jump to install Skills Marketplace Discover and explore AI skills built by the community.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Copy promptShow prompt details A direct command skips the review prompt. Inspect the source before running it.
npx skills add https://github.com/gunning4it/openorder --skill api-crudThe command stays on one line. Scroll horizontally to inspect it before copying.
Prefer a local copy? Download the files currently available to SkillsMP.
Download Zip Downloading... Related occupations SOC
Based on SOC occupation classification
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
Read: /packages/shared-types/src/menu.ts
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 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
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 } });
}
}
✅ 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
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);
}
}
);
};
✅ 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
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 { NotFoundError , ValidationError , AuthError , ForbiddenError } from '../../utils/errors.js' ;
throw new NotFoundError ('Category not found' );
throw new ValidationError ('Max quantity cannot be negative' );
throw new AuthError ('Invalid token' );
throw new ForbiddenError ('Access denied' );
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)
{ 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
More from this repository