
Fullstack Workspace Init
- 125 installs
- 31 repo stars
- Updated August 2, 2026
- shipshitdev/library
Bootstrap a fullstack monorepo with shared configs, package boundaries, dev scripts, and env templates so the team codes features on day one instead of wiring boilerplate.
About
Scaffolds a production-ready fullstack workspace with standardized monorepo layout, shared tooling configs, frontend and backend package boundaries, local dev scripts, and environment templates so new projects start consistently with less setup drift.
- Monorepo scaffolding
- Shared build and TypeScript config
- Frontend and backend package boundaries
- Local dev script wiring
- Environment template defaults
Fullstack Workspace Init by the numbers
- 125 all-time installs (skills.sh)
- +3 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #702 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/shipshitdev/library --skill fullstack-workspace-initAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 125 |
|---|---|
| repo stars | ★ 31 |
| Last updated | August 2, 2026 |
| Repository | shipshitdev/library ↗ |
What it does
Bootstrap a fullstack monorepo with shared configs, package boundaries, dev scripts, and env templates so the team codes features on day one instead of wiring boilerplate.
Files
Full Stack Workspace Init
Create a Shipshit.dev product workspace. For new product repos, use npx @shipshitdev/v0 as the default scaffolder and treat this skill as the product-brief, customization, and verification layer.
Contract
Inputs:
- Project directory and product name
- Product scope or PRD-style brief
- Selected app surfaces and product routes
- Agent handoff preference: codex, claude, or skip-agent
Outputs:
- v0 command or interactive route used
- Generated workspace summary
- Quality gate and startup status
- Follow-up tasks for app-specific customization
Creates/Modifies:
- New Bun/Turbo product repo when v0 runs
.agents/skills,.agents/memory,.claude,.codex, apps, packages, and.v0files generated by v0- App-specific files only when customizing after v0 generation
External Side Effects:
- May install dependencies and start
apps/web - May create GitHub repo/issues only when GitHub flags are explicitly enabled
Confirmation Required:
- Before creating a GitHub repo or issue
- Before running in a non-empty directory
- Before overwriting generated app files after v0 completes
Delegates To:
project-init-orchestratorfor route selectionagent-folder-initonly for existing repos not generated by v0testing-cicd-init,linter-formatter-init, andshadcn-setupfor repair/customizationscaffoldfor incremental modules inside the generated workspace
Default v0 Route
For new Shipshit.dev product repos, run:
npx @shipshitdev/v0 <project-directory>Use non-interactive mode when the user gives enough detail:
npx @shipshitdev/v0 <project-directory> \
--scope "<product scope>" \
--agent codex \
--apps web,app,desktop,mobile,extension,cli \
--routes overview,new-task,search,inbox,activities \
--no-githubUse --skip-agent, --no-install, and --no-start for CI/smoke tests or when the user only wants the scaffold written.
Legacy Manual Route
Use the manual guidance below only when v0 is not appropriate or when enhancing an existing workspace that already has its core scaffold.
Do not use this route for a new Shipshit.dev product repo unless v0 is unavailable or the user explicitly asks to bypass it.
Stack: Next.js 16 + React 19 + TypeScript + Tailwind + @agenticindiedev/ui (frontend), NestJS 11 + MongoDB + Clerk Auth + Swagger (backend), Vitest 80% coverage + Biome + Husky + GitHub Actions CI/CD, Bun package manager.
Load references/legacy-manual-route.md for the full step-by-step workflow, generated structure, key code patterns, and development commands.
---
References
references/templates/- Code generation templatesservice.spec.template.ts- NestJS service unit test templatecontroller.spec.template.ts- NestJS controller unit test templatee2e.spec.template.ts- E2E test template with supertest + MongoDB Memory Servercomponent.spec.template.tsx- React component test templatehook.spec.template.ts- React hook test templatetest-setup.template.ts- Frontend test setup with Clerk mocksreferences/vitest.config.ts- Backend Vitest configuration (80% coverage)references/vitest.config.frontend.ts- Frontend Vitest configuration (jsdom)references/github-actions/ci.yml- CI/CD workflowreferences/architecture-guide.md- Architectural decisionsreferences/coding-standards.md- Coding rules
{
"name": "fullstack-workspace-init",
"version": "1.0.0",
"description": "Scaffold a production-ready full-stack monorepo with working MVP features, tests, and CI/CD. Generat",
"author": {
"name": "Ship Shit Dev",
"email": "hello@shipshit.dev",
"url": "https://shipshit.dev"
},
"license": "MIT",
"skills": "."
}
Architecture Guide
Architectural patterns for the full-stack workspace.
---
Project Structure
workspace/
├── api/ # NestJS backend
├── frontend/ # NextJS apps
├── mobile/ # React Native + Expo
└── packages/ # Shared code---
Backend Architecture (NestJS)
Collection Pattern
Each feature is a "collection" with consistent structure:
collections/users/
├── users.module.ts # NestJS module
├── controllers/
│ └── users.controller.ts # HTTP endpoints
├── services/
│ └── users.service.ts # Business logic
├── schemas/
│ └── users.schema.ts # Mongoose schema
├── dto/
│ ├── create-user.dto.ts
│ └── update-user.dto.ts
└── users.http # REST Client testsDatabase Patterns
Soft Deletes:
@Prop({ default: false, index: true })
isDeleted: boolean;Multi-Tenancy:
// Always filter by organization
async findAll(organizationId: string) {
return this.model.find({
organization: organizationId,
isDeleted: false,
});
}Indexes:
- Simple indexes: In schema via
@Prop({ index: true }) - Compound indexes: In module's
useFactory
MongooseModule.forFeatureAsync([{
name: User.name,
useFactory: () => {
const schema = UserSchema;
schema.index({ organization: 1, isDeleted: 1 });
return schema;
},
}]),---
Frontend Architecture (NextJS)
Package Structure
frontend/
├── apps/
│ ├── dashboard/ # Main app
│ ├── admin/ # Admin app
│ └── settings/ # Settings app
└── packages/
├── components/ # Reusable UI
├── services/ # API clients
├── hooks/ # Custom hooks
├── interfaces/ # TypeScript types
└── props/ # Component propsPath Aliases
import { Button } from "@components/ui/Button";
import { UserService } from "@services/user";
import { useUser } from "@hooks/useUser";
import type { IUser } from "@interfaces/user";Async Operations
Always use AbortController:
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const data = await service.getData({ signal: controller.signal });
setData(data);
} catch (error) {
if (error.name === "AbortError") return;
handleError(error);
}
};
fetchData();
return () => controller.abort();
}, []);---
Mobile Architecture (React Native + Expo)
Expo Router
File-based routing:
mobile/app/
├── _layout.tsx # Root layout
├── index.tsx # Home screen
├── (tabs)/ # Tab group
│ ├── _layout.tsx
│ ├── home.tsx
│ └── profile.tsx
└── settings/
└── index.tsx---
Shared Packages
Location
All shared code goes in packages/:
packages/packages/
├── common/
│ ├── serializers/ # Data serializers
│ ├── interfaces/ # Shared types
│ └── enums/ # Shared enums
├── helpers/ # Utility functions
└── constants/ # Shared constantsSerializers
Serializers live in packages, NOT in API:
// packages/packages/common/serializers/user.serializer.ts
export function serializeUser(user: UserDocument): IUser {
return {
id: user._id.toString(),
name: user.name,
email: user.email,
// Never expose isDeleted, internal fields, etc.
};
}---
Data Flow
Frontend → API → Service → Database
↓
Mobile →
← Serializer ← Response1. Frontend/Mobile makes API request 2. Controller receives request 3. Service handles business logic 4. Database query with organization + isDeleted filters 5. Serializer transforms response 6. Client receives clean data
---
Authentication
- Use Clerk (or similar) for auth
- JWT tokens in Authorization header
- Guards validate tokens on protected routes
@UseGuards(ClerkAuthGuard)
@Controller("protected")
export class ProtectedController {}---
Caching
- Redis for query caching
- BullMQ for job queues
- Cache invalidation on mutations
---
Environment
Each project has its own .env:
api/.env
frontend/.env
mobile/.envNever commit .env files.
Coding Standards
Coding rules for the full-stack workspace.
---
General Rules
Do
- Follow existing patterns (search for 3+ examples)
- Use TypeScript strict mode
- Write meaningful variable names
- Keep functions small and focused
- Handle errors properly
Don't
- Use
anytype - Use
console.log(use LoggerService) - Create inline interfaces
- Skip error handling
- Commit without review
---
TypeScript
Types
// ❌ Wrong
function process(data: any) {}
// ✅ Correct
function process(data: UserData): ProcessedResult {}Interfaces
// ❌ Wrong - inline
function Component({ name }: { name: string }) {}
// ✅ Correct - in dedicated file
// packages/props/user.props.ts
export interface UserProps {
name: string;
}
// component.tsx
import type { UserProps } from "@props/user";
function Component({ name }: UserProps) {}---
Imports
Order
1. External packages 2. Internal aliases 3. Relative imports 4. Types
// External
import { useState } from "react";
import { Controller } from "@nestjs/common";
// Internal aliases
import { Button } from "@components/ui";
import { UserService } from "@services/user";
// Relative
import { helpers } from "./utils";
// Types
import type { IUser } from "@interfaces/user";Path Aliases
// ❌ Wrong
import { Button } from "../../../packages/components/ui/Button";
// ✅ Correct
import { Button } from "@components/ui/Button";---
Error Handling
Backend
try {
const result = await operation();
return result;
} catch (error) {
this.logger.error("Operation failed", error, "ServiceName");
throw new InternalServerErrorException("User-friendly message");
}Frontend
try {
const data = await service.getData({ signal: controller.signal });
setData(data);
} catch (error) {
if (error.name === "AbortError") return;
LoggerService.getInstance().error("API failed", error);
NotificationService.getInstance().error("Something went wrong");
}---
Database
Soft Deletes
// ❌ Wrong
@Prop({ type: Date })
deletedAt?: Date;
// ✅ Correct
@Prop({ default: false, index: true })
isDeleted: boolean;Queries
// ❌ Wrong - missing filters
async findAll() {
return this.model.find();
}
// ✅ Correct - always filter
async findAll(organizationId: string) {
return this.model.find({
organization: organizationId,
isDeleted: false,
});
}Indexes
// Simple indexes - in schema
@Prop({ index: true })
email: string;
// Compound indexes - in module
schema.index({ organization: 1, isDeleted: 1 });---
API Endpoints
Controllers
@Get()
@ApiOperation({ summary: "Get all users" })
@ApiResponse({ status: 200, description: "Returns users" })
async findAll(@Query("organizationId") orgId: string) {
const users = await this.userService.findAll(orgId);
return users.map(serializeUser); // Always serialize
}DTOs
export class CreateUserDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty()
@IsEmail()
email: string;
}---
React Components
Structure
// 1. Imports
import { useState } from "react";
import type { ComponentProps } from "@props/component";
// 2. Types (if local only)
interface LocalState {
count: number;
}
// 3. Component
export function Component({ title }: ComponentProps) {
// State
const [state, setState] = useState<LocalState>({ count: 0 });
// Effects
useEffect(() => {
// ...
}, []);
// Handlers
const handleClick = () => {
// ...
};
// Render
return <div>{title}</div>;
}Async in useEffect
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const data = await api.get({ signal: controller.signal });
setData(data);
} catch (error) {
if (error.name === "AbortError") return;
handleError(error);
}
};
fetchData();
return () => controller.abort();
}, []);---
Naming Conventions
| Type | Convention | Example |
|---|---|---|
| Files | kebab-case | user-service.ts |
| Components | PascalCase | UserProfile.tsx |
| Variables | camelCase | userName |
| Constants | UPPER_SNAKE | MAX_RETRIES |
| Interfaces | PascalCase with I | IUserProfile |
| Types | PascalCase | UserRole |
| Enums | PascalCase | UserStatus |
---
Git
Commits
type(scope): description
feat(auth): add OAuth login
fix(api): handle null user
docs(readme): update setupBranches
feature/user-auth
fix/api-error
chore/update-deps---
Testing
- Tests run in CI/CD only
- Mock external dependencies
- Test business logic, not implementation
- Use descriptive test names
Deployment Guide
Deployment patterns for the full-stack workspace.
---
Overview
| Project | Platform | URL |
|---|---|---|
| API | Railway/Render/Fly.io | api.yourdomain.com |
| Frontend | Vercel | yourdomain.com |
| Mobile | App Store/Play Store | - |
---
API Deployment
Docker
The API includes a Dockerfile:
FROM oven/bun:1 AS base
WORKDIR /app
# Install dependencies
FROM base AS deps
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
# Build
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build
# Production
FROM base AS runner
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3001
CMD ["bun", "run", "start:prod"]Environment Variables
# Required
NODE_ENV=production
PORT=3001
MONGODB_URI=mongodb+srv://...
REDIS_URL=redis://...
# Auth (Clerk)
CLERK_SECRET_KEY=sk_...
# Optional
SENTRY_DSN=https://...Railway
1. Connect GitHub repo 2. Set root directory to api/ 3. Add environment variables 4. Deploy
Render
1. Create new Web Service 2. Connect GitHub repo 3. Set root directory to api/ 4. Build command: bun install && bun run build 5. Start command: bun run start:prod
---
Frontend Deployment
Vercel
1. Import project from GitHub 2. Set root directory to frontend/ 3. Framework: Next.js (auto-detected) 4. Add environment variables 5. Deploy
Environment Variables
NEXT_PUBLIC_API_URL=https://api.yourdomain.com
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_...Multiple Apps
For multiple NextJS apps, deploy each separately:
# Dashboard
vercel --cwd frontend/apps/dashboard
# Admin
vercel --cwd frontend/apps/admin---
Mobile Deployment
Expo Build
cd mobile
# iOS
eas build --platform ios
# Android
eas build --platform androidEAS Configuration
Create eas.json:
{
"cli": {
"version": ">= 3.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {}
},
"submit": {
"production": {}
}
}Environment Variables
# In app.json or via EAS secrets
EXPO_PUBLIC_API_URL=https://api.yourdomain.com---
CI/CD
GitHub Actions
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy-api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: cd api && bun install
- run: cd api && bun run build
# Deploy step depends on platform
deploy-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
working-directory: frontend---
Monitoring
Sentry
Add to both API and Frontend:
# API
bun add @sentry/nestjs
# Frontend
bun add @sentry/nextjsHealth Checks
API should expose:
@Get("/health")
health() {
return { status: "ok", timestamp: new Date().toISOString() };
}---
Database
MongoDB Atlas (Recommended)
1. Create Account & Cluster
- Go to MongoDB Atlas
- Create free M0 cluster (or paid tier for production)
- Select region closest to your deployment
2. Network Access
- Go to Security → Network Access
- Add IP:
0.0.0.0/0(allows all - for serverless/dynamic IPs) - Or add specific IPs for better security
3. Database User
- Go to Security → Database Access
- Create user with "Read and write to any database"
- Save username and password securely
4. Get Connection String
- Go to Database → Connect
- Choose "Connect your application"
- Select Node.js driver
- Copy connection string (mongodb+srv://...)
5. Configure Environment
MONGODB_URI=mongodb+srv://<username>:<password>@<cluster>.mongodb.net/<database>?retryWrites=true&w=majorityReplace <username>, <password>, <cluster>, and <database> with your values
Redis (Upstash)
1. Create database 2. Get connection string 3. Add to environment variables
---
Domain Setup
DNS Records
# API
api.yourdomain.com → CNAME → your-api-platform.com
# Frontend
yourdomain.com → CNAME → cname.vercel-dns.com
www.yourdomain.com → CNAME → cname.vercel-dns.comSSL
Automatic via platform (Vercel, Railway, etc.)
---
Checklist
Before Deploy
- [ ] Environment variables set
- [ ] Database accessible
- [ ] Redis accessible
- [ ] Auth configured
- [ ] CORS configured
After Deploy
- [ ] Health check passing
- [ ] API docs accessible
- [ ] Auth working
- [ ] Monitoring active
- [ ] Logs accessible
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run linter
run: bun run lint
test-api:
name: Test API
runs-on: ubuntu-latest
defaults:
run:
working-directory: api
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run tests with coverage
run: bun run test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./api/coverage/lcov.info
flags: api
fail_ci_if_error: false
test-frontend:
name: Test Frontend
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run tests with coverage
run: bun run test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./frontend/coverage/lcov.info
flags: frontend
fail_ci_if_error: false
build:
name: Build
runs-on: ubuntu-latest
needs: [lint, test-api, test-frontend]
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build API
run: cd api && bun run build
- name: Build Frontend
run: cd frontend && bun run build
env:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Type check API
run: cd api && bun run typecheck
- name: Type check Frontend
run: cd frontend && bun run typecheck
Legacy Manual Route — NestJS + Next.js Workspace
Use this guide only when npx @shipshitdev/v0 is not appropriate or the user explicitly asks to bypass it for an existing workspace.
Phase 1: PRD Brief Intake
Ask the user for a 1-2 paragraph product description, then extract and confirm:
I'll help you build [Project Name]. Based on your description, I understand:
**Entities:**
- [Entity1]: [fields]
- [Entity2]: [fields]
**Features:**
- [Feature 1]
- [Feature 2]
**Routes:**
- / - Home/Dashboard
- /[entity] - List view
- /[entity]/[id] - Detail view
**API Endpoints:**
- GET/POST /api/[entity]
- GET/PATCH/DELETE /api/[entity]/:id
Is this correct? Any adjustments?Phase 2: Auth Setup (Always Included)
Generate Clerk authentication:
Backend:
auth/guards/clerk-auth.guard.ts- Token verification guardauth/decorators/current-user.decorator.ts- User extraction decorator
Frontend:
providers/clerk-provider.tsx- ClerkProvider wrapperapp/sign-in/[[...sign-in]]/page.tsx- Sign in pageapp/sign-up/[[...sign-up]]/page.tsx- Sign up pageproxy.ts- Protected route middleware (Next.js 16+)
Environment:
.env.examplewith all required variables
Phase 3: Entity Generation
For each extracted entity, generate complete CRUD with tests:
Backend (NestJS):
api/apps/api/src/collections/{entity}/
├── {entity}.module.ts
├── {entity}.controller.ts # Full CRUD + Swagger + ClerkAuthGuard
├── {entity}.controller.spec.ts # Controller unit tests
├── {entity}.service.ts # Business logic
├── {entity}.service.spec.ts # Service unit tests
├── schemas/
│ └── {entity}.schema.ts # Mongoose schema with userId
└── dto/
├── create-{entity}.dto.ts # class-validator decorators
└── update-{entity}.dto.ts # PartialType of create
api/apps/api/test/
├── {entity}.e2e-spec.ts # E2E tests with supertest
└── setup.ts # Test setup with MongoDB Memory ServerFrontend (Next.js):
frontend/apps/dashboard/
├── app/{entity}/
│ ├── page.tsx # List view (protected)
│ └── [id]/page.tsx # Detail view (protected)
├── src/test/
│ └── setup.ts # Test setup with Clerk mocks
└── vitest.config.ts # Frontend test config (jsdom)
frontend/packages/components/
├── {entity}-list.tsx
├── {entity}-list.spec.tsx # Component tests
├── {entity}-form.tsx
├── {entity}-form.spec.tsx # Component tests
└── {entity}-item.tsx
frontend/packages/hooks/
├── use-{entities}.ts # React hook for state management
└── use-{entities}.spec.ts # Hook tests
frontend/packages/services/
└── {entity}.service.ts # API client with auth headersPhase 4: Quality Setup
Vitest Configuration:
vitest.config.tsin each project- 80% coverage threshold for lines, functions, branches
@vitest/coverage-v8provider
GitHub Actions:
.github/workflows/ci.yml- Runs on push to main and PRs
- Steps: install → lint → test → build
Husky Hooks:
- Pre-commit:
lint-staged(Biome check) - Pre-push:
bun run typecheck
Biome:
biome.jsonin each project- 100 character line width
- Double quotes, semicolons
Phase 5: Verification
✅ Generation complete!
Quality Report:
- bun install: ✓ succeeded
- bun run lint: ✓ 0 errors
- bun run test: ✓ 24 tests passed
- Coverage: 82% (threshold: 80%)
Ready to run:
cd [project]
bun devGenerated Structure
myproject/
├── .github/
│ └── workflows/
│ └── ci.yml # GitHub Actions CI/CD
├── .husky/
│ ├── pre-commit # Lint staged files
│ └── pre-push # Type check
├── .agents/ # AI documentation
├── package.json # Workspace root
├── biome.json # Root linting config
│
├── api/ # NestJS backend
│ ├── apps/api/src/
│ │ ├── main.ts
│ │ ├── app.module.ts
│ │ ├── auth/
│ │ │ ├── guards/clerk-auth.guard.ts
│ │ │ ├── guards/clerk-auth.guard.spec.ts # Auth guard tests
│ │ │ └── decorators/current-user.decorator.ts
│ │ └── collections/
│ │ └── {entity}/
│ │ ├── {entity}.controller.ts
│ │ ├── {entity}.controller.spec.ts # Controller tests
│ │ ├── {entity}.service.ts
│ │ └── {entity}.service.spec.ts # Service tests
│ ├── apps/api/test/
│ │ ├── {entity}.e2e-spec.ts # E2E tests
│ │ └── setup.ts # E2E test setup
│ ├── vitest.config.ts
│ ├── package.json
│ └── .env.example
│
├── frontend/ # Next.js apps
│ ├── apps/dashboard/
│ │ ├── app/
│ │ │ ├── layout.tsx
│ │ │ ├── page.tsx
│ │ │ ├── sign-in/[[...sign-in]]/page.tsx
│ │ │ ├── sign-up/[[...sign-up]]/page.tsx
│ │ │ └── {entity}/ # Generated per entity
│ │ ├── src/test/
│ │ │ └── setup.ts # Test setup with Clerk mocks
│ │ ├── proxy.ts # Clerk route protection (Next.js 16+)
│ │ └── providers/
│ │ └── clerk-provider.tsx
│ ├── packages/
│ │ ├── components/
│ │ │ ├── {entity}-list.tsx
│ │ │ ├── {entity}-list.spec.tsx # Component tests
│ │ │ ├── {entity}-form.tsx
│ │ │ └── {entity}-form.spec.tsx # Component tests
│ │ ├── hooks/
│ │ │ ├── use-{entities}.ts
│ │ │ └── use-{entities}.spec.ts # Hook tests
│ │ ├── services/ # API clients
│ │ └── interfaces/
│ ├── vitest.config.ts # Frontend test config (jsdom)
│ └── package.json
│
├── mobile/ # React Native + Expo (optional)
│ └── ...
│
└── packages/ # Shared packages
└── packages/
├── common/
│ ├── interfaces/
│ └── enums/
└── helpers/Key Patterns
Backend Controller Pattern
@ApiTags('tasks')
@ApiBearerAuth()
@UseGuards(ClerkAuthGuard)
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Post()
@ApiOperation({ summary: 'Create a new task' })
create(
@Body() createTaskDto: CreateTaskDto,
@CurrentUser() user: { userId: string },
) {
return this.tasksService.create(createTaskDto, user.userId);
}
// ... full CRUD
}Backend Service Pattern
@Injectable()
export class TasksService {
constructor(
@InjectModel(Task.name) private taskModel: Model<TaskDocument>,
) {}
async create(createTaskDto: CreateTaskDto, userId: string): Promise<Task> {
const task = new this.taskModel({ ...createTaskDto, userId });
return task.save();
}
// ... full CRUD with userId filtering
}Frontend Component Pattern
'use client';
import { useEffect, useState } from 'react';
import { TaskService } from '@services/task.service';
import { Task } from '@interfaces/task.interface';
export function TaskList() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
TaskService.getAll({ signal: controller.signal })
.then(setTasks)
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
// ... render
}Additional Scripts
# Add a new entity to existing project
python3 scripts/add-entity.py \
--root ~/www/myproject \
--name "comment" \
--fields "content:string,taskId:string"
# Add a new frontend app
python3 scripts/add-frontend-app.py \
--root ~/www/myproject/frontend \
--name adminDevelopment Commands
After scaffolding:
cd myproject
# Install all dependencies
bun install
# Start all services (backend + frontend)
bun dev
# Or start individually
bun run dev:api # Backend on :3001
bun run dev:frontend # Frontend on :3000
bun run dev:mobile # Mobile via Expo
# Quality commands
bun run lint # Check code style
bun run test # Run tests
bun run test:coverage # Run with coverage
bun run typecheck # Type checkingEnvironment Variables
Create .env files based on .env.example:
API (.env):
PORT=3001
MONGODB_URI=mongodb://localhost:27017/myproject
CLERK_SECRET_KEY=sk_test_...Frontend (.env.local):
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
NEXT_PUBLIC_API_URL=http://localhost:3001/**
* Clerk Auth Guard Template
*
* Place this at: api/apps/api/src/auth/guards/clerk-auth.guard.ts
*/
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from "@nestjs/common";
import { Clerk } from "@clerk/clerk-sdk-node";
@Injectable()
export class ClerkAuthGuard implements CanActivate {
private clerk: Clerk;
constructor() {
this.clerk = new Clerk({
secretKey: process.env.CLERK_SECRET_KEY || "",
});
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw new UnauthorizedException("No authorization token provided");
}
const token = authHeader.replace("Bearer ", "");
try {
const session = await this.clerk.verifyToken(token);
request.user = {
userId: session.sub,
sessionId: session.sid,
};
return true;
} catch (error) {
throw new UnauthorizedException("Invalid or expired token");
}
}
}
/**
* Clerk Provider Template
*
* Place this at: frontend/apps/dashboard/providers/clerk-provider.tsx
*/
"use client";
import { ClerkProvider as BaseClerkProvider } from "@clerk/nextjs";
import { dark } from "@clerk/themes";
interface ClerkProviderProps {
children: React.ReactNode;
}
export function ClerkProvider({ children }: ClerkProviderProps) {
return (
<BaseClerkProvider
appearance={{
baseTheme: dark,
variables: {
colorPrimary: "#3b82f6",
colorBackground: "#0f172a",
colorInputBackground: "#1e293b",
colorInputText: "#f8fafc",
},
elements: {
formButtonPrimary:
"bg-blue-600 hover:bg-blue-700 text-white font-medium",
card: "bg-slate-900 border border-slate-800",
headerTitle: "text-white",
headerSubtitle: "text-slate-400",
socialButtonsBlockButton:
"bg-slate-800 border-slate-700 text-white hover:bg-slate-700",
formFieldLabel: "text-slate-300",
formFieldInput: "bg-slate-800 border-slate-700 text-white",
footerActionLink: "text-blue-500 hover:text-blue-400",
},
}}
>
{children}
</BaseClerkProvider>
);
}
/**
* React Form Component Template
*
* Replace {{Entity}} with PascalCase entity name (e.g., Task)
* Replace {{entity}} with camelCase entity name (e.g., task)
* Replace {{FIELDS}} with actual form fields
*/
"use client";
import { useState } from "react";
import { {{Entity}} } from "@interfaces/{{entity}}.interface";
import { Button, Input } from "@agenticindiedev/ui";
interface {{Entity}}FormProps {
{{entity}}?: {{Entity}};
onSubmit: (data: Partial<{{Entity}}>) => Promise<void>;
onCancel: () => void;
}
export function {{Entity}}Form({ {{entity}}, onSubmit, onCancel }: {{Entity}}FormProps) {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState<Partial<{{Entity}}>>({
// Initialize with existing data or defaults
// {{FIELDS}}
...{{entity}},
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await onSubmit(formData);
} finally {
setLoading(false);
}
};
const handleChange = (field: keyof {{Entity}}, value: unknown) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
return (
<form onSubmit={handleSubmit} className="space-y-4 p-4 border rounded-lg">
{/**
* FORM FIELD EXAMPLES:
*
* Text input:
* <div>
* <label className="block text-sm font-medium mb-1">Title</label>
* <Input
* value={formData.title || ""}
* onChange={(e) => handleChange("title", e.target.value)}
* placeholder="Enter title"
* required
* />
* </div>
*
* Textarea:
* <div>
* <label className="block text-sm font-medium mb-1">Description</label>
* <textarea
* className="w-full p-2 border rounded"
* value={formData.description || ""}
* onChange={(e) => handleChange("description", e.target.value)}
* rows={3}
* />
* </div>
*
* Select:
* <div>
* <label className="block text-sm font-medium mb-1">Priority</label>
* <select
* className="w-full p-2 border rounded"
* value={formData.priority || "medium"}
* onChange={(e) => handleChange("priority", e.target.value)}
* >
* <option value="low">Low</option>
* <option value="medium">Medium</option>
* <option value="high">High</option>
* </select>
* </div>
*
* Date:
* <div>
* <label className="block text-sm font-medium mb-1">Due Date</label>
* <Input
* type="date"
* value={formData.dueDate || ""}
* onChange={(e) => handleChange("dueDate", e.target.value)}
* />
* </div>
*/}
{/* {{FIELDS}} */}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Saving..." : {{entity}} ? "Update" : "Create"}
</Button>
</div>
</form>
);
}
/**
* React List Component Template
*
* Replace {{Entity}} with PascalCase entity name (e.g., Task)
* Replace {{entity}} with camelCase entity name (e.g., task)
* Replace {{entities}} with plural camelCase (e.g., tasks)
*/
"use client";
import { useEffect, useState } from "react";
import { {{Entity}}Service } from "@services/{{entity}}.service";
import { {{Entity}} } from "@interfaces/{{entity}}.interface";
import { {{Entity}}Item } from "./{{entity}}-item";
import { {{Entity}}Form } from "./{{entity}}-form";
import { Button } from "@agenticindiedev/ui";
export function {{Entity}}List() {
const [{{entities}}, set{{Entity}}s] = useState<{{Entity}}[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const fetch{{Entity}}s = async () => {
try {
setLoading(true);
const controller = new AbortController();
const data = await {{Entity}}Service.getAll({ signal: controller.signal });
set{{Entity}}s(data);
setError(null);
} catch (err) {
if (err instanceof Error && err.name !== "AbortError") {
setError(err.message);
}
} finally {
setLoading(false);
}
};
useEffect(() => {
const controller = new AbortController();
{{Entity}}Service.getAll({ signal: controller.signal })
.then(set{{Entity}}s)
.catch((err) => {
if (err.name !== "AbortError") {
setError(err.message);
}
})
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
const handleCreate = async (data: Partial<{{Entity}}>) => {
try {
await {{Entity}}Service.create(data);
setShowForm(false);
fetch{{Entity}}s();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create");
}
};
const handleUpdate = async (id: string, data: Partial<{{Entity}}>) => {
try {
await {{Entity}}Service.update(id, data);
fetch{{Entity}}s();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update");
}
};
const handleDelete = async (id: string) => {
try {
await {{Entity}}Service.delete(id);
fetch{{Entity}}s();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete");
}
};
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
if (error) {
return (
<div className="p-4 bg-red-50 text-red-600 rounded-lg">
Error: {error}
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">{{Entity}}s</h2>
<Button onClick={() => setShowForm(true)}>
Add {{Entity}}
</Button>
</div>
{showForm && (
<{{Entity}}Form
onSubmit={handleCreate}
onCancel={() => setShowForm(false)}
/>
)}
{{{entities}}.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No {{entities}} yet. Create your first one!
</div>
) : (
<div className="space-y-2">
{{{entities}}.map(({{entity}}) => (
<{{Entity}}Item
key={{{entity}}._id}
{{entity}}={{{entity}}}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
);
}
/**
* React Component Test Template (Vitest + Testing Library)
*
* Replace {{Entity}} with PascalCase entity name (e.g., Task)
* Replace {{entity}} with camelCase entity name (e.g., task)
* Replace {{entities}} with plural camelCase (e.g., tasks)
*
* Requires: npm install -D @testing-library/react @testing-library/jest-dom jsdom
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { {{Entity}}List } from "./{{entity}}-list";
import { {{Entity}}Form } from "./{{entity}}-form";
import { {{Entity}}Service } from "@services/{{entity}}.service";
// Mock the service
vi.mock("@services/{{entity}}.service", () => ({
{{Entity}}Service: {
getAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}));
// Mock Clerk
vi.mock("@clerk/nextjs", () => ({
useAuth: () => ({
getToken: vi.fn().mockResolvedValue("test-token"),
isSignedIn: true,
}),
useUser: () => ({
user: { id: "user-123", firstName: "Test" },
}),
}));
describe("{{Entity}}List", () => {
const mock{{Entity}}s = [
{ _id: "1", title: "{{Entity}} 1", userId: "user-123" },
{ _id: "2", title: "{{Entity}} 2", userId: "user-123" },
];
beforeEach(() => {
vi.clearAllMocks();
({{Entity}}Service.getAll as any).mockResolvedValue(mock{{Entity}}s);
});
it("should render loading state initially", () => {
render(<{{Entity}}List />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("should render {{entities}} after loading", async () => {
render(<{{Entity}}List />);
await waitFor(() => {
expect(screen.getByText("{{Entity}} 1")).toBeInTheDocument();
expect(screen.getByText("{{Entity}} 2")).toBeInTheDocument();
});
});
it("should handle empty state", async () => {
({{Entity}}Service.getAll as any).mockResolvedValue([]);
render(<{{Entity}}List />);
await waitFor(() => {
expect(screen.getByText(/no {{entities}} found/i)).toBeInTheDocument();
});
});
it("should handle error state", async () => {
({{Entity}}Service.getAll as any).mockRejectedValue(new Error("Failed to fetch"));
render(<{{Entity}}List />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
it("should delete a {{entity}}", async () => {
({{Entity}}Service.delete as any).mockResolvedValue(undefined);
render(<{{Entity}}List />);
await waitFor(() => {
expect(screen.getByText("{{Entity}} 1")).toBeInTheDocument();
});
const deleteButtons = screen.getAllByRole("button", { name: /delete/i });
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect({{Entity}}Service.delete).toHaveBeenCalledWith("1", expect.any(Object));
});
});
});
describe("{{Entity}}Form", () => {
const mockOnSubmit = vi.fn();
const mockOnCancel = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("should render empty form for new {{entity}}", () => {
render(<{{Entity}}Form onSubmit={mockOnSubmit} onCancel={mockOnCancel} />);
expect(screen.getByLabelText(/title/i)).toHaveValue("");
});
it("should render populated form for editing", () => {
const {{entity}} = { _id: "1", title: "Existing {{Entity}}" };
render(
<{{Entity}}Form
initialData={{ entity }}
onSubmit={mockOnSubmit}
onCancel={mockOnCancel}
/>
);
expect(screen.getByLabelText(/title/i)).toHaveValue("Existing {{Entity}}");
});
it("should call onSubmit with form data", async () => {
render(<{{Entity}}Form onSubmit={mockOnSubmit} onCancel={mockOnCancel} />);
const titleInput = screen.getByLabelText(/title/i);
fireEvent.change(titleInput, { target: { value: "New {{Entity}}" } });
const submitButton = screen.getByRole("button", { name: /save|submit/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({ title: "New {{Entity}}" })
);
});
});
it("should call onCancel when cancel button clicked", () => {
render(<{{Entity}}Form onSubmit={mockOnSubmit} onCancel={mockOnCancel} />);
const cancelButton = screen.getByRole("button", { name: /cancel/i });
fireEvent.click(cancelButton);
expect(mockOnCancel).toHaveBeenCalled();
});
it("should show validation errors for empty required fields", async () => {
render(<{{Entity}}Form onSubmit={mockOnSubmit} onCancel={mockOnCancel} />);
const submitButton = screen.getByRole("button", { name: /save|submit/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/title is required/i)).toBeInTheDocument();
});
expect(mockOnSubmit).not.toHaveBeenCalled();
});
});
/**
* NestJS Controller Test Template (Vitest)
*
* Replace {{Entity}} with PascalCase entity name (e.g., Task)
* Replace {{entity}} with camelCase entity name (e.g., task)
* Replace {{entities}} with plural camelCase (e.g., tasks)
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { {{Entity}}sController } from "./{{entities}}.controller";
import { {{Entity}}sService } from "./{{entities}}.service";
import { Create{{Entity}}Dto } from "./dto/create-{{entity}}.dto";
import { Update{{Entity}}Dto } from "./dto/update-{{entity}}.dto";
describe("{{Entity}}sController", () => {
let controller: {{Entity}}sController;
let service: {{Entity}}sService;
const mockUser = { userId: "user-123" };
const mock{{Entity}} = {
_id: "{{entity}}-123",
title: "Test {{Entity}}",
userId: mockUser.userId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const mock{{Entity}}sService = {
create: vi.fn(),
findAll: vi.fn(),
findOne: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [{{Entity}}sController],
providers: [
{
provide: {{Entity}}sService,
useValue: mock{{Entity}}sService,
},
],
}).compile();
controller = module.get<{{Entity}}sController>({{Entity}}sController);
service = module.get<{{Entity}}sService>({{Entity}}sService);
// Reset mocks
vi.clearAllMocks();
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("create", () => {
it("should create a new {{entity}}", async () => {
const createDto: Create{{Entity}}Dto = {
title: "New {{Entity}}",
};
mock{{Entity}}sService.create.mockResolvedValue(mock{{Entity}});
const result = await controller.create(createDto, mockUser);
expect(service.create).toHaveBeenCalledWith(createDto, mockUser.userId);
expect(result).toEqual(mock{{Entity}});
});
});
describe("findAll", () => {
it("should return all {{entities}} for user", async () => {
const {{entities}} = [mock{{Entity}}];
mock{{Entity}}sService.findAll.mockResolvedValue({{entities}});
const result = await controller.findAll(mockUser);
expect(service.findAll).toHaveBeenCalledWith(mockUser.userId);
expect(result).toEqual({{entities}});
});
});
describe("findOne", () => {
it("should return a single {{entity}}", async () => {
mock{{Entity}}sService.findOne.mockResolvedValue(mock{{Entity}});
const result = await controller.findOne("{{entity}}-123", mockUser);
expect(service.findOne).toHaveBeenCalledWith("{{entity}}-123", mockUser.userId);
expect(result).toEqual(mock{{Entity}});
});
});
describe("update", () => {
it("should update a {{entity}}", async () => {
const updateDto: Update{{Entity}}Dto = { title: "Updated" };
const updated{{Entity}} = { ...mock{{Entity}}, ...updateDto };
mock{{Entity}}sService.update.mockResolvedValue(updated{{Entity}});
const result = await controller.update("{{entity}}-123", updateDto, mockUser);
expect(service.update).toHaveBeenCalledWith("{{entity}}-123", updateDto, mockUser.userId);
expect(result.title).toBe("Updated");
});
});
describe("remove", () => {
it("should delete a {{entity}}", async () => {
mock{{Entity}}sService.remove.mockResolvedValue(undefined);
await controller.remove("{{entity}}-123", mockUser);
expect(service.remove).toHaveBeenCalledWith("{{entity}}-123", mockUser.userId);
});
});
});
/**
* NestJS Controller Template
*
* Replace {{Entity}} with PascalCase entity name (e.g., Task)
* Replace {{entity}} with camelCase entity name (e.g., task)
* Replace {{entities}} with plural camelCase (e.g., tasks)
*/
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
UseGuards,
} from "@nestjs/common";
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from "@nestjs/swagger";
import { {{Entity}}sService } from "./{{entities}}.service";
import { Create{{Entity}}Dto } from "./dto/create-{{entity}}.dto";
import { Update{{Entity}}Dto } from "./dto/update-{{entity}}.dto";
import { ClerkAuthGuard } from "../auth/guards/clerk-auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
@ApiTags("{{entities}}")
@ApiBearerAuth()
@UseGuards(ClerkAuthGuard)
@Controller("{{entities}}")
export class {{Entity}}sController {
constructor(private readonly {{entities}}Service: {{Entity}}sService) {}
@Post()
@ApiOperation({ summary: "Create a new {{entity}}" })
create(
@Body() create{{Entity}}Dto: Create{{Entity}}Dto,
@CurrentUser() user: { userId: string },
) {
return this.{{entities}}Service.create(create{{Entity}}Dto, user.userId);
}
@Get()
@ApiOperation({ summary: "Get all {{entities}}" })
findAll(@CurrentUser() user: { userId: string }) {
return this.{{entities}}Service.findAll(user.userId);
}
@Get(":id")
@ApiOperation({ summary: "Get a {{entity}} by ID" })
findOne(
@Param("id") id: string,
@CurrentUser() user: { userId: string },
) {
return this.{{entities}}Service.findOne(id, user.userId);
}
@Patch(":id")
@ApiOperation({ summary: "Update a {{entity}}" })
update(
@Param("id") id: string,
@Body() update{{Entity}}Dto: Update{{Entity}}Dto,
@CurrentUser() user: { userId: string },
) {
return this.{{entities}}Service.update(id, update{{Entity}}Dto, user.userId);
}
@Delete(":id")
@ApiOperation({ summary: "Delete a {{entity}}" })
remove(
@Param("id") id: string,
@CurrentUser() user: { userId: string },
) {
return this.{{entities}}Service.remove(id, user.userId);
}
}
/**
* Current User Decorator Template
*
* Place this at: api/apps/api/src/auth/decorators/current-user.decorator.ts
*/
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export interface CurrentUserPayload {
userId: string;
sessionId?: string;
}
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): CurrentUserPayload => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
/**
* DTO Templates
*
* Replace {{Entity}} with PascalCase entity name (e.g., Task)
* Replace {{FIELDS}} with actual field definitions
*/
// === create-{{entity}}.dto.ts ===
import {
IsString,
IsOptional,
IsEnum,
IsArray,
IsDateString,
IsBoolean,
IsNumber,
} from "class-validator";
/**
* FIELD EXAMPLES - Replace with actual fields:
*
* Required string:
* @IsString()
* title: string;
*
* Optional string:
* @IsString()
* @IsOptional()
* description?: string;
*
* Enum:
* @IsEnum(Priority)
* @IsOptional()
* priority?: Priority;
*
* Date string:
* @IsDateString()
* @IsOptional()
* dueDate?: string;
*
* Boolean:
* @IsBoolean()
* @IsOptional()
* isCompleted?: boolean;
*
* Number:
* @IsNumber()
* @IsOptional()
* order?: number;
*
* Array of strings:
* @IsArray()
* @IsString({ each: true })
* @IsOptional()
* tags?: string[];
*/
export class Create{{Entity}}Dto {
// {{FIELDS}}
}
// === update-{{entity}}.dto.ts ===
import { PartialType } from "@nestjs/swagger";
import { Create{{Entity}}Dto } from "./create-{{entity}}.dto";
export class Update{{Entity}}Dto extends PartialType(Create{{Entity}}Dto) {}
/**
* NestJS E2E Test Template (Vitest + Supertest)
*
* Replace {{Entity}} with PascalCase entity name (e.g., Task)
* Replace {{entity}} with camelCase entity name (e.g., task)
* Replace {{entities}} with plural camelCase (e.g., tasks)
*
* Requires: npm install -D supertest @types/supertest
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { INestApplication, ValidationPipe } from "@nestjs/common";
import * as request from "supertest";
import { AppModule } from "../app.module";
import { MongoMemoryServer } from "mongodb-memory-server";
import { MongooseModule } from "@nestjs/mongoose";
describe("{{Entity}}s E2E", () => {
let app: INestApplication;
let mongod: MongoMemoryServer;
let authToken: string;
// Mock auth token for testing
const mockAuthToken = "Bearer test-token";
beforeAll(async () => {
// Start in-memory MongoDB
mongod = await MongoMemoryServer.create();
const mongoUri = mongod.getUri();
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider("MONGODB_URI")
.useValue(mongoUri)
.compile();
app = moduleFixture.createNestApplication();
// Apply same configuration as main.ts
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
await app.init();
});
afterAll(async () => {
await app.close();
await mongod.stop();
});
describe("POST /{{entities}}", () => {
it("should create a new {{entity}}", async () => {
const create{{Entity}}Dto = {
title: "Test {{Entity}}",
// Add other required fields
};
const response = await request(app.getHttpServer())
.post("/{{entities}}")
.set("Authorization", mockAuthToken)
.send(create{{Entity}}Dto)
.expect(201);
expect(response.body).toHaveProperty("_id");
expect(response.body.title).toBe(create{{Entity}}Dto.title);
});
it("should return 401 without auth token", async () => {
await request(app.getHttpServer())
.post("/{{entities}}")
.send({ title: "Test" })
.expect(401);
});
it("should return 400 for invalid data", async () => {
await request(app.getHttpServer())
.post("/{{entities}}")
.set("Authorization", mockAuthToken)
.send({}) // Missing required fields
.expect(400);
});
});
describe("GET /{{entities}}", () => {
it("should return all {{entities}} for user", async () => {
const response = await request(app.getHttpServer())
.get("/{{entities}}")
.set("Authorization", mockAuthToken)
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
it("should return 401 without auth token", async () => {
await request(app.getHttpServer())
.get("/{{entities}}")
.expect(401);
});
});
describe("GET /{{entities}}/:id", () => {
let created{{Entity}}Id: string;
beforeEach(async () => {
// Create a {{entity}} first
const response = await request(app.getHttpServer())
.post("/{{entities}}")
.set("Authorization", mockAuthToken)
.send({ title: "Test {{Entity}}" });
created{{Entity}}Id = response.body._id;
});
it("should return a {{entity}} by id", async () => {
const response = await request(app.getHttpServer())
.get(`/{{entities}}/${created{{Entity}}Id}`)
.set("Authorization", mockAuthToken)
.expect(200);
expect(response.body._id).toBe(created{{Entity}}Id);
});
it("should return 404 for non-existent {{entity}}", async () => {
await request(app.getHttpServer())
.get("/{{entities}}/nonexistent-id")
.set("Authorization", mockAuthToken)
.expect(404);
});
});
describe("PATCH /{{entities}}/:id", () => {
let created{{Entity}}Id: string;
beforeEach(async () => {
const response = await request(app.getHttpServer())
.post("/{{entities}}")
.set("Authorization", mockAuthToken)
.send({ title: "Test {{Entity}}" });
created{{Entity}}Id = response.body._id;
});
it("should update a {{entity}}", async () => {
const updateDto = { title: "Updated {{Entity}}" };
const response = await request(app.getHttpServer())
.patch(`/{{entities}}/${created{{Entity}}Id}`)
.set("Authorization", mockAuthToken)
.send(updateDto)
.expect(200);
expect(response.body.title).toBe(updateDto.title);
});
it("should return 404 for non-existent {{entity}}", async () => {
await request(app.getHttpServer())
.patch("/{{entities}}/nonexistent-id")
.set("Authorization", mockAuthToken)
.send({ title: "Updated" })
.expect(404);
});
});
describe("DELETE /{{entities}}/:id", () => {
let created{{Entity}}Id: string;
beforeEach(async () => {
const response = await request(app.getHttpServer())
.post("/{{entities}}")
.set("Authorization", mockAuthToken)
.send({ title: "Test {{Entity}}" });
created{{Entity}}Id = response.body._id;
});
it("should delete a {{entity}}", async () => {
await request(app.getHttpServer())
.delete(`/{{entities}}/${created{{Entity}}Id}`)
.set("Authorization", mockAuthToken)
.expect(200);
// Verify deletion
await request(app.getHttpServer())
.get(`/{{entities}}/${created{{Entity}}Id}`)
.set("Authorization", mockAuthToken)
.expect(404);
});
it("should return 404 for non-existent {{entity}}", async () => {
await request(app.getHttpServer())
.delete("/{{entities}}/nonexistent-id")
.set("Authorization", mockAuthToken)
.expect(404);
});
});
});
/**
* React Hook Test Template (Vitest + Testing Library)
*
* Replace {{Entity}} with PascalCase entity name (e.g., Task)
* Replace {{entity}} with camelCase entity name (e.g., task)
* Replace {{entities}} with plural camelCase (e.g., tasks)
*
* Requires: npm install -D @testing-library/react-hooks
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { use{{Entity}}s } from "./use-{{entities}}";
import { {{Entity}}Service } from "@services/{{entity}}.service";
// Mock the service
vi.mock("@services/{{entity}}.service", () => ({
{{Entity}}Service: {
getAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}));
// Mock Clerk
vi.mock("@clerk/nextjs", () => ({
useAuth: () => ({
getToken: vi.fn().mockResolvedValue("test-token"),
}),
}));
describe("use{{Entity}}s", () => {
const mock{{Entity}}s = [
{ _id: "1", title: "{{Entity}} 1", userId: "user-123" },
{ _id: "2", title: "{{Entity}} 2", userId: "user-123" },
];
beforeEach(() => {
vi.clearAllMocks();
({{Entity}}Service.getAll as any).mockResolvedValue(mock{{Entity}}s);
});
it("should fetch {{entities}} on mount", async () => {
const { result } = renderHook(() => use{{Entity}}s());
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.{{entities}}).toEqual(mock{{Entity}}s);
expect(result.current.error).toBeNull();
});
it("should handle fetch error", async () => {
const errorMessage = "Failed to fetch";
({{Entity}}Service.getAll as any).mockRejectedValue(new Error(errorMessage));
const { result } = renderHook(() => use{{Entity}}s());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe(errorMessage);
expect(result.current.{{entities}}).toEqual([]);
});
it("should create a new {{entity}}", async () => {
const new{{Entity}} = { _id: "3", title: "New {{Entity}}", userId: "user-123" };
({{Entity}}Service.create as any).mockResolvedValue(new{{Entity}});
const { result } = renderHook(() => use{{Entity}}s());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.create{{ Entity }}({ title: "New {{Entity}}" });
});
expect({{Entity}}Service.create).toHaveBeenCalledWith(
{ title: "New {{Entity}}" },
expect.any(Object)
);
});
it("should update a {{entity}}", async () => {
const updated{{Entity}} = { ...mock{{Entity}}s[0], title: "Updated" };
({{Entity}}Service.update as any).mockResolvedValue(updated{{Entity}});
const { result } = renderHook(() => use{{Entity}}s());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.update{{Entity}}("1", { title: "Updated" });
});
expect({{Entity}}Service.update).toHaveBeenCalledWith(
"1",
{ title: "Updated" },
expect.any(Object)
);
});
it("should delete a {{entity}}", async () => {
({{Entity}}Service.delete as any).mockResolvedValue(undefined);
const { result } = renderHook(() => use{{Entity}}s());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.delete{{Entity}}("1");
});
expect({{Entity}}Service.delete).toHaveBeenCalledWith("1", expect.any(Object));
});
it("should refetch {{entities}}", async () => {
const { result } = renderHook(() => use{{Entity}}s());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
({{Entity}}Service.getAll as any).mockClear();
await act(async () => {
await result.current.refetch();
});
expect({{Entity}}Service.getAll).toHaveBeenCalledTimes(1);
});
});
/**
* NestJS Module Template
*
* Replace {{Entity}} with PascalCase entity name (e.g., Task)
* Replace {{entity}} with camelCase entity name (e.g., task)
* Replace {{entities}} with plural camelCase (e.g., tasks)
*/
import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose";
import { {{Entity}}sController } from "./{{entities}}.controller";
import { {{Entity}}sService } from "./{{entities}}.service";
import { {{Entity}}, {{Entity}}Schema } from "./schemas/{{entity}}.schema";
@Module({
imports: [
MongooseModule.forFeature([
{ name: {{Entity}}.name, schema: {{Entity}}Schema },
]),
],
controllers: [{{Entity}}sController],
providers: [{{Entity}}sService],
exports: [{{Entity}}sService],
})
export class {{Entity}}sModule {}
/**
* Next.js proxy.ts Template for Clerk Auth (Next.js 16)
*
* Place this at: frontend/apps/dashboard/proxy.ts
*/
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
// Define public routes that don't require authentication
const isPublicRoute = createRouteMatcher([
"/",
"/sign-in(.*)",
"/sign-up(.*)",
"/api/public(.*)",
]);
export default clerkMiddleware(async (auth, request) => {
// Protect all routes except public ones
if (!isPublicRoute(request)) {
await auth.protect();
}
});
export const config = {
matcher: [
// Skip Next.js internals and static files
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
// Always run for API routes
"/(api|trpc)(.*)",
],
};
/**
* Mongoose Schema Template
*
* Replace {{Entity}} with PascalCase entity name (e.g., Task)
* Replace {{FIELDS}} with actual field definitions
*/
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Document } from "mongoose";
export type {{Entity}}Document = {{Entity}} & Document;
@Schema({ timestamps: true })
export class {{Entity}} {
/**
* FIELD EXAMPLES - Replace with actual fields:
*
* Required string:
* @Prop({ required: true })
* title: string;
*
* Optional string:
* @Prop()
* description?: string;
*
* Enum:
* @Prop({ required: true, default: 'medium', enum: ['low', 'medium', 'high'] })
* priority: string;
*
* Date:
* @Prop()
* dueDate?: Date;
*
* Boolean:
* @Prop({ default: false })
* isCompleted: boolean;
*
* Array of strings:
* @Prop({ type: [String], default: [] })
* tags?: string[];
*
* Reference to another collection:
* @Prop()
* projectId?: string;
*/
// {{FIELDS}}
// Always include userId for multi-tenancy
@Prop({ required: true })
userId: string;
}
export const {{Entity}}Schema = SchemaFactory.createForClass({{Entity}});
// Add indexes for common queries
{{Entity}}Schema.index({ userId: 1 });
{{Entity}}Schema.index({ userId: 1, createdAt: -1 });
/**
* Frontend API Service Template
*
* Replace {{Entity}} with PascalCase entity name (e.g., Task)
* Replace {{entity}} with camelCase entity name (e.g., task)
* Replace {{entities}} with plural camelCase (e.g., tasks)
*/
import { {{Entity}} } from "@interfaces/{{entity}}.interface";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
interface RequestOptions {
signal?: AbortSignal;
}
async function getAuthHeaders(): Promise<HeadersInit> {
// Get token from Clerk
const token = await window.Clerk?.session?.getToken();
return {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({ message: "Request failed" }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
export const {{Entity}}Service = {
async getAll(options?: RequestOptions): Promise<{{Entity}}[]> {
const headers = await getAuthHeaders();
const response = await fetch(`${API_URL}/{{entities}}`, {
headers,
signal: options?.signal,
});
return handleResponse<{{Entity}}[]>(response);
},
async getById(id: string, options?: RequestOptions): Promise<{{Entity}}> {
const headers = await getAuthHeaders();
const response = await fetch(`${API_URL}/{{entities}}/${id}`, {
headers,
signal: options?.signal,
});
return handleResponse<{{Entity}}>(response);
},
async create(data: Partial<{{Entity}}>): Promise<{{Entity}}> {
const headers = await getAuthHeaders();
const response = await fetch(`${API_URL}/{{entities}}`, {
method: "POST",
headers,
body: JSON.stringify(data),
});
return handleResponse<{{Entity}}>(response);
},
async update(id: string, data: Partial<{{Entity}}>): Promise<{{Entity}}> {
const headers = await getAuthHeaders();
const response = await fetch(`${API_URL}/{{entities}}/${id}`, {
method: "PATCH",
headers,
body: JSON.stringify(data),
});
return handleResponse<{{Entity}}>(response);
},
async delete(id: string): Promise<void> {
const headers = await getAuthHeaders();
const response = await fetch(`${API_URL}/{{entities}}/${id}`, {
method: "DELETE",
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: "Delete failed" }));
throw new Error(error.message);
}
},
};
/**
* NestJS Service Test Template (Vitest)
*
* Replace {{Entity}} with PascalCase entity name (e.g., Task)
* Replace {{entity}} with camelCase entity name (e.g., task)
* Replace {{entities}} with plural camelCase (e.g., tasks)
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { getModelToken } from "@nestjs/mongoose";
import { NotFoundException } from "@nestjs/common";
import { {{Entity}}sService } from "./{{entities}}.service";
import { {{Entity}} } from "./schemas/{{entity}}.schema";
describe("{{Entity}}sService", () => {
let service: {{Entity}}sService;
let mockModel: any;
const mockUserId = "user-123";
const mock{{Entity}} = {
_id: "{{entity}}-123",
title: "Test {{Entity}}",
userId: mockUserId,
createdAt: new Date(),
save: vi.fn().mockResolvedValue(this),
};
beforeEach(async () => {
mockModel = {
new: vi.fn().mockResolvedValue(mock{{Entity}}),
constructor: vi.fn().mockResolvedValue(mock{{Entity}}),
find: vi.fn(),
findOne: vi.fn(),
findOneAndUpdate: vi.fn(),
deleteOne: vi.fn(),
};
// Mock the constructor behavior
mockModel.mockImplementation = vi.fn().mockReturnValue(mock{{Entity}});
const module: TestingModule = await Test.createTestingModule({
providers: [
{{Entity}}sService,
{
provide: getModelToken({{Entity}}.name),
useValue: {
...mockModel,
new: vi.fn().mockImplementation((data) => ({
...data,
save: vi.fn().mockResolvedValue({ ...data, _id: "new-id" }),
})),
},
},
],
}).compile();
service = module.get<{{Entity}}sService>({{Entity}}sService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("findAll", () => {
it("should return all {{entities}} for a user", async () => {
const mock{{Entity}}s = [mock{{Entity}}];
mockModel.find.mockReturnValue({
sort: vi.fn().mockReturnValue({
exec: vi.fn().mockResolvedValue(mock{{Entity}}s),
}),
});
const result = await service.findAll(mockUserId);
expect(result).toEqual(mock{{Entity}}s);
expect(mockModel.find).toHaveBeenCalledWith({ userId: mockUserId });
});
});
describe("findOne", () => {
it("should return a {{entity}} by id", async () => {
mockModel.findOne.mockReturnValue({
exec: vi.fn().mockResolvedValue(mock{{Entity}}),
});
const result = await service.findOne("{{entity}}-123", mockUserId);
expect(result).toEqual(mock{{Entity}});
expect(mockModel.findOne).toHaveBeenCalledWith({
_id: "{{entity}}-123",
userId: mockUserId,
});
});
it("should throw NotFoundException if {{entity}} not found", async () => {
mockModel.findOne.mockReturnValue({
exec: vi.fn().mockResolvedValue(null),
});
await expect(
service.findOne("nonexistent", mockUserId),
).rejects.toThrow(NotFoundException);
});
});
describe("update", () => {
it("should update a {{entity}}", async () => {
const updated{{Entity}} = { ...mock{{Entity}}, title: "Updated" };
mockModel.findOneAndUpdate.mockReturnValue({
exec: vi.fn().mockResolvedValue(updated{{Entity}}),
});
const result = await service.update(
"{{entity}}-123",
{ title: "Updated" },
mockUserId,
);
expect(result.title).toBe("Updated");
});
it("should throw NotFoundException if {{entity}} not found", async () => {
mockModel.findOneAndUpdate.mockReturnValue({
exec: vi.fn().mockResolvedValue(null),
});
await expect(
service.update("nonexistent", { title: "Test" }, mockUserId),
).rejects.toThrow(NotFoundException);
});
});
describe("remove", () => {
it("should delete a {{entity}}", async () => {
mockModel.deleteOne.mockReturnValue({
exec: vi.fn().mockResolvedValue({ deletedCount: 1 }),
});
await expect(
service.remove("{{entity}}-123", mockUserId),
).resolves.not.toThrow();
});
it("should throw NotFoundException if {{entity}} not found", async () => {
mockModel.deleteOne.mockReturnValue({
exec: vi.fn().mockResolvedValue({ deletedCount: 0 }),
});
await expect(
service.remove("nonexistent", mockUserId),
).rejects.toThrow(NotFoundException);
});
});
});
/**
* NestJS Service Template
*
* Replace {{Entity}} with PascalCase entity name (e.g., Task)
* Replace {{entity}} with camelCase entity name (e.g., task)
* Replace {{entities}} with plural camelCase (e.g., tasks)
*/
import { Injectable, NotFoundException } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { Model } from "mongoose";
import { {{Entity}}, {{Entity}}Document } from "./schemas/{{entity}}.schema";
import { Create{{Entity}}Dto } from "./dto/create-{{entity}}.dto";
import { Update{{Entity}}Dto } from "./dto/update-{{entity}}.dto";
@Injectable()
export class {{Entity}}sService {
constructor(
@InjectModel({{Entity}}.name) private {{entity}}Model: Model<{{Entity}}Document>,
) {}
async create(create{{Entity}}Dto: Create{{Entity}}Dto, userId: string): Promise<{{Entity}}> {
const {{entity}} = new this.{{entity}}Model({
...create{{Entity}}Dto,
userId,
});
return {{entity}}.save();
}
async findAll(userId: string): Promise<{{Entity}}[]> {
const query = this.{{entity}}Model.find({ userId }).sort({ createdAt: -1 });
return query.then((docs) => docs);
}
async findOne(id: string, userId: string): Promise<{{Entity}}> {
const query = this.{{entity}}Model.findOne({ _id: id, userId });
const {{entity}} = await query.then((doc) => doc);
if (!{{entity}}) {
throw new NotFoundException(`{{Entity}} with ID ${id} not found`);
}
return {{entity}};
}
async update(
id: string,
update{{Entity}}Dto: Update{{Entity}}Dto,
userId: string,
): Promise<{{Entity}}> {
const query = this.{{entity}}Model.findOneAndUpdate(
{ _id: id, userId },
update{{Entity}}Dto,
{ new: true },
);
const {{entity}} = await query.then((doc) => doc);
if (!{{entity}}) {
throw new NotFoundException(`{{Entity}} with ID ${id} not found`);
}
return {{entity}};
}
async remove(id: string, userId: string): Promise<void> {
const query = this.{{entity}}Model.deleteOne({ _id: id, userId });
const result = await query.then((res) => res);
if (result.deletedCount === 0) {
throw new NotFoundException(`{{Entity}} with ID ${id} not found`);
}
}
}
/**
* Test Setup Template for Frontend (React Testing Library)
*
* Place this at src/test/setup.ts or test/setup.ts
*/
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
// Cleanup after each test
afterEach(() => {
cleanup();
});
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
}),
useSearchParams: () => ({
get: vi.fn(),
}),
usePathname: () => "/",
}));
// Mock next/image
vi.mock("next/image", () => ({
default: ({ src, alt, ...props }: any) => {
// eslint-disable-next-line @next/next/no-img-element
return <img src={src} alt={alt} {...props} />;
},
}));
// Mock Clerk
vi.mock("@clerk/nextjs", () => ({
useAuth: () => ({
isLoaded: true,
isSignedIn: true,
userId: "test-user-id",
getToken: vi.fn().mockResolvedValue("test-token"),
}),
useUser: () => ({
isLoaded: true,
isSignedIn: true,
user: {
id: "test-user-id",
firstName: "Test",
lastName: "User",
emailAddresses: [{ emailAddress: "test@example.com" }],
},
}),
SignIn: () => <div data-testid="clerk-sign-in">Sign In</div>,
SignUp: () => <div data-testid="clerk-sign-up">Sign Up</div>,
SignedIn: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SignedOut: ({ children }: { children: React.ReactNode }) => <>{children}</>,
UserButton: () => <button data-testid="clerk-user-button">User</button>,
ClerkProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
// Mock window.matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Suppress console errors in tests (optional)
const originalError = console.error;
console.error = (...args: any[]) => {
if (
typeof args[0] === "string" &&
args[0].includes("Warning: ReactDOM.render is no longer supported")
) {
return;
}
originalError.call(console, ...args);
};
/**
* Vitest Configuration Template for Frontend (React/Next.js)
*
* Copy this to your frontend project and adjust paths as needed.
* This configuration enforces 80% coverage thresholds.
*/
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
// Use global test APIs (describe, it, expect)
globals: true,
// Environment for React tests
environment: "jsdom",
// Setup files for React Testing Library
setupFiles: ["./src/test/setup.ts"],
// Test file patterns
include: [
"src/**/*.{test,spec}.{ts,tsx}",
"components/**/*.{test,spec}.{ts,tsx}",
"hooks/**/*.{test,spec}.{ts,tsx}",
],
// Exclude patterns
exclude: ["node_modules", "dist", ".next", "build", "e2e"],
// Coverage configuration
coverage: {
// Use V8 provider
provider: "v8",
// Output formats
reporter: ["text", "json", "html", "lcov"],
// Files to include in coverage
include: [
"src/**/*.{ts,tsx}",
"components/**/*.{ts,tsx}",
"hooks/**/*.{ts,tsx}",
"lib/**/*.{ts,tsx}",
],
// Files to exclude from coverage
exclude: [
"**/*.test.{ts,tsx}",
"**/*.spec.{ts,tsx}",
"**/*.d.ts",
"**/test/**",
"**/__mocks__/**",
"**/types/**",
"**/*.stories.{ts,tsx}",
],
// Coverage thresholds - fail if below these
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
// Timeout for tests (ms)
testTimeout: 10000,
// CSS handling
css: true,
// Path aliases (match tsconfig/next.config)
alias: {
"@": path.resolve(__dirname, "./src"),
"@components": path.resolve(__dirname, "./components"),
"@hooks": path.resolve(__dirname, "./hooks"),
"@lib": path.resolve(__dirname, "./lib"),
"@services": path.resolve(__dirname, "./services"),
},
},
});
/**
* Vitest Configuration Template
*
* Copy this to your project and adjust paths as needed.
* This configuration enforces 80% coverage thresholds.
*/
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
// Use global test APIs (describe, it, expect)
globals: true,
// Environment for tests
environment: "node", // Use "jsdom" for frontend tests
// Test file patterns
include: ["src/**/*.{test,spec}.{ts,tsx}", "**/*.{test,spec}.{ts,tsx}"],
// Exclude patterns
exclude: ["node_modules", "dist", ".next", "build"],
// Coverage configuration
coverage: {
// Use V8 provider (faster, built into Node)
provider: "v8",
// Output formats
reporter: ["text", "json", "html", "lcov"],
// Files to include in coverage
include: ["src/**/*.ts", "src/**/*.tsx"],
// Files to exclude from coverage
exclude: [
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts",
"src/test/**",
"src/**/__mocks__/**",
],
// Coverage thresholds - fail if below these
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
// Setup files to run before tests
setupFiles: ["./src/test/setup.ts"],
// Reset mocks between tests
mockReset: true,
restoreMocks: true,
// Timeout for tests (ms)
testTimeout: 10000,
// Path aliases (match tsconfig)
alias: {
"@collections": path.resolve(__dirname, "./src/collections"),
"@services": path.resolve(__dirname, "./src/services"),
"@guards": path.resolve(__dirname, "./src/guards"),
"@helpers": path.resolve(__dirname, "./src/helpers"),
},
},
});
#!/usr/bin/env python3
"""
Add a new collection to the NestJS API.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from textwrap import dedent
def to_pascal_case(s: str) -> str:
return "".join(word.capitalize() for word in s.replace("-", "_").split("_"))
def to_camel_case(s: str) -> str:
pascal = to_pascal_case(s)
return pascal[0].lower() + pascal[1:]
def create_module_ts(name: str) -> str:
pascal = to_pascal_case(name)
return dedent(f"""\
import {{ Module }} from "@nestjs/common";
import {{ MongooseModule }} from "@nestjs/mongoose";
import {{ {pascal}, {pascal}Schema }} from "./schemas/{name}.schema";
import {{ {pascal}Controller }} from "./controllers/{name}.controller";
import {{ {pascal}Service }} from "./services/{name}.service";
@Module({{
imports: [
MongooseModule.forFeatureAsync([
{{
name: {pascal}.name,
useFactory: () => {{
const schema = {pascal}Schema;
// Add compound indexes here
// schema.index({{ organization: 1, isDeleted: 1 }});
return schema;
}},
}},
]),
],
controllers: [{pascal}Controller],
providers: [{pascal}Service],
exports: [{pascal}Service],
}})
export class {pascal}Module {{}}
""")
def create_schema_ts(name: str) -> str:
pascal = to_pascal_case(name)
return dedent(f"""\
import {{ Prop, Schema, SchemaFactory }} from "@nestjs/mongoose";
import {{ Document, Types }} from "mongoose";
@Schema({{ timestamps: true }})
export class {pascal} {{
@Prop({{ type: Types.ObjectId, ref: "Organization", required: true, index: true }})
organization: Types.ObjectId;
@Prop({{ required: true }})
name: string;
@Prop({{ default: false, index: true }})
isDeleted: boolean;
}}
export type {pascal}Document = {pascal} & Document;
export const {pascal}Schema = SchemaFactory.createForClass({pascal});
""")
def create_controller_ts(name: str) -> str:
pascal = to_pascal_case(name)
camel = to_camel_case(name)
return dedent(f"""\
import {{
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
}} from "@nestjs/common";
import {{ ApiTags, ApiOperation, ApiBearerAuth }} from "@nestjs/swagger";
import {{ {pascal}Service }} from "../services/{name}.service";
import {{ Create{pascal}Dto }} from "../dto/create-{name}.dto";
import {{ Update{pascal}Dto }} from "../dto/update-{name}.dto";
@ApiTags("{name}")
@ApiBearerAuth()
@Controller("{name}")
export class {pascal}Controller {{
constructor(private readonly {camel}Service: {pascal}Service) {{}}
@Post()
@ApiOperation({{ summary: "Create {name}" }})
create(@Body() dto: Create{pascal}Dto) {{
return this.{camel}Service.create(dto);
}}
@Get()
@ApiOperation({{ summary: "Get all {name}s" }})
findAll(@Query("organizationId") organizationId: string) {{
return this.{camel}Service.findAll(organizationId);
}}
@Get(":id")
@ApiOperation({{ summary: "Get {name} by ID" }})
findOne(
@Param("id") id: string,
@Query("organizationId") organizationId: string,
) {{
return this.{camel}Service.findOne(id, organizationId);
}}
@Patch(":id")
@ApiOperation({{ summary: "Update {name}" }})
update(
@Param("id") id: string,
@Query("organizationId") organizationId: string,
@Body() dto: Update{pascal}Dto,
) {{
return this.{camel}Service.update(id, organizationId, dto);
}}
@Delete(":id")
@ApiOperation({{ summary: "Soft delete {name}" }})
remove(
@Param("id") id: string,
@Query("organizationId") organizationId: string,
) {{
return this.{camel}Service.remove(id, organizationId);
}}
}}
""")
def create_service_ts(name: str) -> str:
pascal = to_pascal_case(name)
camel = to_camel_case(name)
return dedent(f"""\
import {{ Injectable, NotFoundException }} from "@nestjs/common";
import {{ InjectModel }} from "@nestjs/mongoose";
import {{ Model }} from "mongoose";
import {{ {pascal}, {pascal}Document }} from "../schemas/{name}.schema";
import {{ Create{pascal}Dto }} from "../dto/create-{name}.dto";
import {{ Update{pascal}Dto }} from "../dto/update-{name}.dto";
@Injectable()
export class {pascal}Service {{
constructor(
@InjectModel({pascal}.name)
private {camel}Model: Model<{pascal}Document>,
) {{}}
async create(dto: Create{pascal}Dto): Promise<{pascal}Document> {{
const created = new this.{camel}Model(dto);
return created.save();
}}
async findAll(organizationId: string): Promise<{pascal}Document[]> {{
return this.{camel}Model.find({{
organization: organizationId,
isDeleted: false,
}});
}}
async findOne(id: string, organizationId: string): Promise<{pascal}Document> {{
const doc = await this.{camel}Model.findOne({{
_id: id,
organization: organizationId,
isDeleted: false,
}});
if (!doc) {{
throw new NotFoundException("{pascal} not found");
}}
return doc;
}}
async update(
id: string,
organizationId: string,
dto: Update{pascal}Dto,
): Promise<{pascal}Document> {{
const doc = await this.findOne(id, organizationId);
Object.assign(doc, dto);
return doc.save();
}}
async remove(id: string, organizationId: string): Promise<{pascal}Document> {{
const doc = await this.findOne(id, organizationId);
doc.isDeleted = true;
return doc.save();
}}
}}
""")
def create_create_dto_ts(name: str) -> str:
pascal = to_pascal_case(name)
return dedent(f"""\
import {{ ApiProperty }} from "@nestjs/swagger";
import {{ IsString, IsNotEmpty, IsMongoId }} from "class-validator";
export class Create{pascal}Dto {{
@ApiProperty()
@IsMongoId()
@IsNotEmpty()
organization: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
name: string;
}}
""")
def create_update_dto_ts(name: str) -> str:
pascal = to_pascal_case(name)
return dedent(f"""\
import {{ PartialType }} from "@nestjs/swagger";
import {{ Create{pascal}Dto }} from "./create-{name}.dto";
export class Update{pascal}Dto extends PartialType(Create{pascal}Dto) {{}}
""")
def create_http_file(name: str) -> str:
return dedent(f"""\
@baseUrl = http://localhost:3001
@organizationId = YOUR_ORG_ID
### Get all {name}s
GET {{{{baseUrl}}}}/{name}?organizationId={{{{organizationId}}}}
### Get {name} by ID
GET {{{{baseUrl}}}}/{name}/ITEM_ID?organizationId={{{{organizationId}}}}
### Create {name}
POST {{{{baseUrl}}}}/{name}
Content-Type: application/json
{{
"organization": "{{{{organizationId}}}}",
"name": "Test {name}"
}}
### Update {name}
PATCH {{{{baseUrl}}}}/{name}/ITEM_ID?organizationId={{{{organizationId}}}}
Content-Type: application/json
{{
"name": "Updated name"
}}
### Delete {name}
DELETE {{{{baseUrl}}}}/{name}/ITEM_ID?organizationId={{{{organizationId}}}}
""")
def add_api_collection(root: Path, name: str) -> None:
"""Add a new collection to the API."""
collections_dir = root / "apps" / "api" / "src" / "collections"
if not collections_dir.exists():
print(f"Error: {collections_dir} does not exist. Is this an API project?")
sys.exit(1)
collection_dir = collections_dir / name
if collection_dir.exists():
print(f"Error: {collection_dir} already exists.")
sys.exit(1)
# Create directories
dirs = [
collection_dir / "controllers",
collection_dir / "services",
collection_dir / "schemas",
collection_dir / "dto",
]
for d in dirs:
d.mkdir(parents=True)
# Create files
files = {
collection_dir / f"{name}.module.ts": create_module_ts(name),
collection_dir / "schemas" / f"{name}.schema.ts": create_schema_ts(name),
collection_dir / "controllers" / f"{name}.controller.ts": create_controller_ts(name),
collection_dir / "services" / f"{name}.service.ts": create_service_ts(name),
collection_dir / "dto" / f"create-{name}.dto.ts": create_create_dto_ts(name),
collection_dir / "dto" / f"update-{name}.dto.ts": create_update_dto_ts(name),
collection_dir / f"{name}.http": create_http_file(name),
}
for filepath, content in files.items():
filepath.write_text(content)
print(f"Created: {filepath}")
pascal = to_pascal_case(name)
print(f"\n✅ Collection '{name}' created at: {collection_dir}")
print(f"\nDon't forget to:")
print(f"1. Import {pascal}Module in app.module.ts")
print(f"2. Add compound indexes in the module if needed")
print(f"3. Create serializer in packages/common/serializers/")
def main() -> None:
parser = argparse.ArgumentParser(
description="Add a new collection to the NestJS API."
)
parser.add_argument(
"--root",
type=Path,
required=True,
help="Path to the API project root",
)
parser.add_argument(
"--name",
type=str,
required=True,
help="Collection name (e.g., 'users', 'posts')",
)
args = parser.parse_args()
add_api_collection(
root=args.root.resolve(),
name=args.name.lower().replace(" ", "-"),
)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Add a new NextJS app to the frontend project.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from textwrap import dedent
def create_layout_tsx(name: str) -> str:
title = name.replace("-", " ").title()
return dedent(f"""\
import type {{ Metadata }} from "next";
import "../../../apps/dashboard/app/globals.css";
export const metadata: Metadata = {{
title: "{title}",
description: "{title} application",
}};
export default function RootLayout({{
children,
}}: Readonly<{{
children: React.ReactNode;
}}>) {{
return (
<html lang="en" data-theme="dark">
<body>{{children}}</body>
</html>
);
}}
""")
def create_page_tsx(name: str) -> str:
title = name.replace("-", " ").title()
return dedent(f"""\
export default function Home() {{
return (
<main className="min-h-screen p-8">
<h1 className="text-4xl font-bold">{title}</h1>
<p className="mt-4 text-gray-500">Welcome to {title}.</p>
</main>
);
}}
""")
def add_frontend_app(root: Path, name: str) -> None:
"""Add a new app to the frontend project."""
apps_dir = root / "apps"
if not apps_dir.exists():
print(f"Error: {apps_dir} does not exist. Is this a frontend project?")
sys.exit(1)
app_dir = apps_dir / name / "app"
if app_dir.exists():
print(f"Error: {app_dir} already exists.")
sys.exit(1)
app_dir.mkdir(parents=True)
files = {
app_dir / "layout.tsx": create_layout_tsx(name),
app_dir / "page.tsx": create_page_tsx(name),
}
for filepath, content in files.items():
filepath.write_text(content)
print(f"Created: {filepath}")
print(f"\n✅ Frontend app '{name}' created at: {apps_dir / name}")
print(f"\nTo run: bun run dev --filter {name}")
def main() -> None:
parser = argparse.ArgumentParser(
description="Add a new NextJS app to the frontend project."
)
parser.add_argument(
"--root",
type=Path,
required=True,
help="Path to the frontend project root",
)
parser.add_argument(
"--name",
type=str,
required=True,
help="App name (e.g., 'admin', 'settings')",
)
args = parser.parse_args()
add_frontend_app(
root=args.root.resolve(),
name=args.name.lower().replace(" ", "-"),
)
if __name__ == "__main__":
main()