Contributing Guide¶
Welcome to the MMR SaaS Platform! This guide will help you understand our development workflow, coding standards, and best practices.
Table of Contents¶
- Getting Started
- Development Workflow
- Using the AI Agent System
- Coding Standards
- Multi-Tenancy Guidelines
- Testing
- Pull Request Process
- Common Development Tasks
Getting Started¶
1. Setup Your Environment¶
Follow the LOCAL_SETUP.md guide to install prerequisites and configure your development environment.
Quick setup:
# Clone repository
git clone <repository-url>
cd MMR_SAAS_LAB
# Install dependencies
pnpm install
# Setup environment files
cp apps/backend/.env.example apps/backend/.env
cp apps/web/.env.example apps/web/.env.local
# Start Docker services (MySQL + Redis)
pnpm docker:up
# Run database migrations
pnpm db:migrate
# Start development servers
pnpm dev
2. Understand the Project Structure¶
MMR_SAAS_LAB/
├── apps/
│ ├── backend/ # NestJS API (Port 3001)
│ └── web/ # Next.js Frontend (Port 3000)
├── packages/
│ ├── types/ # Shared TypeScript types
│ ├── schemas/ # Zod validation schemas
│ ├── api-client/ # Shared API client
│ └── ui/ # Shared UI components
├── .claude/agents/ # AI Agent System (16 agents)
└── docs/ # Documentation
3. Read Key Documentation¶
- ARCHITECTURE.md - System architecture and design patterns
- GIT_WORKFLOW.md - Git branching strategy
- .claude/agents/README.md - AI agent system
Development Workflow¶
Daily Development Process¶
-
Pull latest changes:
-
Create feature branch:
-
Make changes and test:
-
Commit changes:
-
Push and create PR:
Running Development Servers¶
# Both frontend and backend
pnpm dev
# Frontend only (port 3000)
pnpm dev:web
# Backend only (port 3001)
pnpm dev:back
Watching for Changes¶
Both servers support hot reload: - Backend: NestJS watches for TypeScript changes - Frontend: Next.js Fast Refresh updates React components instantly
Building for Production¶
# Build all apps
pnpm build
# Build specific app
pnpm build:web # Frontend
pnpm build:back # Backend
Using the AI Agent System¶
The platform includes 16 specialized AI agents that automate code generation for common development tasks.
When to Use Agents¶
Use Frontend Agents (10) When: - Creating new UI components or pages - Building forms with validation - Adding new React hooks - Implementing API client services - Working with TanStack Query
Use Backend Agents (6) When: - Creating new database entities - Building REST API endpoints - Implementing business logic services - Writing DTOs and validation - Creating unit and E2E tests
Frontend Agent Workflow (6 Steps)¶
Activation Command:
Example:
Output: 12+ production-ready files: 1. Types - TypeScript interfaces (packages/types/src/) 2. Schemas - Zod validation schemas (packages/schemas/src/) 3. Services - API client services (apps/web/src/services/) 4. Hooks - TanStack Query hooks (apps/web/src/hooks/) 5. Components - React components (apps/web/src/components/) 6. Pages - Next.js pages (apps/web/src/app/)
Detailed Workflow:
Step 1: @type-architect → TypeScript types
Step 2: @schema-engineer → Zod validation schemas
Step 3: @service-builder → API services
Step 4: @hook-factory → TanStack Query hooks
Step 5: @component-builder → React components
Step 6: @page-generator → Next.js pages
Backend Agent Workflow (5 Steps)¶
Activation Command:
Example:
Output: 8+ production-ready files: 1. Entity - TypeORM entity (apps/backend/src/modules/products/) 2. DTOs - Data transfer objects with validation 3. Repository - TenantScopedRepository (multi-tenant safe) 4. Service - Business logic implementation 5. Controller - REST endpoints with Swagger docs 6. Module - NestJS module configuration 7. Tests - Unit and E2E tests
Detailed Workflow:
Step 1: @entity-builder → TypeORM entity
Step 2: @dto-builder → DTOs with validation
Step 3: @service-builder → Business logic service
Step 4: @controller-builder → REST API controller
Step 5: @test-builder → Unit & E2E tests
Agent Best Practices¶
- Use descriptive module names:
products,invoices,customers - Review generated code: Always review and adjust as needed
- Run tests after generation: Ensure generated code passes tests
- Follow up with manual adjustments: Customize business logic
- Commit agent output: Treat generated code as a starting point
Learn More: .claude/agents/README.md
Coding Standards¶
TypeScript Conventions¶
Use Strict Mode:
// tsconfig.json already enforces strict mode
// Always use explicit types, avoid 'any'
// ❌ Bad
function process(data: any) {
return data.value;
}
// ✅ Good
function process(data: { value: number }): number {
return data.value;
}
Interface vs Type: - Prefer interface for object shapes - Use type for unions, intersections, utility types
// ✅ Good
interface User {
id: number;
email: string;
}
type UserRole = 'admin' | 'staff' | 'client';
type UserWithRole = User & { role: UserRole };
Null Handling:
// Use optional chaining and nullish coalescing
const value = user?.profile?.name ?? 'Unknown';
// Avoid non-null assertions unless absolutely certain
// ❌ Bad: user!.profile!.name
// ✅ Good: Use optional chaining or type guards
NestJS Patterns¶
Module Structure¶
Each module should have:
modules/products/
├── products.module.ts # Module definition
├── products.controller.ts # REST endpoints
├── products.service.ts # Business logic
├── products.repository.ts # Database queries (extends TenantScopedRepository)
├── product.entity.ts # TypeORM entity (extends BaseEntity)
├── dto/
│ ├── create-product.dto.ts
│ ├── update-product.dto.ts
│ └── product-query.dto.ts
└── __tests__/
├── products.service.spec.ts
└── products.e2e-spec.ts
Controller Pattern¶
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
import { Auth } from '../auth/decorators/auth.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '../users/user.entity';
@Controller('products')
@Auth() // Require authentication for all routes
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
async findAll(@Query() query: ProductQueryDto) {
return this.productsService.findAll(query);
}
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
return this.productsService.findOne(id);
}
@Post()
@Auth('admin', 'manager') // Specific roles
async create(
@Body() createDto: CreateProductDto,
@CurrentUser() user: User,
) {
return this.productsService.create(createDto);
}
@Put(':id')
@Auth('admin', 'manager')
async update(
@Param('id', ParseIntPipe) id: number,
@Body() updateDto: UpdateProductDto,
) {
return this.productsService.update(id, updateDto);
}
@Delete(':id')
@Auth('admin') // Admin only
async remove(@Param('id', ParseIntPipe) id: number) {
return this.productsService.remove(id);
}
}
Service Pattern¶
import { Injectable, NotFoundException } from '@nestjs/common';
import { ProductsRepository } from './products.repository';
import { CreateProductDto, UpdateProductDto, ProductQueryDto } from './dto';
import { Product } from './product.entity';
@Injectable()
export class ProductsService {
constructor(
private readonly productsRepository: ProductsRepository,
) {}
async findAll(query: ProductQueryDto): Promise<Product[]> {
// TenantScopedRepository automatically adds WHERE organizationId = :organizationId
return this.productsRepository.find({
where: {
...(query.search && { name: Like(`%${query.search}%`) }),
},
skip: (query.page - 1) * query.limit,
take: query.limit,
});
}
async findOne(id: number): Promise<Product> {
const product = await this.productsRepository.findOne({
where: { id },
});
if (!product) {
throw new NotFoundException(`Product with ID ${id} not found`);
}
return product;
}
async create(createDto: CreateProductDto): Promise<Product> {
const product = this.productsRepository.create(createDto);
// organizationId is automatically set by TenantContext
return this.productsRepository.save(product);
}
async update(id: number, updateDto: UpdateProductDto): Promise<Product> {
const product = await this.findOne(id);
Object.assign(product, updateDto);
return this.productsRepository.save(product);
}
async remove(id: number): Promise<void> {
const product = await this.findOne(id);
await this.productsRepository.remove(product);
}
}
Next.js Patterns¶
App Router Structure¶
app/
├── layout.tsx # Root layout
├── page.tsx # Home page (/)
├── (auth)/ # Route group (no path segment)
│ ├── login/
│ │ └── page.tsx # /login
│ └── signup/
│ └── page.tsx # /signup
├── dashboard/
│ ├── layout.tsx # Dashboard layout
│ ├── page.tsx # /dashboard
│ └── products/
│ ├── page.tsx # /dashboard/products (list)
│ ├── create/
│ │ └── page.tsx # /dashboard/products/create
│ └── [id]/
│ ├── page.tsx # /dashboard/products/[id] (view)
│ └── edit/
│ └── page.tsx # /dashboard/products/[id]/edit
└── api/ # API routes
└── auth/
└── [...nextauth]/
└── route.ts
Server vs Client Components¶
// Server Component (default, RSC)
// ✅ Use for: Data fetching, accessing backend directly, SEO
export default async function ProductsPage() {
const products = await fetch('http://localhost:3001/api/products').then(r => r.json());
return (
<div>
<h1>Products</h1>
<ProductList products={products} />
</div>
);
}
// Client Component
// ✅ Use for: Interactivity, state, effects, browser APIs
'use client';
import { useState } from 'react';
export function ProductList({ products }) {
const [filter, setFilter] = useState('');
// ... interactive logic
}
Data Fetching with TanStack Query¶
// hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { productsApi } from '@/services/products.service';
export function useProducts(query?: ProductQueryParams) {
return useQuery({
queryKey: ['products', query],
queryFn: () => productsApi.getAll(query),
});
}
export function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateProductDto) => productsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
// Component usage
'use client';
export function ProductsPage() {
const { data: products, isLoading } = useProducts();
const createProduct = useCreateProduct();
if (isLoading) return <Spinner />;
return <ProductList products={products} />;
}
File Naming Conventions¶
- Backend:
- Entities:
product.entity.ts - Controllers:
products.controller.ts - Services:
products.service.ts - DTOs:
create-product.dto.ts -
Tests:
products.service.spec.ts,products.e2e-spec.ts -
Frontend:
- Components:
ProductList.tsx(PascalCase) - Pages:
page.tsx(Next.js convention) - Hooks:
useProducts.ts - Services:
products.service.ts - Types:
product.types.ts
Import Organization¶
Organize imports in this order:
// 1. External dependencies
import { Controller, Get, Post } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
// 2. Internal modules (absolute imports)
import { Auth } from '@/modules/auth/decorators/auth.decorator';
import { User } from '@/modules/users/user.entity';
// 3. Shared packages
import { CreateProductDto } from '@mmr/types';
import { productSchema } from '@mmr/schemas';
// 4. Relative imports
import { ProductsService } from './products.service';
import { Product } from './product.entity';
Multi-Tenancy Guidelines¶
Critical: All database operations must be tenant-aware to prevent data leaks.
Always Use TenantScopedRepository¶
// ✅ CORRECT: Extends TenantScopedRepository
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Product } from './product.entity';
import { TenantScopedRepository } from '../../common/repositories/tenant-scoped.repository';
@Injectable()
export class ProductsRepository extends TenantScopedRepository<Product> {
constructor(
@InjectRepository(Product)
private readonly productRepository: Repository<Product>,
) {
super(
productRepository.target,
productRepository.manager,
productRepository.queryRunner,
);
}
}
// All queries automatically filtered by organizationId:
// - find(), findOne(), findBy()
// - count(), findAndCount()
// - createTenantQueryBuilder()
// ❌ WRONG: Direct Repository usage (data leak risk!)
@Injectable()
export class ProductsService {
constructor(
@InjectRepository(Product)
private productRepository: Repository<Product>, // NO!
) {}
async findAll() {
// This returns products from ALL organizations!
return this.productRepository.find(); // DANGEROUS!
}
}
Tenant Context Requirements¶
Every request must have a tenant context set:
- Subdomain Middleware extracts organization from subdomain
- JWT Auth provides organizationId from user token
- TenantContextInterceptor sets context for the request
// TenantContext is automatically available in services
import { TenantContext } from '@/common/utils/tenant-context';
async someOperation() {
const organizationId = TenantContext.getCurrentTenantId();
const userId = TenantContext.getCurrentUserId();
// Use for manual queries or logging
console.log(`User ${userId} from org ${organizationId}`);
}
Security Considerations¶
Do: - ✅ Always extend TenantScopedRepository for custom repositories - ✅ Use createTenantQueryBuilder() for complex queries - ✅ Test tenant isolation in E2E tests - ✅ Review all raw SQL queries for tenant filtering
Don't: - ❌ Use Repository directly from TypeORM - ❌ Use EntityManager.query() without manual tenant filter - ❌ Create cross-tenant queries unless explicitly admin-only - ❌ Trust client-provided organizationId (always use context)
Learn More: apps/backend/docs/TENANT_ISOLATION.md
Testing¶
Unit Tests¶
# Run all unit tests
pnpm test
# Run specific test file
pnpm test products.service.spec
# Watch mode
pnpm test:watch
# Coverage
pnpm test:cov
Unit Test Pattern (Backend)¶
// products.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ProductsService } from './products.service';
import { ProductsRepository } from './products.repository';
import { Product } from './product.entity';
describe('ProductsService', () => {
let service: ProductsService;
let repository: ProductsRepository;
const mockRepository = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
remove: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ProductsService,
{
provide: ProductsRepository,
useValue: mockRepository,
},
],
}).compile();
service = module.get<ProductsService>(ProductsService);
repository = module.get<ProductsRepository>(ProductsRepository);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return array of products', async () => {
const mockProducts = [{ id: 1, name: 'Product 1' }];
mockRepository.find.mockResolvedValue(mockProducts);
const result = await service.findAll({});
expect(result).toEqual(mockProducts);
expect(repository.find).toHaveBeenCalled();
});
});
describe('findOne', () => {
it('should return a product', async () => {
const mockProduct = { id: 1, name: 'Product 1' };
mockRepository.findOne.mockResolvedValue(mockProduct);
const result = await service.findOne(1);
expect(result).toEqual(mockProduct);
});
it('should throw NotFoundException when product not found', async () => {
mockRepository.findOne.mockResolvedValue(null);
await expect(service.findOne(999)).rejects.toThrow(NotFoundException);
});
});
});
E2E Tests¶
E2E Test Pattern (Backend)¶
// products.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('Products API (e2e)', () => {
let app: INestApplication;
let authToken: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
// Login to get auth token
const loginResponse = await request(app.getHttpServer())
.post('/api/auth/login')
.send({ email: 'admin@example.com', password: 'password' });
authToken = loginResponse.body.tokens.accessToken;
});
afterAll(async () => {
await app.close();
});
describe('GET /api/products', () => {
it('should return products array', () => {
return request(app.getHttpServer())
.get('/api/products')
.set('Authorization', `Bearer ${authToken}`)
.expect(200)
.expect((res) => {
expect(Array.isArray(res.body.data)).toBe(true);
});
});
it('should return 401 without auth token', () => {
return request(app.getHttpServer())
.get('/api/products')
.expect(401);
});
});
describe('POST /api/products', () => {
it('should create new product', () => {
return request(app.getHttpServer())
.post('/api/products')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'New Product',
price: 99.99,
sku: 'PROD-001',
})
.expect(201)
.expect((res) => {
expect(res.body.data).toHaveProperty('id');
expect(res.body.data.name).toBe('New Product');
});
});
});
});
Multi-Tenant Test Strategy¶
Always test tenant isolation:
describe('Tenant Isolation', () => {
it('should only return products from current organization', async () => {
// Set tenant context for Org 1
await TenantContext.run(
{ tenantId: '1', userId: '1', roles: [] },
async () => {
const products = await productsRepository.find();
// All products should belong to Org 1
expect(products.every(p => p.organizationId === 1)).toBe(true);
},
);
});
it('should prevent cross-tenant access', async () => {
// User from Org 1 trying to access Org 2's product
await TenantContext.run(
{ tenantId: '1', userId: '1', roles: [] },
async () => {
// Product 999 belongs to Org 2
const product = await productsRepository.findOne({ where: { id: 999 } });
// Should return null (not found in this org)
expect(product).toBeNull();
},
);
});
});
Pull Request Process¶
1. Create Feature Branch¶
2. Make Changes and Commit¶
Follow Conventional Commits:
feat: add product module with CRUD operations
fix: resolve authentication token expiry issue
docs: update API documentation for products
refactor: simplify tenant context logic
test: add E2E tests for product endpoints
chore: update dependencies to latest versions
3. Push to Remote¶
4. Open Pull Request¶
PR Title: Follow conventional commit format
PR Description Template:
## Summary
Brief description of changes
## Changes
- Added Product entity with multi-tenant support
- Implemented CRUD endpoints
- Added validation DTOs
- Created unit and E2E tests
## Testing
- [ ] Unit tests pass
- [ ] E2E tests pass
- [ ] Tested manually with Postman
- [ ] Verified tenant isolation
## Checklist
- [ ] Code follows project conventions
- [ ] All tests pass
- [ ] No linting errors
- [ ] Documentation updated
- [ ] Breaking changes documented
## Related Issues
Closes #123
5. Code Review Checklist¶
For Reviewers: - [ ] Code follows TypeScript/NestJS/Next.js best practices - [ ] Multi-tenant isolation is maintained - [ ] Tests cover main functionality - [ ] No security vulnerabilities (SQL injection, XSS, etc.) - [ ] Performance considerations addressed - [ ] Error handling is comprehensive - [ ] Code is well-documented - [ ] No console.log or debugging code left - [ ] Environment variables are documented
6. Addressing Feedback¶
# Make requested changes
git add .
git commit -m "fix: address PR feedback - improve error handling"
git push origin feature/add-product-module
7. Merge¶
After approval, the PR will be merged using Squash and Merge strategy.
Common Development Tasks¶
Adding a New Backend Module¶
-
Generate module structure:
-
Review generated files:
- Entity (product.entity.ts)
- DTOs (create-product.dto.ts, update-product.dto.ts)
- Repository (products.repository.ts)
- Service (products.service.ts)
- Controller (products.controller.ts)
- Module (products.module.ts)
-
Tests (*.spec.ts, *.e2e-spec.ts)
-
Customize business logic:
- Add custom methods to service
- Implement relationships in entity
-
Add custom validation rules
-
Run tests:
Adding a New Frontend Page¶
-
Generate module:
-
Review generated files:
- Types (packages/types/src/products/)
- Schemas (packages/schemas/src/products/)
- Services (apps/web/src/services/products.service.ts)
- Hooks (apps/web/src/hooks/useProducts.ts)
- Components (apps/web/src/components/products/)
-
Pages (apps/web/src/app/dashboard/products/)
-
Customize UI:
- Adjust component styling
- Add custom form fields
-
Implement additional features
-
Test manually:
- Navigate to http://localhost:3000/dashboard/products
- Test CRUD operations
- Verify validation
Creating Database Migrations¶
# Generate migration from entity changes
pnpm migration:generate AddProductsTable
# Review generated migration in apps/backend/src/database/migrations/
# Run migration
pnpm db:migrate
# Revert if needed
pnpm migration:revert
Manual Migration Example:
// 1234567890123-AddProductsTable.ts
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
export class AddProductsTable1234567890123 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'products',
columns: [
{
name: 'id',
type: 'int',
isPrimary: true,
isGenerated: true,
generationStrategy: 'increment',
},
{
name: 'name',
type: 'varchar',
length: '255',
},
{
name: 'organization_id',
type: 'int',
},
// ... more columns
],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('products');
}
}
Adding API Client Methods¶
// apps/web/src/services/products.service.ts
import { apiClient } from '@mmr/api-client';
import type { Product, CreateProductDto, ProductQueryParams } from '@mmr/types';
export const productsApi = {
async getAll(params?: ProductQueryParams): Promise<Product[]> {
const { data } = await apiClient.get('/products', { params });
return data.data;
},
async getOne(id: number): Promise<Product> {
const { data } = await apiClient.get(`/products/${id}`);
return data.data;
},
async create(dto: CreateProductDto): Promise<Product> {
const { data } = await apiClient.post('/products', dto);
return data.data;
},
async update(id: number, dto: Partial<CreateProductDto>): Promise<Product> {
const { data } = await apiClient.put(`/products/${id}`, dto);
return data.data;
},
async delete(id: number): Promise<void> {
await apiClient.delete(`/products/${id}`);
},
};
Questions or Issues?¶
- Setup Problems: See LOCAL_SETUP.md Troubleshooting section
- Architecture Questions: See ARCHITECTURE.md
- Git Workflow: See GIT_WORKFLOW.md
- AI Agents: See .claude/agents/README.md
- Open an Issue: Use GitHub Issues for bugs or feature requests
Version: 2.0.0 | Last Updated: 2026-06-20