feat(sales,commissions): Add Sales and Commissions backend modules

Sales Module (SAAS-018):
- Entities: PipelineStage, Lead, Opportunity, Activity
- Services: LeadsService, OpportunitiesService, ActivitiesService, PipelineService, SalesDashboardService
- Controllers: 25 endpoints for leads, opportunities, activities, pipeline, dashboard
- DTOs: Complete CRUD and query DTOs
- Integration with DDL functions: convert_lead_to_opportunity, update_opportunity_stage, calculate_lead_score

Commissions Module (SAAS-020):
- Entities: CommissionScheme, CommissionAssignment, CommissionPeriod, CommissionEntry
- Services: SchemesService, AssignmentsService, EntriesService, PeriodsService, CommissionsDashboardService
- Controllers: 25 endpoints for schemes, assignments, entries, periods, dashboard
- DTOs: Complete CRUD and query DTOs
- Integration with DDL functions: calculate_commission, close_period, get_user_earnings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-24 22:23:02 -06:00
parent b49a051d85
commit eb6a83daba
51 changed files with 5576 additions and 0 deletions

View File

@ -28,6 +28,8 @@ import { OnboardingModule } from '@modules/onboarding/onboarding.module';
import { WhatsAppModule } from '@modules/whatsapp/whatsapp.module';
import { AnalyticsModule } from '@modules/analytics/analytics.module';
import { ReportsModule } from '@modules/reports/reports.module';
import { SalesModule } from '@modules/sales/sales.module';
import { CommissionsModule } from '@modules/commissions/commissions.module';
@Module({
imports: [
@ -88,6 +90,8 @@ import { ReportsModule } from '@modules/reports/reports.module';
WhatsAppModule,
AnalyticsModule,
ReportsModule,
SalesModule,
CommissionsModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,58 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import {
CommissionSchemeEntity,
CommissionAssignmentEntity,
CommissionPeriodEntity,
CommissionEntryEntity,
} from './entities';
import {
SchemesService,
AssignmentsService,
EntriesService,
PeriodsService,
CommissionsDashboardService,
} from './services';
import {
SchemesController,
AssignmentsController,
EntriesController,
PeriodsController,
CommissionsDashboardController,
} from './controllers';
@Module({
imports: [
TypeOrmModule.forFeature([
CommissionSchemeEntity,
CommissionAssignmentEntity,
CommissionPeriodEntity,
CommissionEntryEntity,
]),
],
controllers: [
SchemesController,
AssignmentsController,
EntriesController,
PeriodsController,
CommissionsDashboardController,
],
providers: [
SchemesService,
AssignmentsService,
EntriesService,
PeriodsService,
CommissionsDashboardService,
],
exports: [
SchemesService,
AssignmentsService,
EntriesService,
PeriodsService,
CommissionsDashboardService,
],
})
export class CommissionsModule {}

View File

@ -0,0 +1,82 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
import { AssignmentsService } from '../services';
import {
CreateAssignmentDto,
UpdateAssignmentDto,
AssignmentListQueryDto,
AssignmentResponseDto,
PaginatedAssignmentsDto,
} from '../dto';
interface RequestUser {
id: string;
tenant_id: string;
email: string;
role: string;
}
@Controller('commissions/assignments')
@UseGuards(JwtAuthGuard)
export class AssignmentsController {
constructor(private readonly assignmentsService: AssignmentsService) {}
@Get()
async findAll(
@CurrentUser() user: RequestUser,
@Query() query: AssignmentListQueryDto,
): Promise<PaginatedAssignmentsDto> {
return this.assignmentsService.findAll(user.tenant_id, query);
}
@Get('my')
async findMyAssignments(@CurrentUser() user: RequestUser): Promise<AssignmentResponseDto[]> {
return this.assignmentsService.findActiveForUser(user.tenant_id, user.id);
}
@Get(':id')
async findOne(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<AssignmentResponseDto> {
return this.assignmentsService.findOne(user.tenant_id, id);
}
@Post()
async create(
@CurrentUser() user: RequestUser,
@Body() dto: CreateAssignmentDto,
): Promise<AssignmentResponseDto> {
return this.assignmentsService.create(user.tenant_id, user.id, dto);
}
@Put(':id')
async update(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateAssignmentDto,
): Promise<AssignmentResponseDto> {
return this.assignmentsService.update(user.tenant_id, id, dto);
}
@Delete(':id')
async remove(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<{ message: string }> {
await this.assignmentsService.remove(user.tenant_id, id);
return { message: 'Commission assignment deleted successfully' };
}
}

View File

@ -0,0 +1,57 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
import { CommissionsDashboardService } from '../services';
import {
CommissionsDashboardDto,
UserEarningsDto,
EntriesByStatusDto,
EntriesBySchemeDto,
EntriesByUserDto,
} from '../dto';
interface RequestUser {
id: string;
tenant_id: string;
email: string;
role: string;
}
@Controller('commissions/dashboard')
@UseGuards(JwtAuthGuard)
export class CommissionsDashboardController {
constructor(private readonly dashboardService: CommissionsDashboardService) {}
@Get()
async getSummary(@CurrentUser() user: RequestUser): Promise<CommissionsDashboardDto> {
return this.dashboardService.getDashboardSummary(user.tenant_id);
}
@Get('my-earnings')
async getMyEarnings(@CurrentUser() user: RequestUser): Promise<UserEarningsDto> {
return this.dashboardService.getUserEarnings(user.tenant_id, user.id);
}
@Get('entries-by-status')
async getEntriesByStatus(@CurrentUser() user: RequestUser): Promise<EntriesByStatusDto[]> {
return this.dashboardService.getEntriesByStatus(user.tenant_id);
}
@Get('entries-by-scheme')
async getEntriesByScheme(@CurrentUser() user: RequestUser): Promise<EntriesBySchemeDto[]> {
return this.dashboardService.getEntriesByScheme(user.tenant_id);
}
@Get('entries-by-user')
async getEntriesByUser(@CurrentUser() user: RequestUser): Promise<EntriesByUserDto[]> {
return this.dashboardService.getEntriesByUser(user.tenant_id);
}
@Get('top-earners')
async getTopEarners(
@CurrentUser() user: RequestUser,
@Query('limit') limit?: number,
): Promise<EntriesByUserDto[]> {
return this.dashboardService.getTopEarners(user.tenant_id, limit || 10);
}
}

View File

@ -0,0 +1,118 @@
import {
Controller,
Get,
Post,
Put,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
import { EntriesService } from '../services';
import {
CreateEntryDto,
UpdateEntryDto,
ApproveEntryDto,
RejectEntryDto,
EntryListQueryDto,
EntryResponseDto,
PaginatedEntriesDto,
CalculateCommissionDto,
CalculateCommissionResponseDto,
} from '../dto';
interface RequestUser {
id: string;
tenant_id: string;
email: string;
role: string;
}
@Controller('commissions/entries')
@UseGuards(JwtAuthGuard)
export class EntriesController {
constructor(private readonly entriesService: EntriesService) {}
@Get()
async findAll(
@CurrentUser() user: RequestUser,
@Query() query: EntryListQueryDto,
): Promise<PaginatedEntriesDto> {
return this.entriesService.findAll(user.tenant_id, query);
}
@Get('my')
async findMyEntries(
@CurrentUser() user: RequestUser,
@Query() query: EntryListQueryDto,
): Promise<PaginatedEntriesDto> {
return this.entriesService.findAll(user.tenant_id, { ...query, userId: user.id });
}
@Get(':id')
async findOne(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<EntryResponseDto> {
return this.entriesService.findOne(user.tenant_id, id);
}
@Post()
async create(
@CurrentUser() user: RequestUser,
@Body() dto: CreateEntryDto,
): Promise<EntryResponseDto> {
return this.entriesService.create(user.tenant_id, user.id, dto);
}
@Put(':id')
async update(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateEntryDto,
): Promise<EntryResponseDto> {
return this.entriesService.update(user.tenant_id, id, dto);
}
@Post(':id/approve')
async approve(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ApproveEntryDto,
): Promise<EntryResponseDto> {
return this.entriesService.approve(user.tenant_id, id, user.id, dto);
}
@Post(':id/reject')
async reject(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RejectEntryDto,
): Promise<EntryResponseDto> {
return this.entriesService.reject(user.tenant_id, id, user.id, dto);
}
@Post('calculate')
async calculateCommission(
@CurrentUser() user: RequestUser,
@Body() dto: CalculateCommissionDto,
): Promise<CalculateCommissionResponseDto> {
return this.entriesService.calculateCommission(user.tenant_id, dto);
}
@Post('bulk-approve')
async bulkApprove(
@CurrentUser() user: RequestUser,
@Body() body: { entryIds: string[] },
): Promise<{ approved: number }> {
const approved = await this.entriesService.bulkApprove(
user.tenant_id,
body.entryIds,
user.id,
);
return { approved };
}
}

View File

@ -0,0 +1,5 @@
export * from './schemes.controller';
export * from './assignments.controller';
export * from './entries.controller';
export * from './periods.controller';
export * from './dashboard.controller';

View File

@ -0,0 +1,116 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
import { PeriodsService } from '../services';
import {
CreatePeriodDto,
UpdatePeriodDto,
ClosePeriodDto,
MarkPaidDto,
PeriodListQueryDto,
PeriodResponseDto,
PaginatedPeriodsDto,
} from '../dto';
interface RequestUser {
id: string;
tenant_id: string;
email: string;
role: string;
}
@Controller('commissions/periods')
@UseGuards(JwtAuthGuard)
export class PeriodsController {
constructor(private readonly periodsService: PeriodsService) {}
@Get()
async findAll(
@CurrentUser() user: RequestUser,
@Query() query: PeriodListQueryDto,
): Promise<PaginatedPeriodsDto> {
return this.periodsService.findAll(user.tenant_id, query);
}
@Get('current')
async getCurrentPeriod(@CurrentUser() user: RequestUser): Promise<PeriodResponseDto | null> {
return this.periodsService.getCurrentPeriod(user.tenant_id);
}
@Get(':id')
async findOne(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<PeriodResponseDto> {
return this.periodsService.findOne(user.tenant_id, id);
}
@Post()
async create(
@CurrentUser() user: RequestUser,
@Body() dto: CreatePeriodDto,
): Promise<PeriodResponseDto> {
return this.periodsService.create(user.tenant_id, user.id, dto);
}
@Put(':id')
async update(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePeriodDto,
): Promise<PeriodResponseDto> {
return this.periodsService.update(user.tenant_id, id, dto);
}
@Post(':id/close')
async close(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ClosePeriodDto,
): Promise<PeriodResponseDto> {
return this.periodsService.close(user.tenant_id, id, user.id, dto);
}
@Post(':id/mark-paid')
async markPaid(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: MarkPaidDto,
): Promise<PeriodResponseDto> {
return this.periodsService.markAsPaid(user.tenant_id, id, user.id, dto);
}
@Delete(':id')
async remove(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<{ message: string }> {
await this.periodsService.remove(user.tenant_id, id);
return { message: 'Commission period deleted successfully' };
}
@Post(':id/assign-entries')
async assignEntries(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() body: { entryIds: string[] },
): Promise<{ assigned: number }> {
const assigned = await this.periodsService.assignEntriesToPeriod(
user.tenant_id,
id,
body.entryIds,
);
return { assigned };
}
}

View File

@ -0,0 +1,93 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
import { SchemesService } from '../services';
import {
CreateSchemeDto,
UpdateSchemeDto,
SchemeListQueryDto,
SchemeResponseDto,
PaginatedSchemesDto,
} from '../dto';
interface RequestUser {
id: string;
tenant_id: string;
email: string;
role: string;
}
@Controller('commissions/schemes')
@UseGuards(JwtAuthGuard)
export class SchemesController {
constructor(private readonly schemesService: SchemesService) {}
@Get()
async findAll(
@CurrentUser() user: RequestUser,
@Query() query: SchemeListQueryDto,
): Promise<PaginatedSchemesDto> {
return this.schemesService.findAll(user.tenant_id, query);
}
@Get(':id')
async findOne(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<SchemeResponseDto> {
return this.schemesService.findOne(user.tenant_id, id);
}
@Post()
async create(
@CurrentUser() user: RequestUser,
@Body() dto: CreateSchemeDto,
): Promise<SchemeResponseDto> {
return this.schemesService.create(user.tenant_id, user.id, dto);
}
@Put(':id')
async update(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateSchemeDto,
): Promise<SchemeResponseDto> {
return this.schemesService.update(user.tenant_id, id, dto);
}
@Delete(':id')
async remove(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<{ message: string }> {
await this.schemesService.remove(user.tenant_id, id);
return { message: 'Commission scheme deleted successfully' };
}
@Post(':id/activate')
async activate(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<SchemeResponseDto> {
return this.schemesService.activate(user.tenant_id, id);
}
@Post(':id/deactivate')
async deactivate(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<SchemeResponseDto> {
return this.schemesService.deactivate(user.tenant_id, id);
}
}

View File

@ -0,0 +1,98 @@
import {
IsUUID,
IsOptional,
IsNumber,
IsBoolean,
IsDateString,
Min,
Max,
} from 'class-validator';
export class CreateAssignmentDto {
@IsUUID()
userId: string;
@IsUUID()
schemeId: string;
@IsDateString()
@IsOptional()
startsAt?: string;
@IsDateString()
@IsOptional()
endsAt?: string;
@IsNumber()
@Min(0)
@Max(100)
@IsOptional()
customRate?: number;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
export class UpdateAssignmentDto {
@IsDateString()
@IsOptional()
startsAt?: string;
@IsDateString()
@IsOptional()
endsAt?: string;
@IsNumber()
@Min(0)
@Max(100)
@IsOptional()
customRate?: number;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
export class AssignmentResponseDto {
id: string;
tenantId: string;
userId: string;
schemeId: string;
startsAt: Date;
endsAt: Date | null;
customRate: number | null;
isActive: boolean;
createdAt: Date;
createdBy: string | null;
scheme?: any;
user?: any;
}
export class AssignmentListQueryDto {
@IsUUID()
@IsOptional()
userId?: string;
@IsUUID()
@IsOptional()
schemeId?: string;
@IsBoolean()
@IsOptional()
isActive?: boolean;
@IsOptional()
page?: number;
@IsOptional()
limit?: number;
}
export class PaginatedAssignmentsDto {
items: AssignmentResponseDto[];
total: number;
page: number;
limit: number;
totalPages: number;
}

View File

@ -0,0 +1,60 @@
import { EntryStatus, PeriodStatus } from '../entities';
export class CommissionsDashboardDto {
totalSchemes: number;
totalActiveAssignments: number;
pendingCommissions: number;
pendingAmount: number;
approvedAmount: number;
paidAmount: number;
currentPeriod: string | null;
currency: string;
}
export class UserEarningsDto {
totalPending: number;
totalApproved: number;
totalPaid: number;
totalEntries: number;
currency: string;
}
export class EntriesByStatusDto {
status: EntryStatus;
count: number;
totalAmount: number;
percentage: number;
}
export class EntriesBySchemeDto {
schemeId: string;
schemeName: string;
count: number;
totalAmount: number;
percentage: number;
}
export class EntriesByUserDto {
userId: string;
userName: string;
count: number;
totalAmount: number;
percentage: number;
}
export class PeriodSummaryDto {
periodId: string;
periodName: string;
status: PeriodStatus;
totalEntries: number;
totalAmount: number;
entriesByStatus: EntriesByStatusDto[];
}
export class CommissionsTimelineDto {
date: string;
entriesCreated: number;
entriesApproved: number;
entriesPaid: number;
totalAmount: number;
}

View File

@ -0,0 +1,155 @@
import {
IsString,
IsUUID,
IsOptional,
IsNumber,
IsEnum,
IsObject,
MaxLength,
Min,
} from 'class-validator';
import { EntryStatus } from '../entities';
export class CreateEntryDto {
@IsUUID()
userId: string;
@IsUUID()
schemeId: string;
@IsUUID()
@IsOptional()
assignmentId?: string;
@IsString()
@MaxLength(50)
referenceType: string;
@IsUUID()
referenceId: string;
@IsNumber()
@Min(0)
baseAmount: number;
@IsString()
@MaxLength(3)
@IsOptional()
currency?: string;
@IsString()
@IsOptional()
notes?: string;
@IsObject()
@IsOptional()
metadata?: Record<string, any>;
}
export class UpdateEntryDto {
@IsEnum(EntryStatus)
@IsOptional()
status?: EntryStatus;
@IsUUID()
@IsOptional()
periodId?: string;
@IsString()
@IsOptional()
notes?: string;
@IsObject()
@IsOptional()
metadata?: Record<string, any>;
}
export class ApproveEntryDto {
@IsString()
@IsOptional()
notes?: string;
}
export class RejectEntryDto {
@IsString()
notes: string;
}
export class EntryResponseDto {
id: string;
tenantId: string;
userId: string;
schemeId: string;
assignmentId: string | null;
referenceType: string;
referenceId: string;
baseAmount: number;
rateApplied: number;
commissionAmount: number;
currency: string;
status: EntryStatus;
periodId: string | null;
paidAt: Date | null;
paymentReference: string | null;
notes: string | null;
metadata: Record<string, any>;
createdAt: Date;
updatedAt: Date;
approvedBy: string | null;
approvedAt: Date | null;
scheme?: any;
period?: any;
}
export class EntryListQueryDto {
@IsUUID()
@IsOptional()
userId?: string;
@IsUUID()
@IsOptional()
schemeId?: string;
@IsUUID()
@IsOptional()
periodId?: string;
@IsEnum(EntryStatus)
@IsOptional()
status?: EntryStatus;
@IsString()
@IsOptional()
referenceType?: string;
@IsOptional()
page?: number;
@IsOptional()
limit?: number;
}
export class PaginatedEntriesDto {
items: EntryResponseDto[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class CalculateCommissionDto {
@IsUUID()
schemeId: string;
@IsUUID()
userId: string;
@IsNumber()
@Min(0)
amount: number;
}
export class CalculateCommissionResponseDto {
rateApplied: number;
commissionAmount: number;
}

View File

@ -0,0 +1,5 @@
export * from './scheme.dto';
export * from './assignment.dto';
export * from './entry.dto';
export * from './period.dto';
export * from './dashboard.dto';

View File

@ -0,0 +1,97 @@
import {
IsString,
IsOptional,
IsEnum,
IsDateString,
MaxLength,
} from 'class-validator';
import { PeriodStatus } from '../entities';
export class CreatePeriodDto {
@IsString()
@MaxLength(100)
name: string;
@IsDateString()
startsAt: string;
@IsDateString()
endsAt: string;
@IsString()
@MaxLength(3)
@IsOptional()
currency?: string;
}
export class UpdatePeriodDto {
@IsString()
@MaxLength(100)
@IsOptional()
name?: string;
@IsDateString()
@IsOptional()
startsAt?: string;
@IsDateString()
@IsOptional()
endsAt?: string;
}
export class ClosePeriodDto {
@IsString()
@IsOptional()
notes?: string;
}
export class MarkPaidDto {
@IsString()
@MaxLength(255)
@IsOptional()
paymentReference?: string;
@IsString()
@IsOptional()
paymentNotes?: string;
}
export class PeriodResponseDto {
id: string;
tenantId: string;
name: string;
startsAt: Date;
endsAt: Date;
totalEntries: number;
totalAmount: number;
currency: string;
status: PeriodStatus;
closedAt: Date | null;
closedBy: string | null;
paidAt: Date | null;
paidBy: string | null;
paymentReference: string | null;
paymentNotes: string | null;
createdAt: Date;
createdBy: string | null;
}
export class PeriodListQueryDto {
@IsEnum(PeriodStatus)
@IsOptional()
status?: PeriodStatus;
@IsOptional()
page?: number;
@IsOptional()
limit?: number;
}
export class PaginatedPeriodsDto {
items: PeriodResponseDto[];
total: number;
page: number;
limit: number;
totalPages: number;
}

View File

@ -0,0 +1,192 @@
import {
IsString,
IsOptional,
IsEnum,
IsNumber,
IsBoolean,
IsArray,
IsUUID,
MaxLength,
Min,
Max,
} from 'class-validator';
import { SchemeType, AppliesTo } from '../entities';
export class TierDto {
@IsNumber()
@Min(0)
from: number;
@IsNumber()
@IsOptional()
to?: number;
@IsNumber()
@Min(0)
@Max(100)
rate: number;
}
export class CreateSchemeDto {
@IsString()
@MaxLength(100)
name: string;
@IsString()
@IsOptional()
description?: string;
@IsEnum(SchemeType)
@IsOptional()
type?: SchemeType;
@IsNumber()
@Min(0)
@Max(100)
@IsOptional()
rate?: number;
@IsNumber()
@Min(0)
@IsOptional()
fixedAmount?: number;
@IsArray()
@IsOptional()
tiers?: TierDto[];
@IsEnum(AppliesTo)
@IsOptional()
appliesTo?: AppliesTo;
@IsArray()
@IsUUID('4', { each: true })
@IsOptional()
productIds?: string[];
@IsArray()
@IsUUID('4', { each: true })
@IsOptional()
categoryIds?: string[];
@IsNumber()
@Min(0)
@IsOptional()
minAmount?: number;
@IsNumber()
@Min(0)
@IsOptional()
maxAmount?: number;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
export class UpdateSchemeDto {
@IsString()
@MaxLength(100)
@IsOptional()
name?: string;
@IsString()
@IsOptional()
description?: string;
@IsEnum(SchemeType)
@IsOptional()
type?: SchemeType;
@IsNumber()
@Min(0)
@Max(100)
@IsOptional()
rate?: number;
@IsNumber()
@Min(0)
@IsOptional()
fixedAmount?: number;
@IsArray()
@IsOptional()
tiers?: TierDto[];
@IsEnum(AppliesTo)
@IsOptional()
appliesTo?: AppliesTo;
@IsArray()
@IsUUID('4', { each: true })
@IsOptional()
productIds?: string[];
@IsArray()
@IsUUID('4', { each: true })
@IsOptional()
categoryIds?: string[];
@IsNumber()
@Min(0)
@IsOptional()
minAmount?: number;
@IsNumber()
@Min(0)
@IsOptional()
maxAmount?: number;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
export class SchemeResponseDto {
id: string;
tenantId: string;
name: string;
description: string | null;
type: SchemeType;
rate: number;
fixedAmount: number;
tiers: TierDto[];
appliesTo: AppliesTo;
productIds: string[];
categoryIds: string[];
minAmount: number;
maxAmount: number | null;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
createdBy: string | null;
assignmentsCount?: number;
}
export class SchemeListQueryDto {
@IsEnum(SchemeType)
@IsOptional()
type?: SchemeType;
@IsBoolean()
@IsOptional()
isActive?: boolean;
@IsString()
@IsOptional()
search?: string;
@IsOptional()
page?: number;
@IsOptional()
limit?: number;
}
export class PaginatedSchemesDto {
items: SchemeResponseDto[];
total: number;
page: number;
limit: number;
totalPages: number;
}

View File

@ -0,0 +1,46 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { CommissionSchemeEntity } from './commission-scheme.entity';
@Entity({ schema: 'commissions', name: 'assignments' })
export class CommissionAssignmentEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ name: 'scheme_id', type: 'uuid' })
schemeId: string;
@Column({ name: 'starts_at', type: 'timestamptz', default: () => 'NOW()' })
startsAt: Date;
@Column({ name: 'ends_at', type: 'timestamptz', nullable: true })
endsAt: Date;
@Column({ name: 'custom_rate', type: 'decimal', precision: 5, scale: 2, nullable: true })
customRate: number;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@ManyToOne(() => CommissionSchemeEntity, (scheme) => scheme.assignments)
@JoinColumn({ name: 'scheme_id' })
scheme: CommissionSchemeEntity;
}

View File

@ -0,0 +1,103 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { CommissionSchemeEntity } from './commission-scheme.entity';
import { CommissionAssignmentEntity } from './commission-assignment.entity';
import { CommissionPeriodEntity } from './commission-period.entity';
export enum EntryStatus {
PENDING = 'pending',
APPROVED = 'approved',
REJECTED = 'rejected',
PAID = 'paid',
CANCELLED = 'cancelled',
}
@Entity({ schema: 'commissions', name: 'entries' })
export class CommissionEntryEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ name: 'scheme_id', type: 'uuid' })
schemeId: string;
@Column({ name: 'assignment_id', type: 'uuid', nullable: true })
assignmentId: string;
@Column({ name: 'reference_type', length: 50 })
referenceType: string;
@Column({ name: 'reference_id', type: 'uuid' })
referenceId: string;
@Column({ name: 'base_amount', type: 'decimal', precision: 15, scale: 2 })
baseAmount: number;
@Column({ name: 'rate_applied', type: 'decimal', precision: 5, scale: 2 })
rateApplied: number;
@Column({ name: 'commission_amount', type: 'decimal', precision: 15, scale: 2 })
commissionAmount: number;
@Column({ length: 3, default: 'USD' })
currency: string;
@Column({
type: 'enum',
enum: EntryStatus,
enumName: 'entry_status',
default: EntryStatus.PENDING,
})
status: EntryStatus;
@Column({ name: 'period_id', type: 'uuid', nullable: true })
periodId: string;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt: Date;
@Column({ name: 'payment_reference', length: 255, nullable: true })
paymentReference: string;
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
approvedBy: string;
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
approvedAt: Date;
@ManyToOne(() => CommissionSchemeEntity, (scheme) => scheme.entries)
@JoinColumn({ name: 'scheme_id' })
scheme: CommissionSchemeEntity;
@ManyToOne(() => CommissionAssignmentEntity)
@JoinColumn({ name: 'assignment_id' })
assignment: CommissionAssignmentEntity;
@ManyToOne(() => CommissionPeriodEntity, (period) => period.entries)
@JoinColumn({ name: 'period_id' })
period: CommissionPeriodEntity;
}

View File

@ -0,0 +1,76 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
OneToMany,
} from 'typeorm';
export enum PeriodStatus {
OPEN = 'open',
CLOSED = 'closed',
PROCESSING = 'processing',
PAID = 'paid',
}
@Entity({ schema: 'commissions', name: 'periods' })
export class CommissionPeriodEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ length: 100 })
name: string;
@Column({ name: 'starts_at', type: 'timestamptz' })
startsAt: Date;
@Column({ name: 'ends_at', type: 'timestamptz' })
endsAt: Date;
@Column({ name: 'total_entries', type: 'int', default: 0 })
totalEntries: number;
@Column({ name: 'total_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalAmount: number;
@Column({ length: 3, default: 'USD' })
currency: string;
@Column({
type: 'enum',
enum: PeriodStatus,
enumName: 'period_status',
default: PeriodStatus.OPEN,
})
status: PeriodStatus;
@Column({ name: 'closed_at', type: 'timestamptz', nullable: true })
closedAt: Date;
@Column({ name: 'closed_by', type: 'uuid', nullable: true })
closedBy: string;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt: Date;
@Column({ name: 'paid_by', type: 'uuid', nullable: true })
paidBy: string;
@Column({ name: 'payment_reference', length: 255, nullable: true })
paymentReference: string | null;
@Column({ name: 'payment_notes', type: 'text', nullable: true })
paymentNotes: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@OneToMany('CommissionEntryEntity', 'period')
entries: any[];
}

View File

@ -0,0 +1,94 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
export enum SchemeType {
PERCENTAGE = 'percentage',
FIXED = 'fixed',
TIERED = 'tiered',
}
export enum AppliesTo {
ALL = 'all',
PRODUCTS = 'products',
CATEGORIES = 'categories',
}
@Entity({ schema: 'commissions', name: 'schemes' })
export class CommissionSchemeEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({
type: 'enum',
enum: SchemeType,
enumName: 'scheme_type',
default: SchemeType.PERCENTAGE,
})
type: SchemeType;
@Column({ type: 'decimal', precision: 5, scale: 2, default: 0 })
rate: number;
@Column({ name: 'fixed_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
fixedAmount: number;
@Column({ type: 'jsonb', default: [] })
tiers: any[];
@Column({
name: 'applies_to',
type: 'enum',
enum: AppliesTo,
enumName: 'applies_to',
default: AppliesTo.ALL,
})
appliesTo: AppliesTo;
@Column({ name: 'product_ids', type: 'uuid', array: true, default: [] })
productIds: string[];
@Column({ name: 'category_ids', type: 'uuid', array: true, default: [] })
categoryIds: string[];
@Column({ name: 'min_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
minAmount: number;
@Column({ name: 'max_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
maxAmount: number;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
@OneToMany('CommissionAssignmentEntity', 'scheme')
assignments: any[];
@OneToMany('CommissionEntryEntity', 'scheme')
entries: any[];
}

View File

@ -0,0 +1,4 @@
export * from './commission-scheme.entity';
export * from './commission-assignment.entity';
export * from './commission-period.entity';
export * from './commission-entry.entity';

View File

@ -0,0 +1,5 @@
export * from './commissions.module';
export * from './entities';
export * from './dto';
export * from './services';
export * from './controllers';

View File

@ -0,0 +1,172 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CommissionAssignmentEntity, CommissionSchemeEntity } from '../entities';
import {
CreateAssignmentDto,
UpdateAssignmentDto,
AssignmentResponseDto,
AssignmentListQueryDto,
PaginatedAssignmentsDto,
} from '../dto';
@Injectable()
export class AssignmentsService {
private readonly logger = new Logger(AssignmentsService.name);
constructor(
@InjectRepository(CommissionAssignmentEntity)
private readonly assignmentRepo: Repository<CommissionAssignmentEntity>,
@InjectRepository(CommissionSchemeEntity)
private readonly schemeRepo: Repository<CommissionSchemeEntity>,
) {}
async create(tenantId: string, userId: string, dto: CreateAssignmentDto): Promise<AssignmentResponseDto> {
// Verify scheme exists
const scheme = await this.schemeRepo.findOne({
where: { id: dto.schemeId, tenantId, deletedAt: null as any },
});
if (!scheme) {
throw new NotFoundException('Commission scheme not found');
}
const assignment = this.assignmentRepo.create({
tenantId,
userId: dto.userId,
schemeId: dto.schemeId,
startsAt: dto.startsAt ? new Date(dto.startsAt) : new Date(),
endsAt: dto.endsAt ? new Date(dto.endsAt) : undefined,
customRate: dto.customRate,
isActive: dto.isActive ?? true,
createdBy: userId,
});
const saved = await this.assignmentRepo.save(assignment);
this.logger.log(`Commission assignment created: ${saved.id} for user ${dto.userId}`);
return this.toResponse(saved);
}
async findAll(tenantId: string, query: AssignmentListQueryDto): Promise<PaginatedAssignmentsDto> {
const page = query.page || 1;
const limit = Math.min(query.limit || 20, 100);
const skip = (page - 1) * limit;
const qb = this.assignmentRepo
.createQueryBuilder('a')
.leftJoinAndSelect('a.scheme', 'scheme')
.where('a.tenant_id = :tenantId', { tenantId });
if (query.userId) {
qb.andWhere('a.user_id = :userId', { userId: query.userId });
}
if (query.schemeId) {
qb.andWhere('a.scheme_id = :schemeId', { schemeId: query.schemeId });
}
if (query.isActive !== undefined) {
qb.andWhere('a.is_active = :isActive', { isActive: query.isActive });
}
qb.orderBy('a.created_at', 'DESC').skip(skip).take(limit);
const [items, total] = await qb.getManyAndCount();
return {
items: items.map((a) => this.toResponse(a)),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(tenantId: string, assignmentId: string): Promise<AssignmentResponseDto> {
const assignment = await this.assignmentRepo.findOne({
where: { id: assignmentId, tenantId },
relations: ['scheme'],
});
if (!assignment) {
throw new NotFoundException('Commission assignment not found');
}
return this.toResponse(assignment);
}
async update(
tenantId: string,
assignmentId: string,
dto: UpdateAssignmentDto,
): Promise<AssignmentResponseDto> {
const assignment = await this.assignmentRepo.findOne({
where: { id: assignmentId, tenantId },
});
if (!assignment) {
throw new NotFoundException('Commission assignment not found');
}
Object.assign(assignment, {
startsAt: dto.startsAt ? new Date(dto.startsAt) : assignment.startsAt,
endsAt: dto.endsAt ? new Date(dto.endsAt) : assignment.endsAt,
customRate: dto.customRate ?? assignment.customRate,
isActive: dto.isActive ?? assignment.isActive,
});
const saved = await this.assignmentRepo.save(assignment);
this.logger.log(`Commission assignment updated: ${saved.id}`);
return this.toResponse(saved);
}
async remove(tenantId: string, assignmentId: string): Promise<void> {
const assignment = await this.assignmentRepo.findOne({
where: { id: assignmentId, tenantId },
});
if (!assignment) {
throw new NotFoundException('Commission assignment not found');
}
await this.assignmentRepo.remove(assignment);
this.logger.log(`Commission assignment deleted: ${assignmentId}`);
}
async findActiveForUser(tenantId: string, userId: string): Promise<AssignmentResponseDto[]> {
const assignments = await this.assignmentRepo.find({
where: {
tenantId,
userId,
isActive: true,
},
relations: ['scheme'],
});
return assignments
.filter((a) => {
const now = new Date();
return a.startsAt <= now && (!a.endsAt || a.endsAt > now);
})
.map((a) => this.toResponse(a));
}
private toResponse(assignment: CommissionAssignmentEntity): AssignmentResponseDto {
return {
id: assignment.id,
tenantId: assignment.tenantId,
userId: assignment.userId,
schemeId: assignment.schemeId,
startsAt: assignment.startsAt,
endsAt: assignment.endsAt,
customRate: assignment.customRate ? Number(assignment.customRate) : null,
isActive: assignment.isActive,
createdAt: assignment.createdAt,
createdBy: assignment.createdBy,
scheme: assignment.scheme,
};
}
}

View File

@ -0,0 +1,183 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import {
CommissionSchemeEntity,
CommissionAssignmentEntity,
CommissionEntryEntity,
CommissionPeriodEntity,
EntryStatus,
PeriodStatus,
} from '../entities';
import {
CommissionsDashboardDto,
UserEarningsDto,
EntriesByStatusDto,
EntriesBySchemeDto,
EntriesByUserDto,
} from '../dto';
@Injectable()
export class CommissionsDashboardService {
private readonly logger = new Logger(CommissionsDashboardService.name);
constructor(
@InjectRepository(CommissionSchemeEntity)
private readonly schemeRepo: Repository<CommissionSchemeEntity>,
@InjectRepository(CommissionAssignmentEntity)
private readonly assignmentRepo: Repository<CommissionAssignmentEntity>,
@InjectRepository(CommissionEntryEntity)
private readonly entryRepo: Repository<CommissionEntryEntity>,
@InjectRepository(CommissionPeriodEntity)
private readonly periodRepo: Repository<CommissionPeriodEntity>,
private readonly dataSource: DataSource,
) {}
async getDashboardSummary(tenantId: string): Promise<CommissionsDashboardDto> {
// Total schemes
const totalSchemes = await this.schemeRepo.count({
where: { tenantId, isActive: true, deletedAt: null as any },
});
// Total active assignments
const totalActiveAssignments = await this.assignmentRepo.count({
where: { tenantId, isActive: true },
});
// Entries by status
const entriesStats = await this.entryRepo
.createQueryBuilder('e')
.select('e.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.addSelect('COALESCE(SUM(e.commission_amount), 0)', 'total')
.where('e.tenant_id = :tenantId', { tenantId })
.groupBy('e.status')
.getRawMany();
const statsMap = new Map(entriesStats.map((s) => [s.status, s]));
const pending = statsMap.get(EntryStatus.PENDING) || { count: 0, total: 0 };
const approved = statsMap.get(EntryStatus.APPROVED) || { count: 0, total: 0 };
const paid = statsMap.get(EntryStatus.PAID) || { count: 0, total: 0 };
// Current period
const currentPeriod = await this.periodRepo.findOne({
where: { tenantId, status: PeriodStatus.OPEN },
order: { startsAt: 'DESC' },
});
return {
totalSchemes,
totalActiveAssignments,
pendingCommissions: pending.count,
pendingAmount: Number(pending.total),
approvedAmount: Number(approved.total),
paidAmount: Number(paid.total),
currentPeriod: currentPeriod?.name || null,
currency: 'USD',
};
}
async getUserEarnings(
tenantId: string,
userId: string,
startDate?: Date,
endDate?: Date,
): Promise<UserEarningsDto> {
const result = await this.dataSource.query(
`SELECT * FROM commissions.get_user_earnings($1, $2, $3, $4)`,
[userId, tenantId, startDate || null, endDate || null],
);
const earnings = result[0] || {
total_pending: 0,
total_approved: 0,
total_paid: 0,
total_entries: 0,
currency: 'USD',
};
return {
totalPending: Number(earnings.total_pending),
totalApproved: Number(earnings.total_approved),
totalPaid: Number(earnings.total_paid),
totalEntries: Number(earnings.total_entries),
currency: earnings.currency,
};
}
async getEntriesByStatus(tenantId: string): Promise<EntriesByStatusDto[]> {
const result = await this.entryRepo
.createQueryBuilder('e')
.select('e.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.addSelect('COALESCE(SUM(e.commission_amount), 0)', 'totalAmount')
.where('e.tenant_id = :tenantId', { tenantId })
.groupBy('e.status')
.getRawMany();
const total = result.reduce((sum, r) => sum + r.count, 0);
return result.map((r) => ({
status: r.status,
count: r.count,
totalAmount: Number(r.totalAmount),
percentage: total > 0 ? Math.round((r.count / total) * 100) : 0,
}));
}
async getEntriesByScheme(tenantId: string): Promise<EntriesBySchemeDto[]> {
const result = await this.entryRepo
.createQueryBuilder('e')
.innerJoin('e.scheme', 's')
.select('e.scheme_id', 'schemeId')
.addSelect('s.name', 'schemeName')
.addSelect('COUNT(*)::int', 'count')
.addSelect('COALESCE(SUM(e.commission_amount), 0)', 'totalAmount')
.where('e.tenant_id = :tenantId', { tenantId })
.groupBy('e.scheme_id')
.addGroupBy('s.name')
.getRawMany();
const total = result.reduce((sum, r) => sum + r.count, 0);
return result.map((r) => ({
schemeId: r.schemeId,
schemeName: r.schemeName,
count: r.count,
totalAmount: Number(r.totalAmount),
percentage: total > 0 ? Math.round((r.count / total) * 100) : 0,
}));
}
async getEntriesByUser(tenantId: string): Promise<EntriesByUserDto[]> {
const result = await this.dataSource.query(`
SELECT
e.user_id as "userId",
COALESCE(u.first_name || ' ' || u.last_name, u.email) as "userName",
COUNT(*)::int as count,
COALESCE(SUM(e.commission_amount), 0) as "totalAmount"
FROM commissions.entries e
LEFT JOIN users.users u ON e.user_id = u.id
WHERE e.tenant_id = $1
GROUP BY e.user_id, u.first_name, u.last_name, u.email
ORDER BY "totalAmount" DESC
`, [tenantId]);
const total = result.reduce((sum: number, r: any) => sum + r.count, 0);
return result.map((r: any) => ({
userId: r.userId,
userName: r.userName,
count: r.count,
totalAmount: Number(r.totalAmount),
percentage: total > 0 ? Math.round((r.count / total) * 100) : 0,
}));
}
async getTopEarners(tenantId: string, limit: number = 10): Promise<EntriesByUserDto[]> {
const users = await this.getEntriesByUser(tenantId);
return users.slice(0, limit);
}
}

View File

@ -0,0 +1,288 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { CommissionEntryEntity, EntryStatus } from '../entities';
import {
CreateEntryDto,
UpdateEntryDto,
ApproveEntryDto,
RejectEntryDto,
EntryResponseDto,
EntryListQueryDto,
PaginatedEntriesDto,
CalculateCommissionDto,
CalculateCommissionResponseDto,
} from '../dto';
@Injectable()
export class EntriesService {
private readonly logger = new Logger(EntriesService.name);
constructor(
@InjectRepository(CommissionEntryEntity)
private readonly entryRepo: Repository<CommissionEntryEntity>,
private readonly dataSource: DataSource,
) {}
async create(tenantId: string, userId: string, dto: CreateEntryDto): Promise<EntryResponseDto> {
// Calculate commission using database function
const calcResult = await this.dataSource.query(
`SELECT * FROM commissions.calculate_commission($1, $2, $3, $4)`,
[dto.schemeId, dto.userId, dto.baseAmount, tenantId],
);
const { rate_applied, commission_amount } = calcResult[0] || { rate_applied: 0, commission_amount: 0 };
if (commission_amount === 0) {
throw new BadRequestException('Commission calculation resulted in zero amount. Check scheme configuration.');
}
const entry = this.entryRepo.create({
tenantId,
userId: dto.userId,
schemeId: dto.schemeId,
assignmentId: dto.assignmentId,
referenceType: dto.referenceType,
referenceId: dto.referenceId,
baseAmount: dto.baseAmount,
rateApplied: rate_applied,
commissionAmount: commission_amount,
currency: dto.currency ?? 'USD',
notes: dto.notes,
metadata: dto.metadata ?? {},
});
const saved = await this.entryRepo.save(entry);
this.logger.log(`Commission entry created: ${saved.id} for user ${dto.userId}`);
return this.toResponse(saved);
}
async findAll(tenantId: string, query: EntryListQueryDto): Promise<PaginatedEntriesDto> {
const page = query.page || 1;
const limit = Math.min(query.limit || 20, 100);
const skip = (page - 1) * limit;
const qb = this.entryRepo
.createQueryBuilder('e')
.leftJoinAndSelect('e.scheme', 'scheme')
.leftJoinAndSelect('e.period', 'period')
.where('e.tenant_id = :tenantId', { tenantId });
if (query.userId) {
qb.andWhere('e.user_id = :userId', { userId: query.userId });
}
if (query.schemeId) {
qb.andWhere('e.scheme_id = :schemeId', { schemeId: query.schemeId });
}
if (query.periodId) {
qb.andWhere('e.period_id = :periodId', { periodId: query.periodId });
}
if (query.status) {
qb.andWhere('e.status = :status', { status: query.status });
}
if (query.referenceType) {
qb.andWhere('e.reference_type = :referenceType', { referenceType: query.referenceType });
}
qb.orderBy('e.created_at', 'DESC').skip(skip).take(limit);
const [items, total] = await qb.getManyAndCount();
return {
items: items.map((e) => this.toResponse(e)),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(tenantId: string, entryId: string): Promise<EntryResponseDto> {
const entry = await this.entryRepo.findOne({
where: { id: entryId, tenantId },
relations: ['scheme', 'period'],
});
if (!entry) {
throw new NotFoundException('Commission entry not found');
}
return this.toResponse(entry);
}
async update(
tenantId: string,
entryId: string,
dto: UpdateEntryDto,
): Promise<EntryResponseDto> {
const entry = await this.entryRepo.findOne({
where: { id: entryId, tenantId },
});
if (!entry) {
throw new NotFoundException('Commission entry not found');
}
Object.assign(entry, {
status: dto.status ?? entry.status,
periodId: dto.periodId ?? entry.periodId,
notes: dto.notes ?? entry.notes,
metadata: dto.metadata ?? entry.metadata,
});
const saved = await this.entryRepo.save(entry);
this.logger.log(`Commission entry updated: ${saved.id}`);
return this.toResponse(saved);
}
async approve(
tenantId: string,
entryId: string,
approvedBy: string,
dto: ApproveEntryDto,
): Promise<EntryResponseDto> {
const entry = await this.entryRepo.findOne({
where: { id: entryId, tenantId },
});
if (!entry) {
throw new NotFoundException('Commission entry not found');
}
if (entry.status !== EntryStatus.PENDING) {
throw new BadRequestException('Only pending entries can be approved');
}
entry.status = EntryStatus.APPROVED;
entry.approvedBy = approvedBy;
entry.approvedAt = new Date();
entry.notes = dto.notes ?? entry.notes;
const saved = await this.entryRepo.save(entry);
this.logger.log(`Commission entry approved: ${saved.id}`);
return this.toResponse(saved);
}
async reject(
tenantId: string,
entryId: string,
rejectedBy: string,
dto: RejectEntryDto,
): Promise<EntryResponseDto> {
const entry = await this.entryRepo.findOne({
where: { id: entryId, tenantId },
});
if (!entry) {
throw new NotFoundException('Commission entry not found');
}
if (entry.status !== EntryStatus.PENDING) {
throw new BadRequestException('Only pending entries can be rejected');
}
entry.status = EntryStatus.REJECTED;
entry.approvedBy = rejectedBy;
entry.approvedAt = new Date();
entry.notes = dto.notes;
const saved = await this.entryRepo.save(entry);
this.logger.log(`Commission entry rejected: ${saved.id}`);
return this.toResponse(saved);
}
async calculateCommission(
tenantId: string,
dto: CalculateCommissionDto,
): Promise<CalculateCommissionResponseDto> {
const result = await this.dataSource.query(
`SELECT * FROM commissions.calculate_commission($1, $2, $3, $4)`,
[dto.schemeId, dto.userId, dto.amount, tenantId],
);
const { rate_applied, commission_amount } = result[0] || { rate_applied: 0, commission_amount: 0 };
return {
rateApplied: Number(rate_applied),
commissionAmount: Number(commission_amount),
};
}
async markAsPaid(tenantId: string, entryId: string, paymentReference: string): Promise<EntryResponseDto> {
const entry = await this.entryRepo.findOne({
where: { id: entryId, tenantId },
});
if (!entry) {
throw new NotFoundException('Commission entry not found');
}
if (entry.status !== EntryStatus.APPROVED) {
throw new BadRequestException('Only approved entries can be marked as paid');
}
entry.status = EntryStatus.PAID;
entry.paidAt = new Date();
entry.paymentReference = paymentReference;
const saved = await this.entryRepo.save(entry);
this.logger.log(`Commission entry marked as paid: ${saved.id}`);
return this.toResponse(saved);
}
async bulkApprove(tenantId: string, entryIds: string[], approvedBy: string): Promise<number> {
const result = await this.entryRepo
.createQueryBuilder()
.update()
.set({
status: EntryStatus.APPROVED,
approvedBy,
approvedAt: new Date(),
})
.where('id IN (:...entryIds)', { entryIds })
.andWhere('tenant_id = :tenantId', { tenantId })
.andWhere('status = :status', { status: EntryStatus.PENDING })
.execute();
this.logger.log(`Bulk approved ${result.affected} commission entries`);
return result.affected || 0;
}
private toResponse(entry: CommissionEntryEntity): EntryResponseDto {
return {
id: entry.id,
tenantId: entry.tenantId,
userId: entry.userId,
schemeId: entry.schemeId,
assignmentId: entry.assignmentId,
referenceType: entry.referenceType,
referenceId: entry.referenceId,
baseAmount: Number(entry.baseAmount),
rateApplied: Number(entry.rateApplied),
commissionAmount: Number(entry.commissionAmount),
currency: entry.currency,
status: entry.status,
periodId: entry.periodId,
paidAt: entry.paidAt,
paymentReference: entry.paymentReference,
notes: entry.notes,
metadata: entry.metadata,
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
approvedBy: entry.approvedBy,
approvedAt: entry.approvedAt,
scheme: entry.scheme,
period: entry.period,
};
}
}

View File

@ -0,0 +1,5 @@
export * from './schemes.service';
export * from './assignments.service';
export * from './entries.service';
export * from './periods.service';
export * from './commissions-dashboard.service';

View File

@ -0,0 +1,263 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { CommissionPeriodEntity, PeriodStatus, CommissionEntryEntity, EntryStatus } from '../entities';
import {
CreatePeriodDto,
UpdatePeriodDto,
ClosePeriodDto,
MarkPaidDto,
PeriodResponseDto,
PeriodListQueryDto,
PaginatedPeriodsDto,
} from '../dto';
@Injectable()
export class PeriodsService {
private readonly logger = new Logger(PeriodsService.name);
constructor(
@InjectRepository(CommissionPeriodEntity)
private readonly periodRepo: Repository<CommissionPeriodEntity>,
@InjectRepository(CommissionEntryEntity)
private readonly entryRepo: Repository<CommissionEntryEntity>,
private readonly dataSource: DataSource,
) {}
async create(tenantId: string, userId: string, dto: CreatePeriodDto): Promise<PeriodResponseDto> {
const period = this.periodRepo.create({
tenantId,
name: dto.name,
startsAt: new Date(dto.startsAt),
endsAt: new Date(dto.endsAt),
currency: dto.currency ?? 'USD',
createdBy: userId,
});
const saved = await this.periodRepo.save(period);
this.logger.log(`Commission period created: ${saved.id} for tenant ${tenantId}`);
return this.toResponse(saved);
}
async findAll(tenantId: string, query: PeriodListQueryDto): Promise<PaginatedPeriodsDto> {
const page = query.page || 1;
const limit = Math.min(query.limit || 20, 100);
const skip = (page - 1) * limit;
const qb = this.periodRepo
.createQueryBuilder('p')
.where('p.tenant_id = :tenantId', { tenantId });
if (query.status) {
qb.andWhere('p.status = :status', { status: query.status });
}
qb.orderBy('p.starts_at', 'DESC').skip(skip).take(limit);
const [items, total] = await qb.getManyAndCount();
return {
items: items.map((p) => this.toResponse(p)),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(tenantId: string, periodId: string): Promise<PeriodResponseDto> {
const period = await this.periodRepo.findOne({
where: { id: periodId, tenantId },
});
if (!period) {
throw new NotFoundException('Commission period not found');
}
return this.toResponse(period);
}
async update(
tenantId: string,
periodId: string,
dto: UpdatePeriodDto,
): Promise<PeriodResponseDto> {
const period = await this.periodRepo.findOne({
where: { id: periodId, tenantId },
});
if (!period) {
throw new NotFoundException('Commission period not found');
}
if (period.status !== PeriodStatus.OPEN) {
throw new BadRequestException('Only open periods can be updated');
}
Object.assign(period, {
name: dto.name ?? period.name,
startsAt: dto.startsAt ? new Date(dto.startsAt) : period.startsAt,
endsAt: dto.endsAt ? new Date(dto.endsAt) : period.endsAt,
});
const saved = await this.periodRepo.save(period);
this.logger.log(`Commission period updated: ${saved.id}`);
return this.toResponse(saved);
}
async close(
tenantId: string,
periodId: string,
userId: string,
dto: ClosePeriodDto,
): Promise<PeriodResponseDto> {
const period = await this.periodRepo.findOne({
where: { id: periodId, tenantId },
});
if (!period) {
throw new NotFoundException('Commission period not found');
}
if (period.status !== PeriodStatus.OPEN) {
throw new BadRequestException('Only open periods can be closed');
}
// Use database function to close period
await this.dataSource.query(
`SELECT commissions.close_period($1, $2)`,
[periodId, userId],
);
// Reload the period
const updated = await this.periodRepo.findOne({ where: { id: periodId } });
this.logger.log(`Commission period closed: ${periodId}`);
return this.toResponse(updated!);
}
async markAsPaid(
tenantId: string,
periodId: string,
userId: string,
dto: MarkPaidDto,
): Promise<PeriodResponseDto> {
const period = await this.periodRepo.findOne({
where: { id: periodId, tenantId },
});
if (!period) {
throw new NotFoundException('Commission period not found');
}
if (period.status !== PeriodStatus.CLOSED && period.status !== PeriodStatus.PROCESSING) {
throw new BadRequestException('Only closed or processing periods can be marked as paid');
}
// Mark all approved entries in this period as paid
await this.entryRepo
.createQueryBuilder()
.update()
.set({
status: EntryStatus.PAID,
paidAt: new Date(),
paymentReference: dto.paymentReference,
})
.where('period_id = :periodId', { periodId })
.andWhere('status = :status', { status: EntryStatus.APPROVED })
.execute();
// Update period status
period.status = PeriodStatus.PAID;
period.paidAt = new Date();
period.paidBy = userId;
period.paymentReference = dto.paymentReference ?? null;
period.paymentNotes = dto.paymentNotes ?? null;
const saved = await this.periodRepo.save(period);
this.logger.log(`Commission period marked as paid: ${periodId}`);
return this.toResponse(saved);
}
async remove(tenantId: string, periodId: string): Promise<void> {
const period = await this.periodRepo.findOne({
where: { id: periodId, tenantId },
});
if (!period) {
throw new NotFoundException('Commission period not found');
}
if (period.status !== PeriodStatus.OPEN) {
throw new BadRequestException('Only open periods can be deleted');
}
// Check if there are entries in this period
const entriesCount = await this.entryRepo.count({
where: { periodId, tenantId },
});
if (entriesCount > 0) {
throw new BadRequestException(`Cannot delete period with ${entriesCount} entries`);
}
await this.periodRepo.remove(period);
this.logger.log(`Commission period deleted: ${periodId}`);
}
async getCurrentPeriod(tenantId: string): Promise<PeriodResponseDto | null> {
const now = new Date();
const period = await this.periodRepo.findOne({
where: {
tenantId,
status: PeriodStatus.OPEN,
},
order: { startsAt: 'DESC' },
});
return period ? this.toResponse(period) : null;
}
async assignEntriesToPeriod(
tenantId: string,
periodId: string,
entryIds: string[],
): Promise<number> {
const result = await this.entryRepo
.createQueryBuilder()
.update()
.set({ periodId })
.where('id IN (:...entryIds)', { entryIds })
.andWhere('tenant_id = :tenantId', { tenantId })
.andWhere('period_id IS NULL')
.execute();
return result.affected || 0;
}
private toResponse(period: CommissionPeriodEntity): PeriodResponseDto {
return {
id: period.id,
tenantId: period.tenantId,
name: period.name,
startsAt: period.startsAt,
endsAt: period.endsAt,
totalEntries: period.totalEntries,
totalAmount: Number(period.totalAmount),
currency: period.currency,
status: period.status,
closedAt: period.closedAt,
closedBy: period.closedBy,
paidAt: period.paidAt,
paidBy: period.paidBy,
paymentReference: period.paymentReference,
paymentNotes: period.paymentNotes,
createdAt: period.createdAt,
createdBy: period.createdBy,
};
}
}

View File

@ -0,0 +1,195 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CommissionSchemeEntity } from '../entities';
import {
CreateSchemeDto,
UpdateSchemeDto,
SchemeResponseDto,
SchemeListQueryDto,
PaginatedSchemesDto,
} from '../dto';
@Injectable()
export class SchemesService {
private readonly logger = new Logger(SchemesService.name);
constructor(
@InjectRepository(CommissionSchemeEntity)
private readonly schemeRepo: Repository<CommissionSchemeEntity>,
) {}
async create(tenantId: string, userId: string, dto: CreateSchemeDto): Promise<SchemeResponseDto> {
const scheme = this.schemeRepo.create({
tenantId,
name: dto.name,
description: dto.description,
type: dto.type,
rate: dto.rate ?? 0,
fixedAmount: dto.fixedAmount ?? 0,
tiers: dto.tiers ?? [],
appliesTo: dto.appliesTo,
productIds: dto.productIds ?? [],
categoryIds: dto.categoryIds ?? [],
minAmount: dto.minAmount ?? 0,
maxAmount: dto.maxAmount,
isActive: dto.isActive ?? true,
createdBy: userId,
});
const saved = await this.schemeRepo.save(scheme);
this.logger.log(`Commission scheme created: ${saved.id} for tenant ${tenantId}`);
return this.toResponse(saved);
}
async findAll(tenantId: string, query: SchemeListQueryDto): Promise<PaginatedSchemesDto> {
const page = query.page || 1;
const limit = Math.min(query.limit || 20, 100);
const skip = (page - 1) * limit;
const qb = this.schemeRepo
.createQueryBuilder('s')
.where('s.tenant_id = :tenantId', { tenantId })
.andWhere('s.deleted_at IS NULL');
if (query.type) {
qb.andWhere('s.type = :type', { type: query.type });
}
if (query.isActive !== undefined) {
qb.andWhere('s.is_active = :isActive', { isActive: query.isActive });
}
if (query.search) {
qb.andWhere('(s.name ILIKE :search OR s.description ILIKE :search)', {
search: `%${query.search}%`,
});
}
qb.orderBy('s.created_at', 'DESC').skip(skip).take(limit);
const [items, total] = await qb.getManyAndCount();
return {
items: items.map((s) => this.toResponse(s)),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(tenantId: string, schemeId: string): Promise<SchemeResponseDto> {
const scheme = await this.schemeRepo.findOne({
where: { id: schemeId, tenantId, deletedAt: null as any },
});
if (!scheme) {
throw new NotFoundException('Commission scheme not found');
}
return this.toResponse(scheme);
}
async update(
tenantId: string,
schemeId: string,
dto: UpdateSchemeDto,
): Promise<SchemeResponseDto> {
const scheme = await this.schemeRepo.findOne({
where: { id: schemeId, tenantId, deletedAt: null as any },
});
if (!scheme) {
throw new NotFoundException('Commission scheme not found');
}
Object.assign(scheme, {
name: dto.name ?? scheme.name,
description: dto.description ?? scheme.description,
type: dto.type ?? scheme.type,
rate: dto.rate ?? scheme.rate,
fixedAmount: dto.fixedAmount ?? scheme.fixedAmount,
tiers: dto.tiers ?? scheme.tiers,
appliesTo: dto.appliesTo ?? scheme.appliesTo,
productIds: dto.productIds ?? scheme.productIds,
categoryIds: dto.categoryIds ?? scheme.categoryIds,
minAmount: dto.minAmount ?? scheme.minAmount,
maxAmount: dto.maxAmount ?? scheme.maxAmount,
isActive: dto.isActive ?? scheme.isActive,
});
const saved = await this.schemeRepo.save(scheme);
this.logger.log(`Commission scheme updated: ${saved.id}`);
return this.toResponse(saved);
}
async remove(tenantId: string, schemeId: string): Promise<void> {
const scheme = await this.schemeRepo.findOne({
where: { id: schemeId, tenantId, deletedAt: null as any },
});
if (!scheme) {
throw new NotFoundException('Commission scheme not found');
}
scheme.deletedAt = new Date();
await this.schemeRepo.save(scheme);
this.logger.log(`Commission scheme soft-deleted: ${schemeId}`);
}
async activate(tenantId: string, schemeId: string): Promise<SchemeResponseDto> {
const scheme = await this.schemeRepo.findOne({
where: { id: schemeId, tenantId, deletedAt: null as any },
});
if (!scheme) {
throw new NotFoundException('Commission scheme not found');
}
scheme.isActive = true;
const saved = await this.schemeRepo.save(scheme);
return this.toResponse(saved);
}
async deactivate(tenantId: string, schemeId: string): Promise<SchemeResponseDto> {
const scheme = await this.schemeRepo.findOne({
where: { id: schemeId, tenantId, deletedAt: null as any },
});
if (!scheme) {
throw new NotFoundException('Commission scheme not found');
}
scheme.isActive = false;
const saved = await this.schemeRepo.save(scheme);
return this.toResponse(saved);
}
private toResponse(scheme: CommissionSchemeEntity): SchemeResponseDto {
return {
id: scheme.id,
tenantId: scheme.tenantId,
name: scheme.name,
description: scheme.description,
type: scheme.type,
rate: Number(scheme.rate),
fixedAmount: Number(scheme.fixedAmount),
tiers: scheme.tiers,
appliesTo: scheme.appliesTo,
productIds: scheme.productIds,
categoryIds: scheme.categoryIds,
minAmount: Number(scheme.minAmount),
maxAmount: scheme.maxAmount ? Number(scheme.maxAmount) : null,
isActive: scheme.isActive,
createdAt: scheme.createdAt,
updatedAt: scheme.updatedAt,
createdBy: scheme.createdBy,
};
}
}

View File

@ -0,0 +1,92 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
import { ActivitiesService } from '../services';
import {
CreateActivityDto,
UpdateActivityDto,
CompleteActivityDto,
ActivityListQueryDto,
ActivityResponseDto,
PaginatedActivitiesDto,
} from '../dto';
interface RequestUser {
id: string;
tenant_id: string;
email: string;
role: string;
}
@Controller('sales/activities')
@UseGuards(JwtAuthGuard)
export class ActivitiesController {
constructor(private readonly activitiesService: ActivitiesService) {}
@Get()
async findAll(
@CurrentUser() user: RequestUser,
@Query() query: ActivityListQueryDto,
): Promise<PaginatedActivitiesDto> {
return this.activitiesService.findAll(user.tenant_id, query);
}
@Get('upcoming')
async getUpcoming(@CurrentUser() user: RequestUser): Promise<ActivityResponseDto[]> {
return this.activitiesService.getUpcoming(user.tenant_id, user.id);
}
@Get(':id')
async findOne(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<ActivityResponseDto> {
return this.activitiesService.findOne(user.tenant_id, id);
}
@Post()
async create(
@CurrentUser() user: RequestUser,
@Body() dto: CreateActivityDto,
): Promise<ActivityResponseDto> {
return this.activitiesService.create(user.tenant_id, user.id, dto);
}
@Put(':id')
async update(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateActivityDto,
): Promise<ActivityResponseDto> {
return this.activitiesService.update(user.tenant_id, id, dto);
}
@Post(':id/complete')
async complete(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CompleteActivityDto,
): Promise<ActivityResponseDto> {
return this.activitiesService.complete(user.tenant_id, id, dto);
}
@Delete(':id')
async remove(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<{ message: string }> {
await this.activitiesService.remove(user.tenant_id, id);
return { message: 'Activity deleted successfully' };
}
}

View File

@ -0,0 +1,57 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
import { SalesDashboardService } from '../services';
import {
SalesDashboardDto,
LeadsByStatusDto,
LeadsBySourceDto,
OpportunitiesByStageDto,
ConversionFunnelDto,
SalesPerformanceDto,
} from '../dto';
interface RequestUser {
id: string;
tenant_id: string;
email: string;
role: string;
}
@Controller('sales/dashboard')
@UseGuards(JwtAuthGuard)
export class SalesDashboardController {
constructor(private readonly dashboardService: SalesDashboardService) {}
@Get()
async getSummary(@CurrentUser() user: RequestUser): Promise<SalesDashboardDto> {
return this.dashboardService.getDashboardSummary(user.tenant_id);
}
@Get('leads-by-status')
async getLeadsByStatus(@CurrentUser() user: RequestUser): Promise<LeadsByStatusDto[]> {
return this.dashboardService.getLeadsByStatus(user.tenant_id);
}
@Get('leads-by-source')
async getLeadsBySource(@CurrentUser() user: RequestUser): Promise<LeadsBySourceDto[]> {
return this.dashboardService.getLeadsBySource(user.tenant_id);
}
@Get('opportunities-by-stage')
async getOpportunitiesByStage(
@CurrentUser() user: RequestUser,
): Promise<OpportunitiesByStageDto[]> {
return this.dashboardService.getOpportunitiesByStage(user.tenant_id);
}
@Get('conversion-funnel')
async getConversionFunnel(@CurrentUser() user: RequestUser): Promise<ConversionFunnelDto[]> {
return this.dashboardService.getConversionFunnel(user.tenant_id);
}
@Get('sales-performance')
async getSalesPerformance(@CurrentUser() user: RequestUser): Promise<SalesPerformanceDto[]> {
return this.dashboardService.getSalesPerformance(user.tenant_id);
}
}

View File

@ -0,0 +1,5 @@
export * from './leads.controller';
export * from './opportunities.controller';
export * from './activities.controller';
export * from './pipeline.controller';
export * from './dashboard.controller';

View File

@ -0,0 +1,104 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
import { LeadsService } from '../services';
import {
CreateLeadDto,
UpdateLeadDto,
ConvertLeadDto,
LeadListQueryDto,
LeadResponseDto,
PaginatedLeadsDto,
} from '../dto';
interface RequestUser {
id: string;
tenant_id: string;
email: string;
role: string;
}
@Controller('sales/leads')
@UseGuards(JwtAuthGuard)
export class LeadsController {
constructor(private readonly leadsService: LeadsService) {}
@Get()
async findAll(
@CurrentUser() user: RequestUser,
@Query() query: LeadListQueryDto,
): Promise<PaginatedLeadsDto> {
return this.leadsService.findAll(user.tenant_id, query);
}
@Get(':id')
async findOne(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<LeadResponseDto> {
return this.leadsService.findOne(user.tenant_id, id);
}
@Post()
async create(
@CurrentUser() user: RequestUser,
@Body() dto: CreateLeadDto,
): Promise<LeadResponseDto> {
return this.leadsService.create(user.tenant_id, user.id, dto);
}
@Put(':id')
async update(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateLeadDto,
): Promise<LeadResponseDto> {
return this.leadsService.update(user.tenant_id, id, dto);
}
@Delete(':id')
async remove(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<{ message: string }> {
await this.leadsService.remove(user.tenant_id, id);
return { message: 'Lead deleted successfully' };
}
@Post(':id/convert')
async convert(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ConvertLeadDto,
): Promise<{ opportunityId: string }> {
return this.leadsService.convert(user.tenant_id, id, dto);
}
@Post(':id/calculate-score')
async calculateScore(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<{ score: number }> {
return this.leadsService.calculateScore(user.tenant_id, id);
}
@Put(':id/assign/:userId')
async assign(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Param('userId', ParseUUIDPipe) userId: string,
): Promise<LeadResponseDto> {
return this.leadsService.assignTo(user.tenant_id, id, userId);
}
}

View File

@ -0,0 +1,102 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
import { OpportunitiesService } from '../services';
import {
CreateOpportunityDto,
UpdateOpportunityDto,
UpdateOpportunityStageDto,
OpportunityListQueryDto,
OpportunityResponseDto,
PaginatedOpportunitiesDto,
PipelineSummaryDto,
} from '../dto';
interface RequestUser {
id: string;
tenant_id: string;
email: string;
role: string;
}
@Controller('sales/opportunities')
@UseGuards(JwtAuthGuard)
export class OpportunitiesController {
constructor(private readonly opportunitiesService: OpportunitiesService) {}
@Get()
async findAll(
@CurrentUser() user: RequestUser,
@Query() query: OpportunityListQueryDto,
): Promise<PaginatedOpportunitiesDto> {
return this.opportunitiesService.findAll(user.tenant_id, query);
}
@Get('pipeline-summary')
async getPipelineSummary(@CurrentUser() user: RequestUser): Promise<PipelineSummaryDto[]> {
return this.opportunitiesService.getPipelineSummary(user.tenant_id);
}
@Get(':id')
async findOne(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<OpportunityResponseDto> {
return this.opportunitiesService.findOne(user.tenant_id, id);
}
@Post()
async create(
@CurrentUser() user: RequestUser,
@Body() dto: CreateOpportunityDto,
): Promise<OpportunityResponseDto> {
return this.opportunitiesService.create(user.tenant_id, user.id, dto);
}
@Put(':id')
async update(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateOpportunityDto,
): Promise<OpportunityResponseDto> {
return this.opportunitiesService.update(user.tenant_id, id, dto);
}
@Put(':id/stage')
async updateStage(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateOpportunityStageDto,
): Promise<OpportunityResponseDto> {
return this.opportunitiesService.updateStage(user.tenant_id, id, dto);
}
@Delete(':id')
async remove(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<{ message: string }> {
await this.opportunitiesService.remove(user.tenant_id, id);
return { message: 'Opportunity deleted successfully' };
}
@Put(':id/assign/:userId')
async assign(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Param('userId', ParseUUIDPipe) userId: string,
): Promise<OpportunityResponseDto> {
return this.opportunitiesService.assignTo(user.tenant_id, id, userId);
}
}

View File

@ -0,0 +1,86 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
import { PipelineService } from '../services';
import {
CreatePipelineStageDto,
UpdatePipelineStageDto,
ReorderStagesDto,
PipelineStageResponseDto,
} from '../dto';
interface RequestUser {
id: string;
tenant_id: string;
email: string;
role: string;
}
@Controller('sales/pipeline')
@UseGuards(JwtAuthGuard)
export class PipelineController {
constructor(private readonly pipelineService: PipelineService) {}
@Get('stages')
async findAll(@CurrentUser() user: RequestUser): Promise<PipelineStageResponseDto[]> {
return this.pipelineService.findAll(user.tenant_id);
}
@Get('stages/:id')
async findOne(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<PipelineStageResponseDto> {
return this.pipelineService.findOne(user.tenant_id, id);
}
@Post('stages')
async create(
@CurrentUser() user: RequestUser,
@Body() dto: CreatePipelineStageDto,
): Promise<PipelineStageResponseDto> {
return this.pipelineService.create(user.tenant_id, dto);
}
@Put('stages/:id')
async update(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePipelineStageDto,
): Promise<PipelineStageResponseDto> {
return this.pipelineService.update(user.tenant_id, id, dto);
}
@Delete('stages/:id')
async remove(
@CurrentUser() user: RequestUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<{ message: string }> {
await this.pipelineService.remove(user.tenant_id, id);
return { message: 'Pipeline stage deleted successfully' };
}
@Post('stages/reorder')
async reorder(
@CurrentUser() user: RequestUser,
@Body() dto: ReorderStagesDto,
): Promise<PipelineStageResponseDto[]> {
return this.pipelineService.reorder(user.tenant_id, dto);
}
@Post('initialize-defaults')
async initializeDefaults(@CurrentUser() user: RequestUser): Promise<{ message: string }> {
await this.pipelineService.initializeDefaults(user.tenant_id);
return { message: 'Default pipeline stages initialized' };
}
}

View File

@ -0,0 +1,232 @@
import {
IsString,
IsOptional,
IsEnum,
IsInt,
IsUUID,
IsObject,
IsArray,
MaxLength,
Min,
IsBoolean,
IsDateString,
} from 'class-validator';
import { ActivityType, ActivityStatus } from '../entities';
export class CreateActivityDto {
@IsEnum(ActivityType)
type: ActivityType;
@IsString()
@MaxLength(255)
subject: string;
@IsString()
@IsOptional()
description?: string;
@IsUUID()
@IsOptional()
leadId?: string;
@IsUUID()
@IsOptional()
opportunityId?: string;
@IsDateString()
@IsOptional()
dueDate?: string;
@IsString()
@IsOptional()
dueTime?: string;
@IsInt()
@Min(0)
@IsOptional()
durationMinutes?: number;
@IsUUID()
@IsOptional()
assignedTo?: string;
@IsString()
@MaxLength(10)
@IsOptional()
callDirection?: string;
@IsString()
@MaxLength(500)
@IsOptional()
callRecordingUrl?: string;
@IsString()
@MaxLength(255)
@IsOptional()
location?: string;
@IsString()
@MaxLength(500)
@IsOptional()
meetingUrl?: string;
@IsArray()
@IsOptional()
attendees?: any[];
@IsDateString()
@IsOptional()
reminderAt?: string;
@IsObject()
@IsOptional()
customFields?: Record<string, any>;
}
export class UpdateActivityDto {
@IsEnum(ActivityType)
@IsOptional()
type?: ActivityType;
@IsEnum(ActivityStatus)
@IsOptional()
status?: ActivityStatus;
@IsString()
@MaxLength(255)
@IsOptional()
subject?: string;
@IsString()
@IsOptional()
description?: string;
@IsDateString()
@IsOptional()
dueDate?: string;
@IsString()
@IsOptional()
dueTime?: string;
@IsInt()
@Min(0)
@IsOptional()
durationMinutes?: number;
@IsString()
@IsOptional()
outcome?: string;
@IsUUID()
@IsOptional()
assignedTo?: string;
@IsString()
@MaxLength(10)
@IsOptional()
callDirection?: string;
@IsString()
@MaxLength(500)
@IsOptional()
callRecordingUrl?: string;
@IsString()
@MaxLength(255)
@IsOptional()
location?: string;
@IsString()
@MaxLength(500)
@IsOptional()
meetingUrl?: string;
@IsArray()
@IsOptional()
attendees?: any[];
@IsDateString()
@IsOptional()
reminderAt?: string;
@IsBoolean()
@IsOptional()
reminderSent?: boolean;
@IsObject()
@IsOptional()
customFields?: Record<string, any>;
}
export class CompleteActivityDto {
@IsString()
@IsOptional()
outcome?: string;
}
export class ActivityResponseDto {
id: string;
tenantId: string;
type: ActivityType;
status: ActivityStatus;
subject: string;
description: string | null;
leadId: string | null;
opportunityId: string | null;
dueDate: Date | null;
dueTime: string | null;
durationMinutes: number | null;
completedAt: Date | null;
outcome: string | null;
assignedTo: string | null;
createdBy: string | null;
callDirection: string | null;
callRecordingUrl: string | null;
location: string | null;
meetingUrl: string | null;
attendees: any[];
reminderAt: Date | null;
reminderSent: boolean;
customFields: Record<string, any>;
createdAt: Date;
updatedAt: Date;
lead?: any;
opportunity?: any;
}
export class ActivityListQueryDto {
@IsEnum(ActivityType)
@IsOptional()
type?: ActivityType;
@IsEnum(ActivityStatus)
@IsOptional()
status?: ActivityStatus;
@IsUUID()
@IsOptional()
leadId?: string;
@IsUUID()
@IsOptional()
opportunityId?: string;
@IsUUID()
@IsOptional()
assignedTo?: string;
@IsOptional()
page?: number;
@IsOptional()
limit?: number;
}
export class PaginatedActivitiesDto {
items: ActivityResponseDto[];
total: number;
page: number;
limit: number;
totalPages: number;
}

View File

@ -0,0 +1,57 @@
import { OpportunityStage, LeadStatus, LeadSource } from '../entities';
export class SalesDashboardDto {
totalLeads: number;
totalOpportunities: number;
totalPipelineValue: number;
wonDeals: number;
conversionRate: number;
averageDealSize: number;
activitiesThisWeek: number;
currency: string;
}
export class LeadsByStatusDto {
status: LeadStatus;
count: number;
percentage: number;
}
export class LeadsBySourceDto {
source: LeadSource;
count: number;
percentage: number;
}
export class OpportunitiesByStageDto {
stage: OpportunityStage;
count: number;
totalAmount: number;
percentage: number;
}
export class ConversionFunnelDto {
stage: string;
count: number;
percentage: number;
conversionFromPrevious: number;
}
export class SalesPerformanceDto {
userId: string;
userName: string;
leadsAssigned: number;
opportunitiesWon: number;
totalRevenue: number;
conversionRate: number;
activitiesCompleted: number;
}
export class TimelineActivityDto {
date: string;
leadsCreated: number;
opportunitiesCreated: number;
activitiesCompleted: number;
dealsWon: number;
dealsLost: number;
}

View File

@ -0,0 +1,5 @@
export * from './lead.dto';
export * from './opportunity.dto';
export * from './activity.dto';
export * from './pipeline.dto';
export * from './dashboard.dto';

View File

@ -0,0 +1,273 @@
import {
IsString,
IsEmail,
IsOptional,
IsEnum,
IsInt,
IsUUID,
IsObject,
MaxLength,
Min,
Max,
IsNumber,
} from 'class-validator';
import { LeadStatus, LeadSource } from '../entities';
export class CreateLeadDto {
@IsString()
@MaxLength(100)
firstName: string;
@IsString()
@MaxLength(100)
lastName: string;
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@MaxLength(50)
@IsOptional()
phone?: string;
@IsString()
@MaxLength(200)
@IsOptional()
company?: string;
@IsString()
@MaxLength(150)
@IsOptional()
jobTitle?: string;
@IsString()
@MaxLength(255)
@IsOptional()
website?: string;
@IsEnum(LeadSource)
@IsOptional()
source?: LeadSource;
@IsEnum(LeadStatus)
@IsOptional()
status?: LeadStatus;
@IsInt()
@Min(0)
@Max(100)
@IsOptional()
score?: number;
@IsUUID()
@IsOptional()
assignedTo?: string;
@IsString()
@IsOptional()
notes?: string;
@IsString()
@MaxLength(255)
@IsOptional()
addressLine1?: string;
@IsString()
@MaxLength(255)
@IsOptional()
addressLine2?: string;
@IsString()
@MaxLength(100)
@IsOptional()
city?: string;
@IsString()
@MaxLength(100)
@IsOptional()
state?: string;
@IsString()
@MaxLength(20)
@IsOptional()
postalCode?: string;
@IsString()
@MaxLength(100)
@IsOptional()
country?: string;
@IsObject()
@IsOptional()
customFields?: Record<string, any>;
}
export class UpdateLeadDto {
@IsString()
@MaxLength(100)
@IsOptional()
firstName?: string;
@IsString()
@MaxLength(100)
@IsOptional()
lastName?: string;
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@MaxLength(50)
@IsOptional()
phone?: string;
@IsString()
@MaxLength(200)
@IsOptional()
company?: string;
@IsString()
@MaxLength(150)
@IsOptional()
jobTitle?: string;
@IsString()
@MaxLength(255)
@IsOptional()
website?: string;
@IsEnum(LeadSource)
@IsOptional()
source?: LeadSource;
@IsEnum(LeadStatus)
@IsOptional()
status?: LeadStatus;
@IsInt()
@Min(0)
@Max(100)
@IsOptional()
score?: number;
@IsUUID()
@IsOptional()
assignedTo?: string;
@IsString()
@IsOptional()
notes?: string;
@IsString()
@MaxLength(255)
@IsOptional()
addressLine1?: string;
@IsString()
@MaxLength(255)
@IsOptional()
addressLine2?: string;
@IsString()
@MaxLength(100)
@IsOptional()
city?: string;
@IsString()
@MaxLength(100)
@IsOptional()
state?: string;
@IsString()
@MaxLength(20)
@IsOptional()
postalCode?: string;
@IsString()
@MaxLength(100)
@IsOptional()
country?: string;
@IsObject()
@IsOptional()
customFields?: Record<string, any>;
}
export class ConvertLeadDto {
@IsString()
@MaxLength(255)
@IsOptional()
opportunityName?: string;
@IsNumber()
@Min(0)
@IsOptional()
amount?: number;
@IsOptional()
expectedCloseDate?: string;
}
export class LeadResponseDto {
id: string;
tenantId: string;
firstName: string;
lastName: string;
fullName: string;
email: string | null;
phone: string | null;
company: string | null;
jobTitle: string | null;
website: string | null;
source: LeadSource;
status: LeadStatus;
score: number;
assignedTo: string | null;
notes: string | null;
convertedAt: Date | null;
convertedToOpportunityId: string | null;
addressLine1: string | null;
addressLine2: string | null;
city: string | null;
state: string | null;
postalCode: string | null;
country: string | null;
customFields: Record<string, any>;
createdAt: Date;
updatedAt: Date;
createdBy: string | null;
}
export class LeadListQueryDto {
@IsEnum(LeadStatus)
@IsOptional()
status?: LeadStatus;
@IsEnum(LeadSource)
@IsOptional()
source?: LeadSource;
@IsUUID()
@IsOptional()
assignedTo?: string;
@IsString()
@IsOptional()
search?: string;
@IsOptional()
page?: number;
@IsOptional()
limit?: number;
}
export class PaginatedLeadsDto {
items: LeadResponseDto[];
total: number;
page: number;
limit: number;
totalPages: number;
}

View File

@ -0,0 +1,241 @@
import {
IsString,
IsEmail,
IsOptional,
IsEnum,
IsInt,
IsUUID,
IsObject,
MaxLength,
Min,
Max,
IsNumber,
IsDateString,
} from 'class-validator';
import { OpportunityStage } from '../entities';
export class CreateOpportunityDto {
@IsString()
@MaxLength(255)
name: string;
@IsString()
@IsOptional()
description?: string;
@IsUUID()
@IsOptional()
leadId?: string;
@IsEnum(OpportunityStage)
@IsOptional()
stage?: OpportunityStage;
@IsUUID()
@IsOptional()
stageId?: string;
@IsNumber()
@Min(0)
@IsOptional()
amount?: number;
@IsString()
@MaxLength(3)
@IsOptional()
currency?: string;
@IsInt()
@Min(0)
@Max(100)
@IsOptional()
probability?: number;
@IsDateString()
@IsOptional()
expectedCloseDate?: string;
@IsUUID()
@IsOptional()
assignedTo?: string;
@IsString()
@MaxLength(200)
@IsOptional()
contactName?: string;
@IsEmail()
@IsOptional()
contactEmail?: string;
@IsString()
@MaxLength(50)
@IsOptional()
contactPhone?: string;
@IsString()
@MaxLength(200)
@IsOptional()
companyName?: string;
@IsString()
@IsOptional()
notes?: string;
@IsObject()
@IsOptional()
customFields?: Record<string, any>;
}
export class UpdateOpportunityDto {
@IsString()
@MaxLength(255)
@IsOptional()
name?: string;
@IsString()
@IsOptional()
description?: string;
@IsEnum(OpportunityStage)
@IsOptional()
stage?: OpportunityStage;
@IsUUID()
@IsOptional()
stageId?: string;
@IsNumber()
@Min(0)
@IsOptional()
amount?: number;
@IsString()
@MaxLength(3)
@IsOptional()
currency?: string;
@IsInt()
@Min(0)
@Max(100)
@IsOptional()
probability?: number;
@IsDateString()
@IsOptional()
expectedCloseDate?: string;
@IsUUID()
@IsOptional()
assignedTo?: string;
@IsString()
@MaxLength(500)
@IsOptional()
lostReason?: string;
@IsString()
@MaxLength(200)
@IsOptional()
contactName?: string;
@IsEmail()
@IsOptional()
contactEmail?: string;
@IsString()
@MaxLength(50)
@IsOptional()
contactPhone?: string;
@IsString()
@MaxLength(200)
@IsOptional()
companyName?: string;
@IsString()
@IsOptional()
notes?: string;
@IsObject()
@IsOptional()
customFields?: Record<string, any>;
}
export class UpdateOpportunityStageDto {
@IsEnum(OpportunityStage)
stage: OpportunityStage;
@IsString()
@IsOptional()
notes?: string;
}
export class OpportunityResponseDto {
id: string;
tenantId: string;
name: string;
description: string | null;
leadId: string | null;
stage: OpportunityStage;
stageId: string | null;
amount: number;
currency: string;
probability: number;
expectedCloseDate: Date | null;
actualCloseDate: Date | null;
assignedTo: string | null;
wonAt: Date | null;
lostAt: Date | null;
lostReason: string | null;
contactName: string | null;
contactEmail: string | null;
contactPhone: string | null;
companyName: string | null;
notes: string | null;
customFields: Record<string, any>;
createdAt: Date;
updatedAt: Date;
createdBy: string | null;
lead?: any;
pipelineStage?: any;
}
export class OpportunityListQueryDto {
@IsEnum(OpportunityStage)
@IsOptional()
stage?: OpportunityStage;
@IsUUID()
@IsOptional()
stageId?: string;
@IsUUID()
@IsOptional()
assignedTo?: string;
@IsString()
@IsOptional()
search?: string;
@IsOptional()
page?: number;
@IsOptional()
limit?: number;
}
export class PaginatedOpportunitiesDto {
items: OpportunityResponseDto[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class PipelineSummaryDto {
stage: OpportunityStage;
count: number;
totalAmount: number;
avgProbability: number;
}

View File

@ -0,0 +1,85 @@
import {
IsString,
IsOptional,
IsInt,
IsBoolean,
MaxLength,
Min,
} from 'class-validator';
export class CreatePipelineStageDto {
@IsString()
@MaxLength(100)
name: string;
@IsInt()
@Min(0)
@IsOptional()
position?: number;
@IsString()
@MaxLength(7)
@IsOptional()
color?: string;
@IsBoolean()
@IsOptional()
isWon?: boolean;
@IsBoolean()
@IsOptional()
isLost?: boolean;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
export class UpdatePipelineStageDto {
@IsString()
@MaxLength(100)
@IsOptional()
name?: string;
@IsInt()
@Min(0)
@IsOptional()
position?: number;
@IsString()
@MaxLength(7)
@IsOptional()
color?: string;
@IsBoolean()
@IsOptional()
isWon?: boolean;
@IsBoolean()
@IsOptional()
isLost?: boolean;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
export class ReorderStagesDto {
@IsString({ each: true })
stageIds: string[];
}
export class PipelineStageResponseDto {
id: string;
tenantId: string;
name: string;
position: number;
color: string;
isWon: boolean;
isLost: boolean;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
opportunityCount?: number;
totalAmount?: number;
}

View File

@ -0,0 +1,123 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { LeadEntity } from './lead.entity';
import { OpportunityEntity } from './opportunity.entity';
export enum ActivityType {
CALL = 'call',
MEETING = 'meeting',
TASK = 'task',
EMAIL = 'email',
NOTE = 'note',
}
export enum ActivityStatus {
PENDING = 'pending',
COMPLETED = 'completed',
CANCELLED = 'cancelled',
}
@Entity({ schema: 'sales', name: 'activities' })
export class ActivityEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({
type: 'enum',
enum: ActivityType,
enumName: 'activity_type',
})
type: ActivityType;
@Column({
type: 'enum',
enum: ActivityStatus,
enumName: 'activity_status',
default: ActivityStatus.PENDING,
})
status: ActivityStatus;
@Column({ length: 255 })
subject: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ name: 'lead_id', type: 'uuid', nullable: true })
leadId: string;
@Column({ name: 'opportunity_id', type: 'uuid', nullable: true })
opportunityId: string;
@Column({ name: 'due_date', type: 'timestamptz', nullable: true })
dueDate: Date;
@Column({ name: 'due_time', type: 'time', nullable: true })
dueTime: string;
@Column({ name: 'duration_minutes', type: 'int', nullable: true })
durationMinutes: number;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt: Date;
@Column({ type: 'text', nullable: true })
outcome: string;
@Column({ name: 'assigned_to', type: 'uuid', nullable: true })
assignedTo: string;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@Column({ name: 'call_direction', length: 10, nullable: true })
callDirection: string;
@Column({ name: 'call_recording_url', length: 500, nullable: true })
callRecordingUrl: string;
@Column({ length: 255, nullable: true })
location: string;
@Column({ name: 'meeting_url', length: 500, nullable: true })
meetingUrl: string;
@Column({ type: 'jsonb', default: [] })
attendees: any[];
@Column({ name: 'reminder_at', type: 'timestamptz', nullable: true })
reminderAt: Date;
@Column({ name: 'reminder_sent', default: false })
reminderSent: boolean;
@Column({ name: 'custom_fields', type: 'jsonb', default: {} })
customFields: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
@ManyToOne(() => LeadEntity, (lead) => lead.activities)
@JoinColumn({ name: 'lead_id' })
lead: LeadEntity;
@ManyToOne(() => OpportunityEntity, (opportunity) => opportunity.activities)
@JoinColumn({ name: 'opportunity_id' })
opportunity: OpportunityEntity;
}

View File

@ -0,0 +1,4 @@
export * from './pipeline-stage.entity';
export * from './lead.entity';
export * from './opportunity.entity';
export * from './activity.entity';

View File

@ -0,0 +1,128 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
export enum LeadStatus {
NEW = 'new',
CONTACTED = 'contacted',
QUALIFIED = 'qualified',
UNQUALIFIED = 'unqualified',
CONVERTED = 'converted',
}
export enum LeadSource {
WEBSITE = 'website',
REFERRAL = 'referral',
COLD_CALL = 'cold_call',
EVENT = 'event',
ADVERTISEMENT = 'advertisement',
SOCIAL_MEDIA = 'social_media',
OTHER = 'other',
}
@Entity({ schema: 'sales', name: 'leads' })
export class LeadEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'first_name', length: 100 })
firstName: string;
@Column({ name: 'last_name', length: 100 })
lastName: string;
@Column({ length: 255, nullable: true })
email: string;
@Column({ length: 50, nullable: true })
phone: string;
@Column({ length: 200, nullable: true })
company: string;
@Column({ name: 'job_title', length: 150, nullable: true })
jobTitle: string;
@Column({ length: 255, nullable: true })
website: string;
@Column({
type: 'enum',
enum: LeadSource,
enumName: 'lead_source',
default: LeadSource.OTHER,
})
source: LeadSource;
@Column({
type: 'enum',
enum: LeadStatus,
enumName: 'lead_status',
default: LeadStatus.NEW,
})
status: LeadStatus;
@Column({ type: 'int', default: 0 })
score: number;
@Column({ name: 'assigned_to', type: 'uuid', nullable: true })
assignedTo: string;
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ name: 'converted_at', type: 'timestamptz', nullable: true })
convertedAt: Date;
@Column({ name: 'converted_to_opportunity_id', type: 'uuid', nullable: true })
convertedToOpportunityId: string;
@Column({ name: 'address_line1', length: 255, nullable: true })
addressLine1: string;
@Column({ name: 'address_line2', length: 255, nullable: true })
addressLine2: string;
@Column({ length: 100, nullable: true })
city: string;
@Column({ length: 100, nullable: true })
state: string;
@Column({ name: 'postal_code', length: 20, nullable: true })
postalCode: string;
@Column({ length: 100, nullable: true })
country: string;
@Column({ name: 'custom_fields', type: 'jsonb', default: {} })
customFields: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
@OneToMany('ActivityEntity', 'lead')
activities: any[];
@OneToMany('OpportunityEntity', 'lead')
opportunities: any[];
}

View File

@ -0,0 +1,118 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
import { LeadEntity } from './lead.entity';
import { PipelineStageEntity } from './pipeline-stage.entity';
export enum OpportunityStage {
PROSPECTING = 'prospecting',
QUALIFICATION = 'qualification',
PROPOSAL = 'proposal',
NEGOTIATION = 'negotiation',
CLOSED_WON = 'closed_won',
CLOSED_LOST = 'closed_lost',
}
@Entity({ schema: 'sales', name: 'opportunities' })
export class OpportunityEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ length: 255 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ name: 'lead_id', type: 'uuid', nullable: true })
leadId: string;
@Column({
type: 'enum',
enum: OpportunityStage,
enumName: 'opportunity_stage',
default: OpportunityStage.PROSPECTING,
})
stage: OpportunityStage;
@Column({ name: 'stage_id', type: 'uuid', nullable: true })
stageId: string;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
amount: number;
@Column({ length: 3, default: 'USD' })
currency: string;
@Column({ type: 'int', default: 0 })
probability: number;
@Column({ name: 'expected_close_date', type: 'date', nullable: true })
expectedCloseDate: Date;
@Column({ name: 'actual_close_date', type: 'date', nullable: true })
actualCloseDate: Date;
@Column({ name: 'assigned_to', type: 'uuid', nullable: true })
assignedTo: string;
@Column({ name: 'won_at', type: 'timestamptz', nullable: true })
wonAt: Date;
@Column({ name: 'lost_at', type: 'timestamptz', nullable: true })
lostAt: Date;
@Column({ name: 'lost_reason', length: 500, nullable: true })
lostReason: string;
@Column({ name: 'contact_name', length: 200, nullable: true })
contactName: string;
@Column({ name: 'contact_email', length: 255, nullable: true })
contactEmail: string;
@Column({ name: 'contact_phone', length: 50, nullable: true })
contactPhone: string;
@Column({ name: 'company_name', length: 200, nullable: true })
companyName: string;
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ name: 'custom_fields', type: 'jsonb', default: {} })
customFields: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
@ManyToOne(() => LeadEntity, (lead) => lead.opportunities)
@JoinColumn({ name: 'lead_id' })
lead: LeadEntity;
@ManyToOne(() => PipelineStageEntity, (stage) => stage.opportunities)
@JoinColumn({ name: 'stage_id' })
pipelineStage: PipelineStageEntity;
@OneToMany('ActivityEntity', 'opportunity')
activities: any[];
}

View File

@ -0,0 +1,46 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
@Entity({ schema: 'sales', name: 'pipeline_stages' })
export class PipelineStageEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ length: 100 })
name: string;
@Column({ type: 'int', default: 0 })
position: number;
@Column({ length: 7, default: '#3B82F6' })
color: string;
@Column({ name: 'is_won', default: false })
isWon: boolean;
@Column({ name: 'is_lost', default: false })
isLost: boolean;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@OneToMany('OpportunityEntity', 'pipelineStage')
opportunities: any[];
}

View File

@ -0,0 +1,5 @@
export * from './sales.module';
export * from './entities';
export * from './dto';
export * from './services';
export * from './controllers';

View File

@ -0,0 +1,58 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import {
PipelineStageEntity,
LeadEntity,
OpportunityEntity,
ActivityEntity,
} from './entities';
import {
LeadsService,
OpportunitiesService,
ActivitiesService,
PipelineService,
SalesDashboardService,
} from './services';
import {
LeadsController,
OpportunitiesController,
ActivitiesController,
PipelineController,
SalesDashboardController,
} from './controllers';
@Module({
imports: [
TypeOrmModule.forFeature([
PipelineStageEntity,
LeadEntity,
OpportunityEntity,
ActivityEntity,
]),
],
controllers: [
LeadsController,
OpportunitiesController,
ActivitiesController,
PipelineController,
SalesDashboardController,
],
providers: [
LeadsService,
OpportunitiesService,
ActivitiesService,
PipelineService,
SalesDashboardService,
],
exports: [
LeadsService,
OpportunitiesService,
ActivitiesService,
PipelineService,
SalesDashboardService,
],
})
export class SalesModule {}

View File

@ -0,0 +1,243 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ActivityEntity, ActivityStatus } from '../entities';
import {
CreateActivityDto,
UpdateActivityDto,
CompleteActivityDto,
ActivityResponseDto,
ActivityListQueryDto,
PaginatedActivitiesDto,
} from '../dto';
@Injectable()
export class ActivitiesService {
private readonly logger = new Logger(ActivitiesService.name);
constructor(
@InjectRepository(ActivityEntity)
private readonly activityRepo: Repository<ActivityEntity>,
) {}
async create(tenantId: string, userId: string, dto: CreateActivityDto): Promise<ActivityResponseDto> {
if (!dto.leadId && !dto.opportunityId) {
throw new BadRequestException('Either leadId or opportunityId must be provided');
}
const activity = this.activityRepo.create({
tenantId,
type: dto.type,
subject: dto.subject,
description: dto.description,
leadId: dto.leadId,
opportunityId: dto.opportunityId,
dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
dueTime: dto.dueTime,
durationMinutes: dto.durationMinutes,
assignedTo: dto.assignedTo,
callDirection: dto.callDirection,
callRecordingUrl: dto.callRecordingUrl,
location: dto.location,
meetingUrl: dto.meetingUrl,
attendees: dto.attendees ?? [],
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : undefined,
customFields: dto.customFields ?? {},
createdBy: userId,
});
const saved = await this.activityRepo.save(activity);
this.logger.log(`Activity created: ${saved.id} for tenant ${tenantId}`);
return this.toResponse(saved);
}
async findAll(tenantId: string, query: ActivityListQueryDto): Promise<PaginatedActivitiesDto> {
const page = query.page || 1;
const limit = Math.min(query.limit || 20, 100);
const skip = (page - 1) * limit;
const qb = this.activityRepo
.createQueryBuilder('a')
.leftJoinAndSelect('a.lead', 'lead')
.leftJoinAndSelect('a.opportunity', 'opportunity')
.where('a.tenant_id = :tenantId', { tenantId })
.andWhere('a.deleted_at IS NULL');
if (query.type) {
qb.andWhere('a.type = :type', { type: query.type });
}
if (query.status) {
qb.andWhere('a.status = :status', { status: query.status });
}
if (query.leadId) {
qb.andWhere('a.lead_id = :leadId', { leadId: query.leadId });
}
if (query.opportunityId) {
qb.andWhere('a.opportunity_id = :opportunityId', { opportunityId: query.opportunityId });
}
if (query.assignedTo) {
qb.andWhere('a.assigned_to = :assignedTo', { assignedTo: query.assignedTo });
}
qb.orderBy('a.due_date', 'ASC').addOrderBy('a.created_at', 'DESC').skip(skip).take(limit);
const [items, total] = await qb.getManyAndCount();
return {
items: items.map((a) => this.toResponse(a)),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(tenantId: string, activityId: string): Promise<ActivityResponseDto> {
const activity = await this.activityRepo.findOne({
where: { id: activityId, tenantId, deletedAt: null as any },
relations: ['lead', 'opportunity'],
});
if (!activity) {
throw new NotFoundException('Activity not found');
}
return this.toResponse(activity);
}
async update(tenantId: string, activityId: string, dto: UpdateActivityDto): Promise<ActivityResponseDto> {
const activity = await this.activityRepo.findOne({
where: { id: activityId, tenantId, deletedAt: null as any },
});
if (!activity) {
throw new NotFoundException('Activity not found');
}
Object.assign(activity, {
type: dto.type ?? activity.type,
status: dto.status ?? activity.status,
subject: dto.subject ?? activity.subject,
description: dto.description ?? activity.description,
dueDate: dto.dueDate ? new Date(dto.dueDate) : activity.dueDate,
dueTime: dto.dueTime ?? activity.dueTime,
durationMinutes: dto.durationMinutes ?? activity.durationMinutes,
outcome: dto.outcome ?? activity.outcome,
assignedTo: dto.assignedTo ?? activity.assignedTo,
callDirection: dto.callDirection ?? activity.callDirection,
callRecordingUrl: dto.callRecordingUrl ?? activity.callRecordingUrl,
location: dto.location ?? activity.location,
meetingUrl: dto.meetingUrl ?? activity.meetingUrl,
attendees: dto.attendees ?? activity.attendees,
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : activity.reminderAt,
reminderSent: dto.reminderSent ?? activity.reminderSent,
customFields: dto.customFields ?? activity.customFields,
});
const saved = await this.activityRepo.save(activity);
this.logger.log(`Activity updated: ${saved.id}`);
return this.toResponse(saved);
}
async complete(
tenantId: string,
activityId: string,
dto: CompleteActivityDto,
): Promise<ActivityResponseDto> {
const activity = await this.activityRepo.findOne({
where: { id: activityId, tenantId, deletedAt: null as any },
});
if (!activity) {
throw new NotFoundException('Activity not found');
}
if (activity.status === ActivityStatus.COMPLETED) {
throw new BadRequestException('Activity is already completed');
}
activity.status = ActivityStatus.COMPLETED;
activity.completedAt = new Date();
activity.outcome = dto.outcome ?? activity.outcome;
const saved = await this.activityRepo.save(activity);
this.logger.log(`Activity completed: ${saved.id}`);
return this.toResponse(saved);
}
async remove(tenantId: string, activityId: string): Promise<void> {
const activity = await this.activityRepo.findOne({
where: { id: activityId, tenantId, deletedAt: null as any },
});
if (!activity) {
throw new NotFoundException('Activity not found');
}
activity.deletedAt = new Date();
await this.activityRepo.save(activity);
this.logger.log(`Activity soft-deleted: ${activityId}`);
}
async getUpcoming(tenantId: string, userId?: string, days: number = 7): Promise<ActivityResponseDto[]> {
const qb = this.activityRepo
.createQueryBuilder('a')
.leftJoinAndSelect('a.lead', 'lead')
.leftJoinAndSelect('a.opportunity', 'opportunity')
.where('a.tenant_id = :tenantId', { tenantId })
.andWhere('a.deleted_at IS NULL')
.andWhere('a.status = :status', { status: ActivityStatus.PENDING })
.andWhere('a.due_date <= :endDate', {
endDate: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
});
if (userId) {
qb.andWhere('a.assigned_to = :userId', { userId });
}
qb.orderBy('a.due_date', 'ASC').take(50);
const activities = await qb.getMany();
return activities.map((a) => this.toResponse(a));
}
private toResponse(activity: ActivityEntity): ActivityResponseDto {
return {
id: activity.id,
tenantId: activity.tenantId,
type: activity.type,
status: activity.status,
subject: activity.subject,
description: activity.description,
leadId: activity.leadId,
opportunityId: activity.opportunityId,
dueDate: activity.dueDate,
dueTime: activity.dueTime,
durationMinutes: activity.durationMinutes,
completedAt: activity.completedAt,
outcome: activity.outcome,
assignedTo: activity.assignedTo,
createdBy: activity.createdBy,
callDirection: activity.callDirection,
callRecordingUrl: activity.callRecordingUrl,
location: activity.location,
meetingUrl: activity.meetingUrl,
attendees: activity.attendees,
reminderAt: activity.reminderAt,
reminderSent: activity.reminderSent,
customFields: activity.customFields,
createdAt: activity.createdAt,
updatedAt: activity.updatedAt,
lead: activity.lead,
opportunity: activity.opportunity,
};
}
}

View File

@ -0,0 +1,5 @@
export * from './leads.service';
export * from './opportunities.service';
export * from './activities.service';
export * from './pipeline.service';
export * from './sales-dashboard.service';

View File

@ -0,0 +1,265 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { LeadEntity, LeadStatus } from '../entities';
import {
CreateLeadDto,
UpdateLeadDto,
ConvertLeadDto,
LeadResponseDto,
LeadListQueryDto,
PaginatedLeadsDto,
} from '../dto';
@Injectable()
export class LeadsService {
private readonly logger = new Logger(LeadsService.name);
constructor(
@InjectRepository(LeadEntity)
private readonly leadRepo: Repository<LeadEntity>,
private readonly dataSource: DataSource,
) {}
async create(tenantId: string, userId: string, dto: CreateLeadDto): Promise<LeadResponseDto> {
const lead = this.leadRepo.create({
tenantId,
firstName: dto.firstName,
lastName: dto.lastName,
email: dto.email,
phone: dto.phone,
company: dto.company,
jobTitle: dto.jobTitle,
website: dto.website,
source: dto.source,
status: dto.status,
score: dto.score ?? 0,
assignedTo: dto.assignedTo,
notes: dto.notes,
addressLine1: dto.addressLine1,
addressLine2: dto.addressLine2,
city: dto.city,
state: dto.state,
postalCode: dto.postalCode,
country: dto.country,
customFields: dto.customFields ?? {},
createdBy: userId,
});
const saved = await this.leadRepo.save(lead);
this.logger.log(`Lead created: ${saved.id} for tenant ${tenantId}`);
return this.toResponse(saved);
}
async findAll(tenantId: string, query: LeadListQueryDto): Promise<PaginatedLeadsDto> {
const page = query.page || 1;
const limit = Math.min(query.limit || 20, 100);
const skip = (page - 1) * limit;
const qb = this.leadRepo
.createQueryBuilder('l')
.where('l.tenant_id = :tenantId', { tenantId })
.andWhere('l.deleted_at IS NULL');
if (query.status) {
qb.andWhere('l.status = :status', { status: query.status });
}
if (query.source) {
qb.andWhere('l.source = :source', { source: query.source });
}
if (query.assignedTo) {
qb.andWhere('l.assigned_to = :assignedTo', { assignedTo: query.assignedTo });
}
if (query.search) {
qb.andWhere(
'(l.first_name ILIKE :search OR l.last_name ILIKE :search OR l.email ILIKE :search OR l.company ILIKE :search)',
{ search: `%${query.search}%` },
);
}
qb.orderBy('l.created_at', 'DESC').skip(skip).take(limit);
const [items, total] = await qb.getManyAndCount();
return {
items: items.map((l) => this.toResponse(l)),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(tenantId: string, leadId: string): Promise<LeadResponseDto> {
const lead = await this.leadRepo.findOne({
where: { id: leadId, tenantId, deletedAt: null as any },
});
if (!lead) {
throw new NotFoundException('Lead not found');
}
return this.toResponse(lead);
}
async update(tenantId: string, leadId: string, dto: UpdateLeadDto): Promise<LeadResponseDto> {
const lead = await this.leadRepo.findOne({
where: { id: leadId, tenantId, deletedAt: null as any },
});
if (!lead) {
throw new NotFoundException('Lead not found');
}
Object.assign(lead, {
firstName: dto.firstName ?? lead.firstName,
lastName: dto.lastName ?? lead.lastName,
email: dto.email ?? lead.email,
phone: dto.phone ?? lead.phone,
company: dto.company ?? lead.company,
jobTitle: dto.jobTitle ?? lead.jobTitle,
website: dto.website ?? lead.website,
source: dto.source ?? lead.source,
status: dto.status ?? lead.status,
score: dto.score ?? lead.score,
assignedTo: dto.assignedTo ?? lead.assignedTo,
notes: dto.notes ?? lead.notes,
addressLine1: dto.addressLine1 ?? lead.addressLine1,
addressLine2: dto.addressLine2 ?? lead.addressLine2,
city: dto.city ?? lead.city,
state: dto.state ?? lead.state,
postalCode: dto.postalCode ?? lead.postalCode,
country: dto.country ?? lead.country,
customFields: dto.customFields ?? lead.customFields,
});
const saved = await this.leadRepo.save(lead);
this.logger.log(`Lead updated: ${saved.id}`);
return this.toResponse(saved);
}
async remove(tenantId: string, leadId: string): Promise<void> {
const lead = await this.leadRepo.findOne({
where: { id: leadId, tenantId, deletedAt: null as any },
});
if (!lead) {
throw new NotFoundException('Lead not found');
}
lead.deletedAt = new Date();
await this.leadRepo.save(lead);
this.logger.log(`Lead soft-deleted: ${leadId}`);
}
async convert(
tenantId: string,
leadId: string,
dto: ConvertLeadDto,
): Promise<{ opportunityId: string }> {
const lead = await this.leadRepo.findOne({
where: { id: leadId, tenantId, deletedAt: null as any },
});
if (!lead) {
throw new NotFoundException('Lead not found');
}
if (lead.status === LeadStatus.CONVERTED) {
throw new BadRequestException('Lead is already converted');
}
// Call the database function to convert lead to opportunity
const result = await this.dataSource.query(
`SELECT sales.convert_lead_to_opportunity($1, $2, $3, $4) as opportunity_id`,
[
leadId,
dto.opportunityName || null,
dto.amount || 0,
dto.expectedCloseDate || null,
],
);
const opportunityId = result[0]?.opportunity_id;
this.logger.log(`Lead ${leadId} converted to opportunity ${opportunityId}`);
return { opportunityId };
}
async calculateScore(tenantId: string, leadId: string): Promise<{ score: number }> {
const lead = await this.leadRepo.findOne({
where: { id: leadId, tenantId, deletedAt: null as any },
});
if (!lead) {
throw new NotFoundException('Lead not found');
}
const result = await this.dataSource.query(
`SELECT sales.calculate_lead_score($1) as score`,
[leadId],
);
const score = result[0]?.score ?? 0;
// Update lead score
lead.score = score;
await this.leadRepo.save(lead);
return { score };
}
async assignTo(tenantId: string, leadId: string, userId: string): Promise<LeadResponseDto> {
const lead = await this.leadRepo.findOne({
where: { id: leadId, tenantId, deletedAt: null as any },
});
if (!lead) {
throw new NotFoundException('Lead not found');
}
lead.assignedTo = userId;
const saved = await this.leadRepo.save(lead);
this.logger.log(`Lead ${leadId} assigned to user ${userId}`);
return this.toResponse(saved);
}
private toResponse(lead: LeadEntity): LeadResponseDto {
return {
id: lead.id,
tenantId: lead.tenantId,
firstName: lead.firstName,
lastName: lead.lastName,
fullName: `${lead.firstName} ${lead.lastName}`,
email: lead.email,
phone: lead.phone,
company: lead.company,
jobTitle: lead.jobTitle,
website: lead.website,
source: lead.source,
status: lead.status,
score: lead.score,
assignedTo: lead.assignedTo,
notes: lead.notes,
convertedAt: lead.convertedAt,
convertedToOpportunityId: lead.convertedToOpportunityId,
addressLine1: lead.addressLine1,
addressLine2: lead.addressLine2,
city: lead.city,
state: lead.state,
postalCode: lead.postalCode,
country: lead.country,
customFields: lead.customFields,
createdAt: lead.createdAt,
updatedAt: lead.updatedAt,
createdBy: lead.createdBy,
};
}
}

View File

@ -0,0 +1,255 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { OpportunityEntity, OpportunityStage } from '../entities';
import {
CreateOpportunityDto,
UpdateOpportunityDto,
UpdateOpportunityStageDto,
OpportunityResponseDto,
OpportunityListQueryDto,
PaginatedOpportunitiesDto,
PipelineSummaryDto,
} from '../dto';
@Injectable()
export class OpportunitiesService {
private readonly logger = new Logger(OpportunitiesService.name);
constructor(
@InjectRepository(OpportunityEntity)
private readonly opportunityRepo: Repository<OpportunityEntity>,
private readonly dataSource: DataSource,
) {}
async create(tenantId: string, userId: string, dto: CreateOpportunityDto): Promise<OpportunityResponseDto> {
const opportunity = this.opportunityRepo.create({
tenantId,
name: dto.name,
description: dto.description,
leadId: dto.leadId,
stage: dto.stage ?? OpportunityStage.PROSPECTING,
stageId: dto.stageId,
amount: dto.amount ?? 0,
currency: dto.currency ?? 'USD',
probability: dto.probability ?? 0,
expectedCloseDate: dto.expectedCloseDate ? new Date(dto.expectedCloseDate) : undefined,
assignedTo: dto.assignedTo,
contactName: dto.contactName,
contactEmail: dto.contactEmail,
contactPhone: dto.contactPhone,
companyName: dto.companyName,
notes: dto.notes,
customFields: dto.customFields ?? {},
createdBy: userId,
});
const saved = await this.opportunityRepo.save(opportunity);
this.logger.log(`Opportunity created: ${saved.id} for tenant ${tenantId}`);
return this.toResponse(saved);
}
async findAll(tenantId: string, query: OpportunityListQueryDto): Promise<PaginatedOpportunitiesDto> {
const page = query.page || 1;
const limit = Math.min(query.limit || 20, 100);
const skip = (page - 1) * limit;
const qb = this.opportunityRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.lead', 'lead')
.leftJoinAndSelect('o.pipelineStage', 'pipelineStage')
.where('o.tenant_id = :tenantId', { tenantId })
.andWhere('o.deleted_at IS NULL');
if (query.stage) {
qb.andWhere('o.stage = :stage', { stage: query.stage });
}
if (query.stageId) {
qb.andWhere('o.stage_id = :stageId', { stageId: query.stageId });
}
if (query.assignedTo) {
qb.andWhere('o.assigned_to = :assignedTo', { assignedTo: query.assignedTo });
}
if (query.search) {
qb.andWhere(
'(o.name ILIKE :search OR o.company_name ILIKE :search OR o.contact_name ILIKE :search)',
{ search: `%${query.search}%` },
);
}
qb.orderBy('o.created_at', 'DESC').skip(skip).take(limit);
const [items, total] = await qb.getManyAndCount();
return {
items: items.map((o) => this.toResponse(o)),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(tenantId: string, opportunityId: string): Promise<OpportunityResponseDto> {
const opportunity = await this.opportunityRepo.findOne({
where: { id: opportunityId, tenantId, deletedAt: null as any },
relations: ['lead', 'pipelineStage'],
});
if (!opportunity) {
throw new NotFoundException('Opportunity not found');
}
return this.toResponse(opportunity);
}
async update(
tenantId: string,
opportunityId: string,
dto: UpdateOpportunityDto,
): Promise<OpportunityResponseDto> {
const opportunity = await this.opportunityRepo.findOne({
where: { id: opportunityId, tenantId, deletedAt: null as any },
});
if (!opportunity) {
throw new NotFoundException('Opportunity not found');
}
Object.assign(opportunity, {
name: dto.name ?? opportunity.name,
description: dto.description ?? opportunity.description,
stage: dto.stage ?? opportunity.stage,
stageId: dto.stageId ?? opportunity.stageId,
amount: dto.amount ?? opportunity.amount,
currency: dto.currency ?? opportunity.currency,
probability: dto.probability ?? opportunity.probability,
expectedCloseDate: dto.expectedCloseDate
? new Date(dto.expectedCloseDate)
: opportunity.expectedCloseDate,
assignedTo: dto.assignedTo ?? opportunity.assignedTo,
lostReason: dto.lostReason ?? opportunity.lostReason,
contactName: dto.contactName ?? opportunity.contactName,
contactEmail: dto.contactEmail ?? opportunity.contactEmail,
contactPhone: dto.contactPhone ?? opportunity.contactPhone,
companyName: dto.companyName ?? opportunity.companyName,
notes: dto.notes ?? opportunity.notes,
customFields: dto.customFields ?? opportunity.customFields,
});
const saved = await this.opportunityRepo.save(opportunity);
this.logger.log(`Opportunity updated: ${saved.id}`);
return this.toResponse(saved);
}
async updateStage(
tenantId: string,
opportunityId: string,
dto: UpdateOpportunityStageDto,
): Promise<OpportunityResponseDto> {
const opportunity = await this.opportunityRepo.findOne({
where: { id: opportunityId, tenantId, deletedAt: null as any },
});
if (!opportunity) {
throw new NotFoundException('Opportunity not found');
}
// Use database function for stage update with tracking
await this.dataSource.query(
`SELECT sales.update_opportunity_stage($1, $2, $3)`,
[opportunityId, dto.stage, dto.notes || null],
);
// Reload the opportunity
const updated = await this.opportunityRepo.findOne({
where: { id: opportunityId },
relations: ['lead', 'pipelineStage'],
});
this.logger.log(`Opportunity ${opportunityId} stage updated to ${dto.stage}`);
return this.toResponse(updated!);
}
async remove(tenantId: string, opportunityId: string): Promise<void> {
const opportunity = await this.opportunityRepo.findOne({
where: { id: opportunityId, tenantId, deletedAt: null as any },
});
if (!opportunity) {
throw new NotFoundException('Opportunity not found');
}
opportunity.deletedAt = new Date();
await this.opportunityRepo.save(opportunity);
this.logger.log(`Opportunity soft-deleted: ${opportunityId}`);
}
async getPipelineSummary(tenantId: string): Promise<PipelineSummaryDto[]> {
const result = await this.dataSource.query(
`SELECT * FROM sales.get_pipeline_summary($1)`,
[tenantId],
);
return result.map((row: any) => ({
stage: row.stage,
count: Number(row.count),
totalAmount: Number(row.total_amount),
avgProbability: Number(row.avg_probability),
}));
}
async assignTo(tenantId: string, opportunityId: string, userId: string): Promise<OpportunityResponseDto> {
const opportunity = await this.opportunityRepo.findOne({
where: { id: opportunityId, tenantId, deletedAt: null as any },
});
if (!opportunity) {
throw new NotFoundException('Opportunity not found');
}
opportunity.assignedTo = userId;
const saved = await this.opportunityRepo.save(opportunity);
this.logger.log(`Opportunity ${opportunityId} assigned to user ${userId}`);
return this.toResponse(saved);
}
private toResponse(opportunity: OpportunityEntity): OpportunityResponseDto {
return {
id: opportunity.id,
tenantId: opportunity.tenantId,
name: opportunity.name,
description: opportunity.description,
leadId: opportunity.leadId,
stage: opportunity.stage,
stageId: opportunity.stageId,
amount: Number(opportunity.amount),
currency: opportunity.currency,
probability: opportunity.probability,
expectedCloseDate: opportunity.expectedCloseDate,
actualCloseDate: opportunity.actualCloseDate,
assignedTo: opportunity.assignedTo,
wonAt: opportunity.wonAt,
lostAt: opportunity.lostAt,
lostReason: opportunity.lostReason,
contactName: opportunity.contactName,
contactEmail: opportunity.contactEmail,
contactPhone: opportunity.contactPhone,
companyName: opportunity.companyName,
notes: opportunity.notes,
customFields: opportunity.customFields,
createdAt: opportunity.createdAt,
updatedAt: opportunity.updatedAt,
createdBy: opportunity.createdBy,
lead: opportunity.lead,
pipelineStage: opportunity.pipelineStage,
};
}
}

View File

@ -0,0 +1,186 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { PipelineStageEntity, OpportunityEntity } from '../entities';
import {
CreatePipelineStageDto,
UpdatePipelineStageDto,
ReorderStagesDto,
PipelineStageResponseDto,
} from '../dto';
@Injectable()
export class PipelineService {
private readonly logger = new Logger(PipelineService.name);
constructor(
@InjectRepository(PipelineStageEntity)
private readonly stageRepo: Repository<PipelineStageEntity>,
@InjectRepository(OpportunityEntity)
private readonly opportunityRepo: Repository<OpportunityEntity>,
private readonly dataSource: DataSource,
) {}
async initializeDefaults(tenantId: string): Promise<void> {
await this.dataSource.query(`SELECT sales.initialize_default_stages($1)`, [tenantId]);
this.logger.log(`Default pipeline stages initialized for tenant ${tenantId}`);
}
async findAll(tenantId: string): Promise<PipelineStageResponseDto[]> {
const stages = await this.stageRepo.find({
where: { tenantId },
order: { position: 'ASC' },
});
// Get opportunity counts and totals for each stage
const stats = await this.opportunityRepo
.createQueryBuilder('o')
.select('o.stage_id', 'stageId')
.addSelect('COUNT(*)::int', 'count')
.addSelect('COALESCE(SUM(o.amount), 0)', 'total')
.where('o.tenant_id = :tenantId', { tenantId })
.andWhere('o.deleted_at IS NULL')
.groupBy('o.stage_id')
.getRawMany();
const statsMap = new Map(stats.map((s) => [s.stageId, s]));
return stages.map((stage) => {
const stageStats = statsMap.get(stage.id) || { count: 0, total: 0 };
return {
...this.toResponse(stage),
opportunityCount: Number(stageStats.count),
totalAmount: Number(stageStats.total),
};
});
}
async findOne(tenantId: string, stageId: string): Promise<PipelineStageResponseDto> {
const stage = await this.stageRepo.findOne({
where: { id: stageId, tenantId },
});
if (!stage) {
throw new NotFoundException('Pipeline stage not found');
}
return this.toResponse(stage);
}
async create(tenantId: string, dto: CreatePipelineStageDto): Promise<PipelineStageResponseDto> {
// Get max position
const maxPosition = await this.stageRepo
.createQueryBuilder('s')
.select('MAX(s.position)', 'max')
.where('s.tenant_id = :tenantId', { tenantId })
.getRawOne();
const position = dto.position ?? (maxPosition?.max ?? 0) + 1;
const stage = this.stageRepo.create({
tenantId,
name: dto.name,
position,
color: dto.color ?? '#3B82F6',
isWon: dto.isWon ?? false,
isLost: dto.isLost ?? false,
isActive: dto.isActive ?? true,
});
const saved = await this.stageRepo.save(stage);
this.logger.log(`Pipeline stage created: ${saved.id} for tenant ${tenantId}`);
return this.toResponse(saved);
}
async update(
tenantId: string,
stageId: string,
dto: UpdatePipelineStageDto,
): Promise<PipelineStageResponseDto> {
const stage = await this.stageRepo.findOne({
where: { id: stageId, tenantId },
});
if (!stage) {
throw new NotFoundException('Pipeline stage not found');
}
Object.assign(stage, {
name: dto.name ?? stage.name,
position: dto.position ?? stage.position,
color: dto.color ?? stage.color,
isWon: dto.isWon ?? stage.isWon,
isLost: dto.isLost ?? stage.isLost,
isActive: dto.isActive ?? stage.isActive,
});
const saved = await this.stageRepo.save(stage);
this.logger.log(`Pipeline stage updated: ${saved.id}`);
return this.toResponse(saved);
}
async remove(tenantId: string, stageId: string): Promise<void> {
const stage = await this.stageRepo.findOne({
where: { id: stageId, tenantId },
});
if (!stage) {
throw new NotFoundException('Pipeline stage not found');
}
// Check if there are opportunities in this stage
const opportunityCount = await this.opportunityRepo.count({
where: { stageId, tenantId, deletedAt: null as any },
});
if (opportunityCount > 0) {
throw new NotFoundException(
`Cannot delete stage with ${opportunityCount} opportunities. Move them first.`,
);
}
await this.stageRepo.remove(stage);
this.logger.log(`Pipeline stage deleted: ${stageId}`);
}
async reorder(tenantId: string, dto: ReorderStagesDto): Promise<PipelineStageResponseDto[]> {
const stages = await this.stageRepo.find({
where: { tenantId },
});
const stageMap = new Map(stages.map((s) => [s.id, s]));
// Update positions based on the order in dto.stageIds
const updates = dto.stageIds.map((id, index) => {
const stage = stageMap.get(id);
if (stage) {
stage.position = index + 1;
return stage;
}
return null;
}).filter(Boolean);
await this.stageRepo.save(updates as PipelineStageEntity[]);
this.logger.log(`Pipeline stages reordered for tenant ${tenantId}`);
return this.findAll(tenantId);
}
private toResponse(stage: PipelineStageEntity): PipelineStageResponseDto {
return {
id: stage.id,
tenantId: stage.tenantId,
name: stage.name,
position: stage.position,
color: stage.color,
isWon: stage.isWon,
isLost: stage.isLost,
isActive: stage.isActive,
createdAt: stage.createdAt,
updatedAt: stage.updatedAt,
};
}
}

View File

@ -0,0 +1,227 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { LeadEntity, OpportunityEntity, ActivityEntity, OpportunityStage, LeadStatus, ActivityStatus } from '../entities';
import {
SalesDashboardDto,
LeadsByStatusDto,
LeadsBySourceDto,
OpportunitiesByStageDto,
ConversionFunnelDto,
SalesPerformanceDto,
} from '../dto';
@Injectable()
export class SalesDashboardService {
private readonly logger = new Logger(SalesDashboardService.name);
constructor(
@InjectRepository(LeadEntity)
private readonly leadRepo: Repository<LeadEntity>,
@InjectRepository(OpportunityEntity)
private readonly opportunityRepo: Repository<OpportunityEntity>,
@InjectRepository(ActivityEntity)
private readonly activityRepo: Repository<ActivityEntity>,
private readonly dataSource: DataSource,
) {}
async getDashboardSummary(tenantId: string): Promise<SalesDashboardDto> {
// Total leads
const totalLeads = await this.leadRepo.count({
where: { tenantId, deletedAt: null as any },
});
// Total opportunities
const totalOpportunities = await this.opportunityRepo.count({
where: { tenantId, deletedAt: null as any },
});
// Pipeline value and won deals
const pipelineStats = await this.opportunityRepo
.createQueryBuilder('o')
.select('COUNT(*)', 'total')
.addSelect('COALESCE(SUM(o.amount), 0)', 'totalValue')
.addSelect('COUNT(*) FILTER (WHERE o.stage = :wonStage)', 'wonCount')
.addSelect('COALESCE(SUM(o.amount) FILTER (WHERE o.stage = :wonStage), 0)', 'wonValue')
.where('o.tenant_id = :tenantId', { tenantId })
.andWhere('o.deleted_at IS NULL')
.setParameter('wonStage', OpportunityStage.CLOSED_WON)
.getRawOne();
// Conversion rate (leads converted to opportunities)
const convertedLeads = await this.leadRepo.count({
where: { tenantId, status: LeadStatus.CONVERTED, deletedAt: null as any },
});
const conversionRate = totalLeads > 0 ? (convertedLeads / totalLeads) * 100 : 0;
// Average deal size
const avgDealSize =
Number(pipelineStats.wonCount) > 0
? Number(pipelineStats.wonValue) / Number(pipelineStats.wonCount)
: 0;
// Activities this week
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
const activitiesThisWeek = await this.activityRepo.count({
where: {
tenantId,
status: ActivityStatus.COMPLETED,
deletedAt: null as any,
},
});
return {
totalLeads,
totalOpportunities,
totalPipelineValue: Number(pipelineStats.totalValue),
wonDeals: Number(pipelineStats.wonCount),
conversionRate: Math.round(conversionRate * 100) / 100,
averageDealSize: Math.round(avgDealSize * 100) / 100,
activitiesThisWeek,
currency: 'USD',
};
}
async getLeadsByStatus(tenantId: string): Promise<LeadsByStatusDto[]> {
const result = await this.leadRepo
.createQueryBuilder('l')
.select('l.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.where('l.tenant_id = :tenantId', { tenantId })
.andWhere('l.deleted_at IS NULL')
.groupBy('l.status')
.getRawMany();
const total = result.reduce((sum, r) => sum + r.count, 0);
return result.map((r) => ({
status: r.status,
count: r.count,
percentage: total > 0 ? Math.round((r.count / total) * 100) : 0,
}));
}
async getLeadsBySource(tenantId: string): Promise<LeadsBySourceDto[]> {
const result = await this.leadRepo
.createQueryBuilder('l')
.select('l.source', 'source')
.addSelect('COUNT(*)::int', 'count')
.where('l.tenant_id = :tenantId', { tenantId })
.andWhere('l.deleted_at IS NULL')
.groupBy('l.source')
.getRawMany();
const total = result.reduce((sum, r) => sum + r.count, 0);
return result.map((r) => ({
source: r.source,
count: r.count,
percentage: total > 0 ? Math.round((r.count / total) * 100) : 0,
}));
}
async getOpportunitiesByStage(tenantId: string): Promise<OpportunitiesByStageDto[]> {
const result = await this.opportunityRepo
.createQueryBuilder('o')
.select('o.stage', 'stage')
.addSelect('COUNT(*)::int', 'count')
.addSelect('COALESCE(SUM(o.amount), 0)', 'totalAmount')
.where('o.tenant_id = :tenantId', { tenantId })
.andWhere('o.deleted_at IS NULL')
.groupBy('o.stage')
.getRawMany();
const total = result.reduce((sum, r) => sum + r.count, 0);
return result.map((r) => ({
stage: r.stage,
count: r.count,
totalAmount: Number(r.totalAmount),
percentage: total > 0 ? Math.round((r.count / total) * 100) : 0,
}));
}
async getConversionFunnel(tenantId: string): Promise<ConversionFunnelDto[]> {
// Define funnel stages
const stages = [
{ stage: 'New Leads', query: { status: LeadStatus.NEW } },
{ stage: 'Contacted', query: { status: LeadStatus.CONTACTED } },
{ stage: 'Qualified', query: { status: LeadStatus.QUALIFIED } },
{ stage: 'Converted', query: { status: LeadStatus.CONVERTED } },
];
const totalLeads = await this.leadRepo.count({
where: { tenantId, deletedAt: null as any },
});
let previousCount = totalLeads;
const funnel: ConversionFunnelDto[] = [];
for (const { stage, query } of stages) {
const count = await this.leadRepo.count({
where: { tenantId, ...query, deletedAt: null as any },
});
funnel.push({
stage,
count,
percentage: totalLeads > 0 ? Math.round((count / totalLeads) * 100) : 0,
conversionFromPrevious:
previousCount > 0 ? Math.round((count / previousCount) * 100) : 0,
});
previousCount = count > 0 ? count : previousCount;
}
// Add opportunity stages
const wonOpportunities = await this.opportunityRepo.count({
where: { tenantId, stage: OpportunityStage.CLOSED_WON, deletedAt: null as any },
});
funnel.push({
stage: 'Won Deals',
count: wonOpportunities,
percentage: totalLeads > 0 ? Math.round((wonOpportunities / totalLeads) * 100) : 0,
conversionFromPrevious:
previousCount > 0 ? Math.round((wonOpportunities / previousCount) * 100) : 0,
});
return funnel;
}
async getSalesPerformance(tenantId: string): Promise<SalesPerformanceDto[]> {
// Get aggregated performance by user
const result = await this.dataSource.query(`
SELECT
u.id as user_id,
COALESCE(u.first_name || ' ' || u.last_name, u.email) as user_name,
(SELECT COUNT(*) FROM sales.leads WHERE assigned_to = u.id AND tenant_id = $1 AND deleted_at IS NULL)::int as leads_assigned,
(SELECT COUNT(*) FROM sales.opportunities WHERE assigned_to = u.id AND tenant_id = $1 AND stage = 'closed_won' AND deleted_at IS NULL)::int as opportunities_won,
(SELECT COALESCE(SUM(amount), 0) FROM sales.opportunities WHERE assigned_to = u.id AND tenant_id = $1 AND stage = 'closed_won' AND deleted_at IS NULL) as total_revenue,
(SELECT COUNT(*) FROM sales.activities WHERE assigned_to = u.id AND tenant_id = $1 AND status = 'completed' AND deleted_at IS NULL)::int as activities_completed
FROM users.users u
WHERE u.tenant_id = $1
AND EXISTS (
SELECT 1 FROM sales.leads WHERE assigned_to = u.id AND tenant_id = $1
UNION
SELECT 1 FROM sales.opportunities WHERE assigned_to = u.id AND tenant_id = $1
)
`, [tenantId]);
return result.map((r: any) => ({
userId: r.user_id,
userName: r.user_name,
leadsAssigned: r.leads_assigned,
opportunitiesWon: r.opportunities_won,
totalRevenue: Number(r.total_revenue),
conversionRate:
r.leads_assigned > 0
? Math.round((r.opportunities_won / r.leads_assigned) * 100)
: 0,
activitiesCompleted: r.activities_completed,
}));
}
}