
feature-builder
by mavric
Mavric AI Toolchain - AI-assisted SaaS development
SKILL.md
name: feature-builder description: Builds complete features full-stack (backend + frontend + tests). Triggers when user wants to implement a feature, add functionality, or build a specific capability.
Feature Builder
I implement complete features from specification to working code, covering backend APIs, frontend UI, and tests.
What I Build
For each feature, I deliver:
1. Backend Implementation
- REST API endpoints (or extend existing)
- Service layer with business logic
- Data access layer
- Input validation
- Error handling
- OpenAPI documentation
2. Frontend Implementation
- UI components with shadcn/ui
- Forms with react-hook-form + zod
- API integration with React Query
- State management
- Error handling & loading states
- Responsive design
3. Testing
- Backend unit tests
- Backend integration tests
- Frontend component tests
- E2E tests (if critical flow)
4. Documentation
- Feature specification
- API documentation
- Component documentation
- Usage examples
The Build Process
Step 1: Feature Specification
I start by creating a detailed spec:
Feature: [Name] User Story:
As a [user type]
I want to [action]
So that [benefit]
Acceptance Criteria:
- Criterion 1
- Criterion 2
- Criterion 3
API Endpoints Needed:
POST /api/resource- CreateGET /api/resource/:id- FetchPUT /api/resource/:id- Update
UI Components:
- ResourceList
- ResourceForm
- ResourceCard
- ResourceDetail
Data Flow:
User Input → Form Validation → API Request → Backend Validation → Database → API Response → UI Update
Step 2: Backend Implementation
A. Define DTOs (Data Transfer Objects)
// server/src/extensions/Resource/dto/create-resource.dto.ts
import { IsString, IsEnum, IsOptional, MinLength } from 'class-validator';
export class CreateResourceDto {
@IsString()
@MinLength(3)
name: string;
@IsString()
@IsOptional()
description?: string;
@IsEnum(['active', 'draft'])
status: 'active' | 'draft';
}
B. Add Business Logic to Service
// server/src/extensions/Resource/Resource.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Resource } from 'src/autogen/entities/Resource';
@Injectable()
export class ResourceService {
constructor(
@InjectRepository(Resource)
private resourceRepo: Repository<Resource>,
) {}
async create(dto: CreateResourceDto, orgId: string, userId: string) {
// Business logic
const resource = this.resourceRepo.create({
...dto,
organization_id: orgId,
created_by: userId,
});
// Save to database
const saved = await this.resourceRepo.save(resource);
// Trigger side effects (emails, notifications, etc.)
await this.notificationService.notifyResourceCreated(saved);
return saved;
}
async findWithStats(id: string, orgId: string) {
const resource = await this.resourceRepo.findOne({
where: { id, organization_id: orgId },
relations: ['created_by', 'items'],
});
if (!resource) {
throw new NotFoundException('Resource not found');
}
// Add computed stats
return {
...resource,
stats: {
total_items: resource.items.length,
active_items: resource.items.filter(i => i.status === 'active').length,
},
};
}
async archive(id: string, orgId: string) {
const resource = await this.findOne(id, orgId);
// Business logic for archiving
resource.status = 'archived';
resource.archived_at = new Date();
// Archive related items
await this.itemService.archiveByResource(id);
return this.resourceRepo.save(resource);
}
}
C. Add Custom Controller Endpoints
// server/src/extensions/Resource/Resource.controller.ts
import { Controller, Post, Get, Put, Param, Body, UseGuards, Req } from '@nestjs/common';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { OrgGuard } from 'src/common/guards/org.guard';
@Controller('resources')
@UseGuards(AuthGuard, OrgGuard)
export class ResourceController {
constructor(private resourceService: ResourceService) {}
// Extend generated CRUD with custom endpoint
@Get(':id/stats')
async getWithStats(@Param('id') id: string, @Req() req) {
return this.resourceService.findWithStats(id, req.organizationId);
}
@Post(':id/archive')
async archive(@Param('id') id: string, @Req() req) {
return this.resourceService.archive(id, req.organizationId);
}
@Post(':id/duplicate')
async duplicate(@Param('id') id: string, @Req() req) {
const original = await this.resourceService.findOne(id, req.organizationId);
return this.resourceService.create(
{ ...original, name: `${original.name} (Copy)` },
req.organizationId,
req.userId,
);
}
}
Step 3: Frontend Implementation
A. Type Definitions
// client/src/types/resource.ts
export interface Resource {
id: string;
organization_id: string;
name: string;
description: string | null;
status: 'active' | 'draft' | 'archived';
created_by: string;
created_at: string;
updated_at: string;
}
export interface ResourceWithStats extends Resource {
stats: {
total_items: number;
active_items: number;
};
}
export interface CreateResourceInput {
name: string;
description?: string;
status: 'active' | 'draft';
}
B. API Client Methods
// client/src/lib/api/resources.ts
import { apiClient } from '../api-client';
import { Resource, ResourceWithStats, CreateResourceInput } from '@/types/resource';
export const resourcesApi = {
list: async (): Promise<Resource[]> => {
const res = await apiClient.get('/resources');
return res.data.data;
},
get: async (id: string): Promise<Resource> => {
const res = await apiClient.get(`/resources/${id}`);
return res.data;
},
getWithStats: async (id: string): Promise<ResourceWithStats> => {
const res = await apiClient.get(`/resources/${id}/stats`);
return res.data;
},
create: async (data: CreateResourceInput): Promise<Resource> => {
const res = await apiClient.post('/resources', data);
return res.data;
},
update: async (id: string, data: Partial<CreateResourceInput>): Promise<Resource> => {
const res = await apiClient.put(`/resources/${id}`, data);
return res.data;
},
archive: async (id: string): Promise<Resource> => {
const res = await apiClient.post(`/resources/${id}/archive`);
return res.data;
},
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/resources/${id}`);
},
};
C. React Query Hooks
// client/src/hooks/use-resources.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { resourcesApi } from '@/lib/api/resources';
import { CreateResourceInput } from '@/types/resource';
import { useToast } from '@/components/ui/use-toast';
export function useResources() {
return useQuery({
queryKey: ['resources'],
queryFn: resourcesApi.list,
});
}
export function useResource(id: string) {
return useQuery({
queryKey: ['resources', id],
queryFn: () => resourcesApi.getWithStats(id),
enabled: !!id,
});
}
export function useCreateResource() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: resourcesApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
toast({
title: 'Success',
description: 'Resource created successfully',
});
},
onError: (error: any) => {
toast({
title: 'Error',
description: error.response?.data?.message || 'Failed to create resource',
variant: 'destructive',
});
},
});
}
export function useUpdateResource() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<CreateResourceInput> }) =>
resourcesApi.update(id, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
queryClient.invalidateQueries({ queryKey: ['resources', variables.id] });
toast({
title: 'Success',
description: 'Resource updated successfully',
});
},
onError: (error: any) => {
toast({
title: 'Error',
description: error.response?.data?.message || 'Failed to update resource',
variant: 'destructive',
});
},
});
}
export function useArchiveResource() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: resourcesApi.archive,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
toast({
title: 'Success',
description: 'Resource archived successfully',
});
},
});
}
D. UI Components
Resource Form Component:
// client/src/components/features/resources/resource-form.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { CreateResourceInput, Resource } from '@/types/resource';
const resourceSchema = z.object({
name: z.string().min(3, 'Name must be at least 3 characters'),
description: z.string().optional(),
status: z.enum(['active', 'draft']),
});
interface ResourceFormProps {
resource?: Resource;
onSubmit: (data: CreateResourceInput) => void;
isLoading?: boolean;
}
export function ResourceForm({ resource, onSubmit, isLoading }: ResourceFormProps) {
const form = useForm<CreateResourceInput>({
resolver: zodResolver(resourceSchema),
defaultValues: {
name: resource?.name || '',
description: resource?.description || '',
status: resource?.status || 'draft',
},
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Resource name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder="Resource description" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="active">Active</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : resource ? 'Update' : 'Create'}
</Button>
</div>
</form>
</Form>
);
}
Resource List Page:
// client/src/app/resources/page.tsx
'use client';
import { useState } from 'react';
import { useResources, useArchiveResource } from '@/hooks/use-resources';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import Link from 'next/link';
import { Plus, Archive } from 'lucide-react';
export default function ResourcesPage() {
const { data: resources, isLoading } = useResources();
const archiveMutation = useArchiveResource();
if (isLoading) {
return <div>Loading...</div>;
}
return (
<div className="container mx-auto py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Resources</h1>
<Button asChild>
<Link href="/resources/new">
<Plus className="mr-2 h-4 w-4" />
New Resource
</Link>
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{resources?.map((resource) => (
<Card key={resource.id}>
<CardHeader>
<div className="flex justify-between items-start">
<CardTitle>{resource.name}</CardTitle>
<Badge variant={resource.status === 'active' ? 'default' : 'secondary'}>
{resource.status}
</Badge>
</div>
<CardDescription>{resource.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/resources/${resource.id}`}>View</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/resources/${resource.id}/edit`}>Edit</Link>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => archiveMutation.mutate(resource.id)}
>
<Archive className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
Step 4: Testing
I write comprehensive tests:
// server/test/resource.e2e-spec.ts
describe('Resource API (e2e)', () => {
it('should create a resource', async () => {
const res = await request(app)
.post('/resources')
.send({ name: 'Test Resource', status: 'draft' })
.expect(201);
expect(res.body.name).toBe('Test Resource');
expect(res.body.id).toBeDefined();
});
it('should get resource with stats', async () => {
const res = await request(app)
.get(`/resources/${resourceId}/stats`)
.expect(200);
expect(res.body.stats).toBeDefined();
expect(res.body.stats.total_items).toBeGreaterThanOrEqual(0);
});
});
// client/src/components/features/resources/__tests__/resource-form.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { ResourceForm } from '../resource-form';
describe('ResourceForm', () => {
it('renders form fields', () => {
render(<ResourceForm onSubmit={jest.fn()} />);
expect(screen.getByLabelText('Name')).toBeInTheDocument();
expect(screen.getByLabelText('Description')).toBeInTheDocument();
expect(screen.getByLabelText('Status')).toBeInTheDocument();
});
it('validates required fields', async () => {
const onSubmit = jest.fn();
render(<ResourceForm onSubmit={onSubmit} />);
fireEvent.click(screen.getByText('Create'));
expect(await screen.findByText('Name must be at least 3 characters')).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it('submits valid data', async () => {
const onSubmit = jest.fn();
render(<ResourceForm onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Test' } });
fireEvent.click(screen.getByText('Create'));
expect(onSubmit).toHaveBeenCalledWith({
name: 'Test',
status: 'draft',
});
});
});
Feature Patterns I Use
Pattern 1: List-Detail-Edit
Standard CRUD pattern with three views
Pattern 2: Wizard/Multi-Step
Break complex features into steps
Pattern 3: Modal Forms
Quick actions without page navigation
Pattern 4: Inline Editing
Edit directly in lists/tables
Pattern 5: Bulk Actions
Select multiple items and perform actions
Quality Checklist
Before marking a feature complete:
- ✅ Backend endpoints work and return proper responses
- ✅ Input validation prevents invalid data
- ✅ Error handling provides helpful messages
- ✅ Multi-tenancy enforced (org-scoped queries)
- ✅ Frontend UI matches design system
- ✅ Forms have validation with helpful error messages
- ✅ Loading states while fetching data
- ✅ Success/error toasts for user feedback
- ✅ Responsive design works on mobile
- ✅ Tests cover happy path and error cases
- ✅ OpenAPI documentation updated
- ✅ Code follows SOLID principles
Ready?
Tell me what feature you want to build, and I'll implement it full-stack with tests and documentation.
What feature should we build?
スコア
総合スコア
リポジトリの品質指標に基づく評価
SKILL.mdファイルが含まれている
ライセンスが設定されている
100文字以上の説明がある
GitHub Stars 100以上
3ヶ月以内に更新がある
10回以上フォークされている
オープンIssueが50未満
プログラミング言語が設定されている
1つ以上のタグが設定されている
レビュー
レビュー機能は近日公開予定です