Instruction file imported from vitor-ramalho/scheduler (
.cursor/rules/backend_structure_document.mdc). Copyright stays with the author.
Backend Structure Document
API Architecture
RESTful API Structure
- Implement a RESTful API using NestJS with TypeScript
- Organize endpoints by resource domains using NestJS modules:
/api/v1/[resource] - Main resource domains:
organizations,users,professionals,services,clients,appointments,availability - Include tenant identification in all routes except public and authentication endpoints
- All user-facing messages returned by the API should be in Portuguese (pt-BR)
Multi-Tenant Design
- Implement tenant isolation through NestJS guards and interceptors
- All database queries must include tenant filters
- Store
tenantId(organizationId) in JWT payload - Apply tenant filtering at the repository layer
- Implement tenant guard to validate requests
Authentication & Authorization
- JWT-based authentication with refresh token rotation using
@nestjs/jwtand@nestjs/passport - Role-based access control (RBAC) with granular permissions using NestJS guards
- Permission structure:
[action]:[resource](e.g.,read:appointments,create:professionals) - Support for inviting users with predefined roles (admin, receptionist, professional)
- Secure password handling with bcrypt and proper salt rounds
API Response Format
// src/common/interfaces/api-response.interface.ts
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: {
code: string;
message: string; // Messages in Portuguese (pt-BR)
details?: any;
};
meta?: {
pagination?: {
page: number;
pageSize: number;
total: number;
totalPages: number;
}
}
}
// src/common/interceptors/transform.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
return next.handle().pipe(
map(data => ({
success: true,
data,
meta: {
timestamp: new Date().toISOString(),
},
})),
);
}
}
Error Handling
- Implement centralized error handling using NestJS exception filters
- Use custom error classes extending from a base AppError
- Include status codes, error codes, and user-friendly messages in Portuguese
- Log detailed errors but return sanitized responses to clients
- Handle validation errors using class-validator
// src/common/filters/http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
response.status(status).json({
success: false,
error: {
code: exception.name,
message: exception.message,
details: exception.getResponse(),
},
meta: {
timestamp: new Date().toISOString(),
path: request.url,
},
});
}
}
Database Design
Core Schema
organizations: Multi-tenant root entityorganization_plans: Subscription plans for organizationsusers: User accounts linked to organizationsroles: Role definitions with permissionsprofessionals: Professional profiles within organizationsservices: Services offered by professionalsavailability: Working hours and availability slotsclients: Customer informationappointments: Appointment recordswhatsapp_templates: Templates for WhatsApp communication
Example Schema Design with TypeORM
Organizations Entity
// src/modules/organizations/entities/organization.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Professional } from '../../professionals/entities/professional.entity';
@Entity('organizations')
export class Organization {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column()
planId: string;
@Column({
type: 'enum',
enum: ['MEDICAL', 'BEAUTY', 'TATTOO', 'OTHER'],
})
businessType: string;
@Column({ nullable: true })
address: string;
@Column({ nullable: true })
phone: string;
@Column()
email: string;
@Column({ nullable: true })
logo: string;
@Column({ nullable: true })
whatsappBusinessId: string;
@Column('jsonb', { nullable: true })
settings: Record<string, any>;
@Column({ default: true })
active: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@OneToMany(() => User, user => user.organization)
users: User[];
@OneToMany(() => Professional, professional => professional.organization)
professionals: Professional[];
}
Users Entity
// src/modules/users/entities/user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Organization } from '../../organizations/entities/organization.entity';
import { Role } from '../../roles/entities/role.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
organizationId: string;
@Column()
email: string;
@Column()
passwordHash: string;
@Column()
firstName: string;
@Column()
lastName: string;
@Column()
roleId: string;
@Column({ nullable: true })
professionalId: string;
@Column({ default: true })
active: boolean;
@Column({ nullable: true })
lastLogin: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => Organization, organization => organization.users)
@JoinColumn({ name: 'organizationId' })
organization: Organization;
@ManyToOne(() => Role)
@JoinColumn({ name: 'roleId' })
role: Role;
}
Repository Pattern with TypeORM
// src/common/repositories/base.repository.ts
import { Repository, FindOptionsWhere } from 'typeorm';
import { Injectable } from '@nestjs/common';
@Injectable()
export abstract class BaseRepository<T> {
constructor(protected readonly repository: Repository<T>) {}
async findById(id: string, tenantId: string): Promise<T | null> {
return this.repository.findOne({
where: { id, organizationId: tenantId } as FindOptionsWhere<T>,
});
}
async findAll(
tenantId: string,
filters?: FindOptionsWhere<T>,
pagination?: { page: number; pageSize: number },
): Promise<{ data: T[]; total: number }> {
const [data, total] = await this.repository.findAndCount({
where: { organizationId: tenantId, ...filters } as FindOptionsWhere<T>,
skip: pagination ? (pagination.page - 1) * pagination.pageSize : 0,
take: pagination?.pageSize,
});
return { data, total };
}
}
Service Layer
Service Pattern with NestJS
- Implement business logic in service classes using NestJS dependency injection
- Services should be stateless and properly decorated with
@Injectable() - Each domain area has its own service (ProfessionalService, AppointmentService, etc.)
- Services handle permissions checks before operations
// src/modules/appointments/services/appointment.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Appointment } from '../entities/appointment.entity';
import { CreateAppointmentDto } from '../dto/create-appointment.dto';
import { AvailabilityService } from '../../availability/services/availability.service';
import { WhatsAppService } from '../../whatsapp/services/whatsapp.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class AppointmentService {
constructor(
@InjectRepository(Appointment)
private appointmentRepository: Repository<Appointment>,
private availabilityService: AvailabilityService,
private whatsappService: WhatsAppService,
private eventEmitter: EventEmitter2,
) {}
async createAppointment(
tenantId: string,
userId: string,
appointmentData: CreateAppointmentDto,
): Promise<Appointment> {
// Validate professional availability
const isAvailable = await this.availabilityService.checkAvailability(
tenantId,
appointmentData.professionalId,
appointmentData.startTime,
appointmentData.endTime,
);
if (!isAvailable) {
throw new UnauthorizedException('Professional is not available at this time');
}
// Create appointment
const appointment = this.appointmentRepository.create({
...appointmentData,
organizationId: tenantId,
createdBy: userId,
});
const savedAppointment = await this.appointmentRepository.save(appointment);
// Send WhatsApp confirmation
await this.whatsappService.sendAppointmentConfirmation(
tenantId,
savedAppointment.id,
);
// Emit event
this.eventEmitter.emit('appointment.created', {
tenantId,
payload: savedAppointment,
metadata: {
userId,
timestamp: new Date(),
},
});
return savedAppointment;
}
}
Data Transfer Objects (DTOs)
- Use DTOs with class-validator for input validation
- Implement using NestJS decorators
- Separate DTOs for creation, updates, and responses
// src/modules/appointments/dto/create-appointment.dto.ts
import { IsString, IsUUID, IsDate, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateAppointmentDto {
@ApiProperty()
@IsUUID()
clientId: string;
@ApiProperty()
@IsUUID()
professionalId: string;
@ApiProperty()
@IsUUID()
serviceId: string;
@ApiProperty()
@IsDate()
startTime: Date;
@ApiProperty()
@IsDate()
endTime: Date;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
notes?: string;
}
WhatsApp Integration
WhatsApp API Client with NestJS
- Implement integration with WhatsApp Business API using NestJS modules
- Use templates for structured messages
- Create service for sending and receiving messages
// src/modules/whatsapp/whatsapp.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WhatsAppTemplate } from './entities/whatsapp-template.entity';
import { WhatsAppService } from './services/whatsapp.service';
import { WhatsAppController } from './controllers/whatsapp.controller';
@Module({
imports: [TypeOrmModule.forFeature([WhatsAppTemplate])],
controllers: [WhatsAppController],
providers: [WhatsAppService],
exports: [WhatsAppService],
})
export class WhatsAppModule {}
// src/modules/whatsapp/services/whatsapp.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WhatsAppTemplate } from '../entities/whatsapp-template.entity';
import { WhatsAppApiClient } from '../clients/whatsapp-api.client';
@Injectable()
export class WhatsAppService {
constructor(
@InjectRepository(WhatsAppTemplate)
private templateRepository: Repository<WhatsAppTemplate>,
private whatsappApiClient: WhatsAppApiClient,
) {}
async sendAppointmentConfirmation(
tenantId: string,
appointmentId: string,
): Promise<void> {
const template = await this.templateRepository.findOne({
where: {
organizationId: tenantId,
type: 'CONFIRMATION',
active: true,
},
});
if (!template) {
throw new Error('No active confirmation template found');
}
// Get appointment details and client info
// Replace placeholders in template
// Send message via WhatsApp API
}
}
Background Jobs & Processing
Job Queue with Bull
- Implement with Bull using NestJS BullModule
- Separate queues for different job types
- Configure job processors and listeners
// src/modules/notifications/notifications.module.ts
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bull';
import { NotificationsProcessor } from './processors/notifications.processor';
import { NotificationsService } from './services/notifications.service';
@Module({
imports: [
BullModule.registerQueue({
name: 'notifications',
redis: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT),
},
}),
],
providers: [NotificationsProcessor, NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule {}
// src/modules/notifications/processors/notifications.processor.ts
import { Processor, Process } from '@nestjs/bull';
import { Job } from 'bull';
@Processor('notifications')
export class NotificationsProcessor {
@Process('appointment-reminder')
async handleAppointmentReminder(job: Job) {
const { appointmentId, tenantId } = job.data;
// Process appointment reminder
}
}
Event System
Event-Driven Architecture with NestJS
- Implement publish/subscribe pattern using NestJS EventEmitter2
- Events should include tenant context
- Define event interfaces and handlers
// src/common/events/appointment.events.ts
export enum AppointmentEventType {
CREATED = 'appointment.created',
CONFIRMED = 'appointment.confirmed',
CANCELLED = 'appointment.cancelled',
COMPLETED = 'appointment.completed',
}
export interface AppointmentEvent {
type: AppointmentEventType;
tenantId: string;
payload: any;
metadata: {
userId?: string;
timestamp: Date;
};
}
// src/modules/appointments/appointments.module.ts
import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { AppointmentsService } from './services/appointments.service';
import { AppointmentsController } from './controllers/appointments.controller';
@Module({
imports: [EventEmitterModule.forRoot()],
controllers: [AppointmentsController],
providers: [AppointmentsService],
})
export class AppointmentsModule {}
API Testing Structure
Test Categories with NestJS
- Unit tests using Jest and NestJS testing utilities
- Integration tests for API endpoints using
@nestjs/testing - Repository tests with test database
- WhatsApp communication tests with mocked API
// src/modules/appointments/__tests__/appointments.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { AppointmentsService } from '../services/appointments.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Appointment } from '../entities/appointment.entity';
import { Repository } from 'typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
describe('AppointmentsService', () => {
let service: AppointmentsService;
let repository: Repository<Appointment>;
let eventEmitter: EventEmitter2;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AppointmentsService,
{
provide: getRepositoryToken(Appointment),
useValue: {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
},
},
{
provide: EventEmitter2,
useValue: {
emit: jest.fn(),
},
},
],
}).compile();
service = module.get<AppointmentsService>(AppointmentsService);
repository = module.get<Repository<Appointment>>(getRepositoryToken(Appointment));
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('createAppointment', () => {
it('should create an appointment and emit event', async () => {
const appointmentData = {
clientId: 'client-1',
professionalId: 'professional-1',
serviceId: 'service-1',
startTime: new Date(),
endTime: new Date(),
};
const savedAppointment = {
id: 'appointment-1',
...appointmentData,
organizationId: 'tenant-1',
createdBy: 'user-1',
};
jest.spyOn(repository, 'create').mockReturnValue(savedAppointment);
jest.spyOn(repository, 'save').mockResolvedValue(savedAppointment);
const result = await service.createAppointment(
'tenant-1',
'user-1',
appointmentData,
);
expect(result).toEqual(savedAppointment);
expect(eventEmitter.emit).toHaveBeenCalledWith(
'appointment.created',
expect.objectContaining({
tenantId: 'tenant-1',
payload: savedAppointment,
}),
);
});
});
});
Test Organization
- Test files should mirror the structure of source files
- Use fixtures for test data
- Mock external dependencies using NestJS testing utilities
- Include tenant context in all tests
- Include Portuguese text validation for user-facing messages
Localization Testing
- Verify that all user-facing messages are correctly returned in Portuguese
- Test WhatsApp templates with various placeholders
- Validate date/time formatting for Brazilian locale