backend-api
Create Express.js API endpoints for IntelliFill following established patterns (Prisma, Supabase auth, Joi validation, Bull queues). Use when creating new API routes, services, or middleware.
$ 설치
git clone https://github.com/Intellifill/IntelliFill /tmp/IntelliFill && cp -r /tmp/IntelliFill/.claude/skills/backend-api ~/.claude/skills/IntelliFill// tip: Run this command in your terminal to install the skill
name: backend-api description: Create Express.js API endpoints for IntelliFill following established patterns (Prisma, Supabase auth, Joi validation, Bull queues). Use when creating new API routes, services, or middleware.
Backend API Development Skill
This skill provides comprehensive guidance for creating Express.js API endpoints in the IntelliFill backend (quikadmin/).
Table of Contents
- Route Factory Pattern
- Validation with Joi
- Service Layer
- Authentication Middleware
- Error Handling
- Rate Limiting
- Background Jobs with Bull
- Testing Patterns
Route Factory Pattern
IntelliFill uses a modular route pattern where each domain has its own route file.
File Structure
quikadmin/src/api/
├── routes.ts # Main route registry
├── auth.routes.ts # Authentication routes
├── documents.routes.ts # Document management
├── templates.routes.ts # Template CRUD
├── knowledge.routes.ts # Knowledge base
└── users.routes.ts # User profile
Route File Template
// quikadmin/src/api/[domain].routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { validateRequest } from '../middleware/validation';
import { authMiddleware } from '../middleware/supabaseAuth';
import { rateLimiter } from '../middleware/rateLimiter';
import * as schemas from '../validators/schemas/[domain].schemas';
import logger from '../utils/logger';
const router = Router();
/**
* GET /api/[domain]
* List all items with pagination
*/
router.get(
'/',
authMiddleware,
rateLimiter('standard'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.id;
const { page = 1, limit = 20 } = req.query;
// Service call
const items = await domainService.list({
userId,
page: Number(page),
limit: Number(limit),
});
res.json({
success: true,
data: items,
meta: {
page: Number(page),
limit: Number(limit),
},
});
} catch (error) {
next(error);
}
}
);
/**
* POST /api/[domain]
* Create new item with validation
*/
router.post(
'/',
authMiddleware,
rateLimiter('strict'),
validateRequest(schemas.createSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.id;
const data = req.body;
const item = await domainService.create({
userId,
data,
});
logger.info('[domain] Created', { userId, itemId: item.id });
res.status(201).json({
success: true,
data: item,
});
} catch (error) {
next(error);
}
}
);
export default router;
Registering Routes
Register new routes in quikadmin/src/api/routes.ts:
import express from 'express';
import authRoutes from './auth.routes';
import documentsRoutes from './documents.routes';
import newDomainRoutes from './newDomain.routes'; // Your new routes
const router = express.Router();
router.use('/auth/v2', authRoutes);
router.use('/documents', documentsRoutes);
router.use('/new-domain', newDomainRoutes); // Register here
export default router;
Validation with Joi
IntelliFill uses Joi for request validation.
Schema Location
quikadmin/src/validators/schemas/
├── auth.schemas.ts
├── documents.schemas.ts
├── templates.schemas.ts
└── [domain].schemas.ts
Validation Schema Pattern
// quikadmin/src/validators/schemas/[domain].schemas.ts
import Joi from 'joi';
/**
* Create item schema
*/
export const createSchema = Joi.object({
name: Joi.string().min(1).max(255).required(),
description: Joi.string().max(1000).optional(),
type: Joi.string().valid('type1', 'type2', 'type3').required(),
metadata: Joi.object({
category: Joi.string().optional(),
tags: Joi.array().items(Joi.string()).optional(),
}).optional(),
isPublic: Joi.boolean().default(false),
});
/**
* Update item schema (all fields optional)
*/
export const updateSchema = Joi.object({
name: Joi.string().min(1).max(255).optional(),
description: Joi.string().max(1000).optional(),
type: Joi.string().valid('type1', 'type2', 'type3').optional(),
metadata: Joi.object().optional(),
isPublic: Joi.boolean().optional(),
}).min(1); // At least one field required
/**
* Query parameters schema
*/
export const listQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().max(255).optional(),
type: Joi.string().valid('type1', 'type2', 'type3').optional(),
sortBy: Joi.string().valid('name', 'createdAt', 'updatedAt').default('createdAt'),
sortOrder: Joi.string().valid('asc', 'desc').default('desc'),
});
/**
* ID parameter schema
*/
export const idParamSchema = Joi.object({
id: Joi.string().uuid().required(),
});
Using Validation Middleware
import { validateRequest } from '../middleware/validation';
import * as schemas from '../validators/schemas/domain.schemas';
// Body validation
router.post('/', validateRequest(schemas.createSchema), handler);
// Query validation
router.get('/', validateRequest(schemas.listQuerySchema, 'query'), handler);
// Param validation
router.get('/:id', validateRequest(schemas.idParamSchema, 'params'), handler);
// Multiple validations
router.patch(
'/:id',
validateRequest(schemas.idParamSchema, 'params'),
validateRequest(schemas.updateSchema),
handler
);
Service Layer
Services contain business logic and are injected with dependencies.
Service Location
quikadmin/src/services/
├── [domain].service.ts
└── __tests__/
└── [domain].service.test.ts
Service Pattern
// quikadmin/src/services/[domain].service.ts
import { PrismaClient } from '@prisma/client';
import logger from '../utils/logger';
import { AppError } from '../utils/errors';
export interface DomainServiceDeps {
prisma: PrismaClient;
}
export class DomainService {
private prisma: PrismaClient;
constructor(deps: DomainServiceDeps) {
this.prisma = deps.prisma;
}
/**
* List items with pagination
*/
async list(params: {
userId: string;
page: number;
limit: number;
search?: string;
}) {
const { userId, page, limit, search } = params;
const skip = (page - 1) * limit;
const where = {
userId,
...(search && {
OR: [
{ name: { contains: search, mode: 'insensitive' as const } },
{ description: { contains: search, mode: 'insensitive' as const } },
],
}),
};
const [items, total] = await Promise.all([
this.prisma.domain.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
this.prisma.domain.count({ where }),
]);
return {
items,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
* Get single item by ID
*/
async getById(id: string, userId: string) {
const item = await this.prisma.domain.findFirst({
where: { id, userId },
});
if (!item) {
throw new AppError('Item not found', 404);
}
return item;
}
/**
* Create new item
*/
async create(params: { userId: string; data: CreateData }) {
const { userId, data } = params;
const item = await this.prisma.domain.create({
data: {
...data,
userId,
},
});
logger.info('Domain item created', { userId, itemId: item.id });
return item;
}
/**
* Update existing item
*/
async update(id: string, userId: string, data: UpdateData) {
// Verify ownership
await this.getById(id, userId);
const item = await this.prisma.domain.update({
where: { id },
data,
});
logger.info('Domain item updated', { userId, itemId: item.id });
return item;
}
/**
* Delete item
*/
async delete(id: string, userId: string) {
// Verify ownership
await this.getById(id, userId);
await this.prisma.domain.delete({
where: { id },
});
logger.info('Domain item deleted', { userId, itemId: id });
}
}
// Export singleton instance
import prisma from '../utils/prisma';
export const domainService = new DomainService({ prisma });
Authentication Middleware
IntelliFill uses Supabase for authentication.
Auth Middleware Usage
import { authMiddleware } from '../middleware/supabaseAuth';
// Protected route
router.get('/protected', authMiddleware, async (req, res) => {
const userId = req.user?.id; // Type-safe user object
const userEmail = req.user?.email;
// Your logic here
});
// Public route (no authMiddleware)
router.get('/public', async (req, res) => {
// Your logic here
});
User Object Type
// Available in req.user after authMiddleware
interface User {
id: string;
email: string;
role?: string;
// Other Supabase user fields
}
Error Handling
IntelliFill uses a centralized error handling system.
Error Classes
import { AppError } from '../utils/errors';
// Validation error
throw new AppError('Invalid input', 400);
// Not found
throw new AppError('Resource not found', 404);
// Unauthorized
throw new AppError('Unauthorized', 401);
// Forbidden
throw new AppError('Forbidden', 403);
// Internal server error (will be logged)
throw new AppError('Something went wrong', 500);
Error Handler
All routes should use next(error) to pass errors to the global error handler:
router.get('/example', async (req, res, next) => {
try {
// Your logic
} catch (error) {
next(error); // Passes to error handler
}
});
Rate Limiting
IntelliFill uses Redis-backed rate limiting with in-memory fallback.
Rate Limiter Usage
import { rateLimiter } from '../middleware/rateLimiter';
// Standard rate limit (100 requests/15min)
router.get('/', rateLimiter('standard'), handler);
// Strict rate limit (10 requests/15min)
router.post('/sensitive', rateLimiter('strict'), handler);
// Lenient rate limit (1000 requests/15min)
router.get('/public', rateLimiter('lenient'), handler);
Custom Rate Limits
import { createRateLimiter } from '../middleware/rateLimiter';
const customLimiter = createRateLimiter({
windowMs: 60 * 1000, // 1 minute
max: 5, // 5 requests per minute
keyGenerator: (req) => req.user?.id || req.ip, // Rate limit by user
});
router.post('/expensive-operation', customLimiter, handler);
Background Jobs with Bull
IntelliFill uses Bull queues for async processing.
Queue Setup
// quikadmin/src/queues/[domain]Queue.ts
import Queue from 'bull';
import logger from '../utils/logger';
export interface JobData {
userId: string;
itemId: string;
// Other job data
}
export const domainQueue = new Queue<JobData>('domain-processing', {
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: Number(process.env.REDIS_PORT) || 6379,
},
});
// Job processor
domainQueue.process(async (job) => {
const { userId, itemId } = job.data;
logger.info('Processing domain job', { userId, itemId });
try {
// Your processing logic
await processItem(itemId);
logger.info('Domain job completed', { userId, itemId });
} catch (error) {
logger.error('Domain job failed', { userId, itemId, error });
throw error; // Retry based on queue settings
}
});
// Error handling
domainQueue.on('failed', (job, err) => {
logger.error('Queue job failed', { jobId: job.id, error: err });
});
Adding Jobs
import { domainQueue } from '../queues/domainQueue';
// In your route handler
const job = await domainQueue.add(
{
userId: req.user?.id,
itemId: item.id,
},
{
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
}
);
res.json({
success: true,
jobId: job.id,
});
Testing Patterns
API Route Test Template
// quikadmin/src/api/__tests__/[domain].routes.test.ts
import request from 'supertest';
import app from '../../app';
import prisma from '../../utils/prisma';
describe('Domain API Routes', () => {
const mockUser = {
id: 'user-123',
email: 'test@example.com',
};
// Mock auth middleware
jest.mock('../../middleware/supabaseAuth', () => ({
authMiddleware: (req: any, res: any, next: any) => {
req.user = mockUser;
next();
},
}));
afterEach(async () => {
await prisma.domain.deleteMany();
});
describe('GET /api/domain', () => {
it('should list items', async () => {
const response = await request(app)
.get('/api/domain')
.set('Authorization', 'Bearer fake-token')
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body).toHaveProperty('data');
});
});
describe('POST /api/domain', () => {
it('should create item', async () => {
const response = await request(app)
.post('/api/domain')
.set('Authorization', 'Bearer fake-token')
.send({
name: 'Test Item',
type: 'type1',
})
.expect(201);
expect(response.body.data).toHaveProperty('id');
expect(response.body.data.name).toBe('Test Item');
});
it('should validate input', async () => {
const response = await request(app)
.post('/api/domain')
.set('Authorization', 'Bearer fake-token')
.send({}) // Missing required fields
.expect(400);
expect(response.body).toHaveProperty('success', false);
});
});
});
Best Practices
- Always use TypeScript types - Define interfaces for all request/response shapes
- Validate all inputs - Use Joi schemas for validation
- Use service layer - Keep route handlers thin, move logic to services
- Error handling - Always use try/catch and pass errors to next()
- Logging - Log important operations with structured context
- Rate limiting - Apply appropriate rate limits to all routes
- Authentication - Use authMiddleware for protected routes
- Testing - Write tests for happy path and error cases
- Documentation - Add JSDoc comments to routes and services
- Transactions - Use Prisma transactions for multi-step operations
Common Patterns
Batch Operations
router.post('/batch', authMiddleware, async (req, res, next) => {
try {
const { ids } = req.body;
const userId = req.user?.id;
const results = await prisma.$transaction(
ids.map((id: string) =>
prisma.domain.update({
where: { id },
data: { processed: true },
})
)
);
res.json({ success: true, data: results });
} catch (error) {
next(error);
}
});
File Upload
import multer from 'multer';
const upload = multer({
dest: 'uploads/',
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
});
router.post(
'/upload',
authMiddleware,
upload.single('file'),
async (req, res, next) => {
try {
const file = req.file;
const userId = req.user?.id;
// Process file
const result = await processFile(file, userId);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
}
);
Soft Delete
async softDelete(id: string, userId: string) {
await this.getById(id, userId);
return this.prisma.domain.update({
where: { id },
data: { deletedAt: new Date() },
});
}
// Exclude soft-deleted in queries
async list(params: ListParams) {
const where = {
userId: params.userId,
deletedAt: null, // Exclude soft-deleted
};
return this.prisma.domain.findMany({ where });
}
References
Repository
