diff --git a/src/app.module.ts b/src/app.module.ts index cb18b04..ce82354 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -32,6 +32,7 @@ import { SalesModule } from '@modules/sales/sales.module'; import { CommissionsModule } from '@modules/commissions/commissions.module'; import { PortfolioModule } from '@modules/portfolio/portfolio.module'; import { GoalsModule } from '@modules/goals/goals.module'; +import { MlmModule } from '@modules/mlm/mlm.module'; @Module({ imports: [ @@ -96,6 +97,7 @@ import { GoalsModule } from '@modules/goals/goals.module'; CommissionsModule, PortfolioModule, GoalsModule, + MlmModule, ], }) export class AppModule {} diff --git a/src/modules/mlm/controllers/commissions.controller.ts b/src/modules/mlm/controllers/commissions.controller.ts new file mode 100644 index 0000000..25ed873 --- /dev/null +++ b/src/modules/mlm/controllers/commissions.controller.ts @@ -0,0 +1,88 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { CurrentUser } from '@modules/auth/decorators/current-user.decorator'; +import { CommissionsService } from '../services/commissions.service'; +import { + CalculateCommissionsDto, + UpdateCommissionStatusDto, + CommissionFiltersDto, + CommissionResponseDto, + CommissionsByLevelDto, +} from '../dto/commission.dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@ApiTags('MLM - Commissions') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('mlm/commissions') +export class CommissionsController { + constructor(private readonly commissionsService: CommissionsService) {} + + @Get() + @ApiOperation({ summary: 'List all MLM commissions' }) + @ApiResponse({ status: 200 }) + findAll( + @CurrentUser() user: RequestUser, + @Query() filters: CommissionFiltersDto, + ) { + return this.commissionsService.findAll(user.tenant_id, filters); + } + + @Get('by-level') + @ApiOperation({ summary: 'Get commissions breakdown by level' }) + @ApiResponse({ status: 200, type: [CommissionsByLevelDto] }) + getByLevel( + @CurrentUser() user: RequestUser, + @Query('nodeId') nodeId?: string, + ) { + return this.commissionsService.getCommissionsByLevel(user.tenant_id, nodeId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a commission by ID' }) + @ApiResponse({ status: 200, type: CommissionResponseDto }) + findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.commissionsService.findOne(user.tenant_id, id); + } + + @Post('calculate') + @ApiOperation({ summary: 'Calculate commissions for a sale/volume' }) + @ApiResponse({ status: 201, type: [CommissionResponseDto] }) + calculateCommissions( + @CurrentUser() user: RequestUser, + @Body() dto: CalculateCommissionsDto, + ) { + return this.commissionsService.calculateCommissions(user.tenant_id, dto); + } + + @Patch(':id/status') + @ApiOperation({ summary: 'Update commission status' }) + @ApiResponse({ status: 200, type: CommissionResponseDto }) + updateStatus( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateCommissionStatusDto, + ) { + return this.commissionsService.updateStatus(user.tenant_id, id, dto); + } +} diff --git a/src/modules/mlm/controllers/index.ts b/src/modules/mlm/controllers/index.ts new file mode 100644 index 0000000..99f513a --- /dev/null +++ b/src/modules/mlm/controllers/index.ts @@ -0,0 +1,4 @@ +export * from './structures.controller'; +export * from './ranks.controller'; +export * from './nodes.controller'; +export * from './commissions.controller'; diff --git a/src/modules/mlm/controllers/nodes.controller.ts b/src/modules/mlm/controllers/nodes.controller.ts new file mode 100644 index 0000000..85a1d18 --- /dev/null +++ b/src/modules/mlm/controllers/nodes.controller.ts @@ -0,0 +1,195 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { CurrentUser } from '@modules/auth/decorators/current-user.decorator'; +import { NodesService } from '../services/nodes.service'; +import { CommissionsService } from '../services/commissions.service'; +import { + CreateNodeDto, + UpdateNodeDto, + UpdateNodeStatusDto, + NodeFiltersDto, + NodeResponseDto, + TreeNodeDto, + InviteLinkDto, + MyNetworkSummaryDto, +} from '../dto/node.dto'; +import { EarningsSummaryDto } from '../dto/commission.dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@ApiTags('MLM - Nodes') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('mlm') +export class NodesController { + constructor( + private readonly nodesService: NodesService, + private readonly commissionsService: CommissionsService, + ) {} + + // ───────────────────────────────────────────── + // Nodes CRUD + // ───────────────────────────────────────────── + + @Post('nodes') + @ApiOperation({ summary: 'Create a new node (register distributor)' }) + @ApiResponse({ status: 201, type: NodeResponseDto }) + create( + @CurrentUser() user: RequestUser, + @Body() dto: CreateNodeDto, + ) { + return this.nodesService.create(user.tenant_id, dto); + } + + @Get('nodes') + @ApiOperation({ summary: 'List all nodes' }) + @ApiResponse({ status: 200 }) + findAll( + @CurrentUser() user: RequestUser, + @Query() filters: NodeFiltersDto, + ) { + return this.nodesService.findAll(user.tenant_id, filters); + } + + @Get('nodes/:id') + @ApiOperation({ summary: 'Get a node by ID' }) + @ApiResponse({ status: 200, type: NodeResponseDto }) + findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.nodesService.findOne(user.tenant_id, id); + } + + @Patch('nodes/:id') + @ApiOperation({ summary: 'Update a node' }) + @ApiResponse({ status: 200, type: NodeResponseDto }) + update( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateNodeDto, + ) { + return this.nodesService.update(user.tenant_id, id, dto); + } + + @Patch('nodes/:id/status') + @ApiOperation({ summary: 'Update node status' }) + @ApiResponse({ status: 200, type: NodeResponseDto }) + updateStatus( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateNodeStatusDto, + ) { + return this.nodesService.updateStatus(user.tenant_id, id, dto); + } + + // ───────────────────────────────────────────── + // Network Navigation + // ───────────────────────────────────────────── + + @Get('nodes/:id/downline') + @ApiOperation({ summary: 'Get downline of a node' }) + @ApiResponse({ status: 200, type: [NodeResponseDto] }) + getDownline( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Query('maxDepth') maxDepth?: number, + ) { + return this.nodesService.getDownline(user.tenant_id, id, maxDepth); + } + + @Get('nodes/:id/upline') + @ApiOperation({ summary: 'Get upline of a node' }) + @ApiResponse({ status: 200, type: [NodeResponseDto] }) + getUpline( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.nodesService.getUpline(user.tenant_id, id); + } + + @Get('nodes/:id/tree') + @ApiOperation({ summary: 'Get tree structure from a node' }) + @ApiResponse({ status: 200, type: TreeNodeDto }) + getTree( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Query('maxDepth') maxDepth?: number, + ) { + return this.nodesService.getTree(user.tenant_id, id, maxDepth || 3); + } + + // ───────────────────────────────────────────── + // My Network (Current User) + // ───────────────────────────────────────────── + + @Get('my/dashboard') + @ApiOperation({ summary: 'Get current user network dashboard' }) + @ApiResponse({ status: 200, type: MyNetworkSummaryDto }) + getMyDashboard(@CurrentUser() user: RequestUser) { + return this.nodesService.getMyNetworkSummary(user.tenant_id, user.id); + } + + @Get('my/network') + @ApiOperation({ summary: 'Get current user network tree' }) + @ApiResponse({ status: 200, type: TreeNodeDto }) + async getMyNetwork( + @CurrentUser() user: RequestUser, + @Query('maxDepth') maxDepth?: number, + ) { + const myNode = await this.nodesService.findByUserId(user.tenant_id, user.id); + if (!myNode) { + return null; + } + return this.nodesService.getTree(user.tenant_id, myNode.id, maxDepth || 3); + } + + @Get('my/earnings') + @ApiOperation({ summary: 'Get current user earnings summary' }) + @ApiResponse({ status: 200, type: EarningsSummaryDto }) + async getMyEarnings(@CurrentUser() user: RequestUser) { + const myNode = await this.nodesService.findByUserId(user.tenant_id, user.id); + if (!myNode) { + return null; + } + return this.commissionsService.getEarningsSummary(user.tenant_id, myNode.id); + } + + @Get('my/rank') + @ApiOperation({ summary: 'Get current user rank and progress' }) + @ApiResponse({ status: 200 }) + async getMyRank(@CurrentUser() user: RequestUser) { + const summary = await this.nodesService.getMyNetworkSummary(user.tenant_id, user.id); + return { + currentRank: summary.currentRank, + nextRank: summary.nextRank, + }; + } + + @Post('my/invite') + @ApiOperation({ summary: 'Generate invite link for current user' }) + @ApiResponse({ status: 200, type: InviteLinkDto }) + async generateInviteLink(@CurrentUser() user: RequestUser) { + const myNode = await this.nodesService.findByUserId(user.tenant_id, user.id); + if (!myNode) { + return null; + } + return this.nodesService.generateInviteLink(user.tenant_id, myNode.id); + } +} diff --git a/src/modules/mlm/controllers/ranks.controller.ts b/src/modules/mlm/controllers/ranks.controller.ts new file mode 100644 index 0000000..46add08 --- /dev/null +++ b/src/modules/mlm/controllers/ranks.controller.ts @@ -0,0 +1,98 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { CurrentUser } from '@modules/auth/decorators/current-user.decorator'; +import { RanksService } from '../services/ranks.service'; +import { + CreateRankDto, + UpdateRankDto, + RankFiltersDto, + RankResponseDto, +} from '../dto/rank.dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@ApiTags('MLM - Ranks') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('mlm/ranks') +export class RanksController { + constructor(private readonly ranksService: RanksService) {} + + @Post() + @ApiOperation({ summary: 'Create a new rank' }) + @ApiResponse({ status: 201, type: RankResponseDto }) + create( + @CurrentUser() user: RequestUser, + @Body() dto: CreateRankDto, + ) { + return this.ranksService.create(user.tenant_id, dto); + } + + @Get() + @ApiOperation({ summary: 'List all ranks' }) + @ApiResponse({ status: 200, type: [RankResponseDto] }) + findAll( + @CurrentUser() user: RequestUser, + @Query() filters: RankFiltersDto, + ) { + return this.ranksService.findAll(user.tenant_id, filters); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a rank by ID' }) + @ApiResponse({ status: 200, type: RankResponseDto }) + findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.ranksService.findOne(user.tenant_id, id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a rank' }) + @ApiResponse({ status: 200, type: RankResponseDto }) + update( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateRankDto, + ) { + return this.ranksService.update(user.tenant_id, id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a rank' }) + @ApiResponse({ status: 204 }) + remove( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.ranksService.remove(user.tenant_id, id); + } + + @Post('evaluate') + @ApiOperation({ summary: 'Evaluate all nodes for rank qualification' }) + @ApiResponse({ status: 200 }) + evaluate( + @CurrentUser() user: RequestUser, + @Query('structureId', ParseUUIDPipe) structureId: string, + ) { + return this.ranksService.evaluateAllNodes(user.tenant_id, structureId); + } +} diff --git a/src/modules/mlm/controllers/structures.controller.ts b/src/modules/mlm/controllers/structures.controller.ts new file mode 100644 index 0000000..fd38836 --- /dev/null +++ b/src/modules/mlm/controllers/structures.controller.ts @@ -0,0 +1,88 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { CurrentUser } from '@modules/auth/decorators/current-user.decorator'; +import { StructuresService } from '../services/structures.service'; +import { + CreateStructureDto, + UpdateStructureDto, + StructureFiltersDto, + StructureResponseDto, +} from '../dto/structure.dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@ApiTags('MLM - Structures') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('mlm/structures') +export class StructuresController { + constructor(private readonly structuresService: StructuresService) {} + + @Post() + @ApiOperation({ summary: 'Create a new MLM structure' }) + @ApiResponse({ status: 201, type: StructureResponseDto }) + create( + @CurrentUser() user: RequestUser, + @Body() dto: CreateStructureDto, + ) { + return this.structuresService.create(user.tenant_id, user.id, dto); + } + + @Get() + @ApiOperation({ summary: 'List all MLM structures' }) + @ApiResponse({ status: 200, type: [StructureResponseDto] }) + findAll( + @CurrentUser() user: RequestUser, + @Query() filters: StructureFiltersDto, + ) { + return this.structuresService.findAll(user.tenant_id, filters); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a structure by ID' }) + @ApiResponse({ status: 200, type: StructureResponseDto }) + findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.structuresService.findOne(user.tenant_id, id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a structure' }) + @ApiResponse({ status: 200, type: StructureResponseDto }) + update( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateStructureDto, + ) { + return this.structuresService.update(user.tenant_id, id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a structure' }) + @ApiResponse({ status: 204 }) + remove( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.structuresService.remove(user.tenant_id, id); + } +} diff --git a/src/modules/mlm/dto/commission.dto.ts b/src/modules/mlm/dto/commission.dto.ts new file mode 100644 index 0000000..3fdaaed --- /dev/null +++ b/src/modules/mlm/dto/commission.dto.ts @@ -0,0 +1,238 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsUUID, + IsNumber, + IsEnum, + IsOptional, + IsString, + Min, + Max, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { CommissionType, CommissionStatus } from '../entities/commission.entity'; +import { BonusType } from '../entities/bonus.entity'; + +export class CalculateCommissionsDto { + @ApiProperty({ description: 'Source node ID (who generated the sale)' }) + @IsUUID() + sourceNodeId: string; + + @ApiProperty({ description: 'Amount to calculate commissions on' }) + @IsNumber() + @Min(0) + amount: number; + + @ApiPropertyOptional({ description: 'Currency', default: 'USD' }) + @IsOptional() + @IsString() + currency?: string; + + @ApiPropertyOptional({ description: 'Source reference (sale ID, etc.)' }) + @IsOptional() + @IsString() + sourceReference?: string; + + @ApiPropertyOptional({ description: 'Period ID' }) + @IsOptional() + @IsUUID() + periodId?: string; +} + +export class UpdateCommissionStatusDto { + @ApiProperty({ description: 'New status', enum: CommissionStatus }) + @IsEnum(CommissionStatus) + status: CommissionStatus; +} + +export class CommissionResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + tenantId: string; + + @ApiProperty() + nodeId: string; + + @ApiProperty() + sourceNodeId: string; + + @ApiProperty({ enum: CommissionType }) + type: CommissionType; + + @ApiProperty() + level: number; + + @ApiProperty() + sourceAmount: number; + + @ApiProperty() + rateApplied: number; + + @ApiProperty() + commissionAmount: number; + + @ApiProperty() + currency: string; + + @ApiPropertyOptional() + periodId: string | null; + + @ApiPropertyOptional() + sourceReference: string | null; + + @ApiProperty({ enum: CommissionStatus }) + status: CommissionStatus; + + @ApiPropertyOptional() + paidAt: Date | null; + + @ApiPropertyOptional() + notes: string | null; + + @ApiProperty() + createdAt: Date; + + // Populated relations + @ApiPropertyOptional() + node?: { + id: string; + userId: string; + user?: { + email: string; + firstName: string | null; + lastName: string | null; + }; + }; + + @ApiPropertyOptional() + sourceNode?: { + id: string; + userId: string; + user?: { + email: string; + firstName: string | null; + lastName: string | null; + }; + }; +} + +export class CommissionFiltersDto { + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + nodeId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + sourceNodeId?: string; + + @ApiPropertyOptional({ enum: CommissionType }) + @IsOptional() + @IsEnum(CommissionType) + type?: CommissionType; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Type(() => Number) + level?: number; + + @ApiPropertyOptional({ enum: CommissionStatus }) + @IsOptional() + @IsEnum(CommissionStatus) + status?: CommissionStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + periodId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(1) + page?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(1) + @Max(100) + limit?: number; +} + +export class BonusResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + tenantId: string; + + @ApiProperty() + nodeId: string; + + @ApiPropertyOptional() + rankId: string | null; + + @ApiProperty({ enum: BonusType }) + type: BonusType; + + @ApiProperty() + amount: number; + + @ApiProperty() + currency: string; + + @ApiPropertyOptional() + periodId: string | null; + + @ApiProperty({ enum: CommissionStatus }) + status: CommissionStatus; + + @ApiPropertyOptional() + paidAt: Date | null; + + @ApiProperty() + achievedAt: Date; + + @ApiPropertyOptional() + notes: string | null; + + @ApiProperty() + createdAt: Date; +} + +export class CommissionsByLevelDto { + @ApiProperty() + level: number; + + @ApiProperty() + count: number; + + @ApiProperty() + totalAmount: number; +} + +export class EarningsSummaryDto { + @ApiProperty() + totalCommissions: number; + + @ApiProperty() + totalBonuses: number; + + @ApiProperty() + totalEarnings: number; + + @ApiProperty() + pendingAmount: number; + + @ApiProperty() + paidAmount: number; + + @ApiProperty({ type: [CommissionsByLevelDto] }) + byLevel: CommissionsByLevelDto[]; +} diff --git a/src/modules/mlm/dto/index.ts b/src/modules/mlm/dto/index.ts new file mode 100644 index 0000000..0ced185 --- /dev/null +++ b/src/modules/mlm/dto/index.ts @@ -0,0 +1,4 @@ +export * from './structure.dto'; +export * from './rank.dto'; +export * from './node.dto'; +export * from './commission.dto'; diff --git a/src/modules/mlm/dto/node.dto.ts b/src/modules/mlm/dto/node.dto.ts new file mode 100644 index 0000000..3ee42da --- /dev/null +++ b/src/modules/mlm/dto/node.dto.ts @@ -0,0 +1,283 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { + IsString, + IsUUID, + IsNumber, + IsOptional, + IsEnum, + Min, + Max, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { NodeStatus } from '../entities/node.entity'; + +export class CreateNodeDto { + @ApiProperty({ description: 'Structure ID' }) + @IsUUID() + structureId: string; + + @ApiProperty({ description: 'User ID' }) + @IsUUID() + userId: string; + + @ApiPropertyOptional({ description: 'Parent node ID (placement)' }) + @IsOptional() + @IsUUID() + parentId?: string; + + @ApiPropertyOptional({ description: 'Sponsor node ID (referral)' }) + @IsOptional() + @IsUUID() + sponsorId?: string; + + @ApiPropertyOptional({ description: 'Position (1=left, 2=right for binary)' }) + @IsOptional() + @IsNumber() + @Min(1) + position?: number; +} + +export class UpdateNodeDto { + @ApiPropertyOptional({ description: 'Parent node ID' }) + @IsOptional() + @IsUUID() + parentId?: string; + + @ApiPropertyOptional({ description: 'Position' }) + @IsOptional() + @IsNumber() + @Min(1) + position?: number; +} + +export class UpdateNodeStatusDto { + @ApiProperty({ description: 'New status', enum: NodeStatus }) + @IsEnum(NodeStatus) + status: NodeStatus; +} + +export class NodeResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + tenantId: string; + + @ApiProperty() + structureId: string; + + @ApiProperty() + userId: string; + + @ApiPropertyOptional() + parentId: string | null; + + @ApiPropertyOptional() + sponsorId: string | null; + + @ApiPropertyOptional() + position: number | null; + + @ApiPropertyOptional() + path: string | null; + + @ApiProperty() + depth: number; + + @ApiPropertyOptional() + rankId: string | null; + + @ApiPropertyOptional() + highestRankId: string | null; + + @ApiProperty() + personalVolume: number; + + @ApiProperty() + groupVolume: number; + + @ApiProperty() + directReferrals: number; + + @ApiProperty() + totalDownline: number; + + @ApiProperty() + totalEarnings: number; + + @ApiProperty({ enum: NodeStatus }) + status: NodeStatus; + + @ApiProperty() + joinedAt: Date; + + @ApiPropertyOptional() + inviteCode: string | null; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + updatedAt: Date; + + // Populated relations + @ApiPropertyOptional() + user?: { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + }; + + @ApiPropertyOptional() + rank?: { + id: string; + name: string; + level: number; + color: string | null; + }; +} + +export class NodeFiltersDto { + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + structureId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + parentId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + sponsorId?: string; + + @ApiPropertyOptional({ enum: NodeStatus }) + @IsOptional() + @IsEnum(NodeStatus) + status?: NodeStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Type(() => Number) + minDepth?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Type(() => Number) + maxDepth?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(1) + page?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(1) + @Max(100) + limit?: number; +} + +export class TreeNodeDto { + @ApiProperty() + id: string; + + @ApiProperty() + userId: string; + + @ApiProperty() + depth: number; + + @ApiPropertyOptional() + position: number | null; + + @ApiProperty() + personalVolume: number; + + @ApiProperty() + groupVolume: number; + + @ApiProperty() + directReferrals: number; + + @ApiProperty({ enum: NodeStatus }) + status: NodeStatus; + + @ApiPropertyOptional() + user?: { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + }; + + @ApiPropertyOptional() + rank?: { + id: string; + name: string; + level: number; + color: string | null; + badgeUrl: string | null; + }; + + @ApiProperty({ type: [TreeNodeDto] }) + children: TreeNodeDto[]; +} + +export class InviteLinkDto { + @ApiProperty() + inviteCode: string; + + @ApiProperty() + inviteUrl: string; +} + +export class MyNetworkSummaryDto { + @ApiProperty() + totalDownline: number; + + @ApiProperty() + directReferrals: number; + + @ApiProperty() + activeDownline: number; + + @ApiProperty() + personalVolume: number; + + @ApiProperty() + groupVolume: number; + + @ApiProperty() + totalEarnings: number; + + @ApiPropertyOptional() + currentRank: { + id: string; + name: string; + level: number; + } | null; + + @ApiPropertyOptional() + nextRank: { + id: string; + name: string; + level: number; + requirements: any; + progress: Record; + } | null; +} diff --git a/src/modules/mlm/dto/rank.dto.ts b/src/modules/mlm/dto/rank.dto.ts new file mode 100644 index 0000000..2dff03b --- /dev/null +++ b/src/modules/mlm/dto/rank.dto.ts @@ -0,0 +1,114 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { + IsString, + IsUUID, + IsNumber, + IsOptional, + IsBoolean, + IsObject, + Min, + Max, +} from 'class-validator'; +import { RankRequirements, RankBenefits } from '../entities/rank.entity'; + +export class CreateRankDto { + @ApiProperty({ description: 'Structure ID' }) + @IsUUID() + structureId: string; + + @ApiProperty({ description: 'Rank name' }) + @IsString() + name: string; + + @ApiProperty({ description: 'Rank level (1=Entry, higher=better)' }) + @IsNumber() + @Min(1) + level: number; + + @ApiPropertyOptional({ description: 'Badge URL' }) + @IsOptional() + @IsString() + badgeUrl?: string; + + @ApiPropertyOptional({ description: 'Color hex code' }) + @IsOptional() + @IsString() + color?: string; + + @ApiPropertyOptional({ description: 'Requirements to achieve rank' }) + @IsOptional() + @IsObject() + requirements?: RankRequirements; + + @ApiPropertyOptional({ description: 'Bonus rate for this rank' }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(1) + bonusRate?: number; + + @ApiPropertyOptional({ description: 'Benefits for this rank' }) + @IsOptional() + @IsObject() + benefits?: RankBenefits; + + @ApiPropertyOptional({ description: 'Is active', default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateRankDto extends PartialType(CreateRankDto) {} + +export class RankResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + tenantId: string; + + @ApiProperty() + structureId: string; + + @ApiProperty() + name: string; + + @ApiProperty() + level: number; + + @ApiPropertyOptional() + badgeUrl: string | null; + + @ApiPropertyOptional() + color: string | null; + + @ApiProperty() + requirements: RankRequirements; + + @ApiPropertyOptional() + bonusRate: number | null; + + @ApiProperty() + benefits: RankBenefits; + + @ApiProperty() + isActive: boolean; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + updatedAt: Date; +} + +export class RankFiltersDto { + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + structureId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/modules/mlm/dto/structure.dto.ts b/src/modules/mlm/dto/structure.dto.ts new file mode 100644 index 0000000..2f46fd8 --- /dev/null +++ b/src/modules/mlm/dto/structure.dto.ts @@ -0,0 +1,121 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { + IsString, + IsEnum, + IsObject, + IsArray, + IsOptional, + IsBoolean, + IsNumber, + ValidateNested, + Min, + Max, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { StructureType, LevelRate } from '../entities/structure.entity'; + +export class LevelRateDto { + @ApiProperty({ description: 'Level number (1=direct, 2=second level, etc.)' }) + @IsNumber() + @Min(1) + level: number; + + @ApiProperty({ description: 'Commission rate (0.10 = 10%)' }) + @IsNumber() + @Min(0) + @Max(1) + rate: number; +} + +export class CreateStructureDto { + @ApiProperty({ description: 'Structure name' }) + @IsString() + name: string; + + @ApiPropertyOptional({ description: 'Description' }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ description: 'Structure type', enum: StructureType }) + @IsEnum(StructureType) + type: StructureType; + + @ApiPropertyOptional({ description: 'Type-specific configuration' }) + @IsOptional() + @IsObject() + config?: Record; + + @ApiPropertyOptional({ description: 'Commission rates by level', type: [LevelRateDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => LevelRateDto) + levelRates?: LevelRateDto[]; + + @ApiPropertyOptional({ description: 'Matching bonus rates', type: [LevelRateDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => LevelRateDto) + matchingRates?: LevelRateDto[]; + + @ApiPropertyOptional({ description: 'Is active', default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateStructureDto extends PartialType(CreateStructureDto) {} + +export class StructureResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + tenantId: string; + + @ApiProperty() + name: string; + + @ApiPropertyOptional() + description: string | null; + + @ApiProperty({ enum: StructureType }) + type: StructureType; + + @ApiProperty() + config: Record; + + @ApiProperty({ type: [LevelRateDto] }) + levelRates: LevelRate[]; + + @ApiProperty({ type: [LevelRateDto] }) + matchingRates: LevelRate[]; + + @ApiProperty() + isActive: boolean; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + updatedAt: Date; +} + +export class StructureFiltersDto { + @ApiPropertyOptional({ enum: StructureType }) + @IsOptional() + @IsEnum(StructureType) + type?: StructureType; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; +} diff --git a/src/modules/mlm/entities/bonus.entity.ts b/src/modules/mlm/entities/bonus.entity.ts new file mode 100644 index 0000000..d78fb9d --- /dev/null +++ b/src/modules/mlm/entities/bonus.entity.ts @@ -0,0 +1,76 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { NodeEntity } from './node.entity'; +import { RankEntity } from './rank.entity'; +import { CommissionStatus } from './commission.entity'; + +export enum BonusType { + RANK_ACHIEVEMENT = 'rank_achievement', + RANK_MAINTENANCE = 'rank_maintenance', + FAST_START = 'fast_start', + POOL_SHARE = 'pool_share', +} + +@Entity({ schema: 'mlm', name: 'bonuses' }) +export class BonusEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'node_id', type: 'uuid' }) + nodeId: string; + + @Column({ name: 'rank_id', type: 'uuid', nullable: true }) + rankId: string | null; + + @Column({ + type: 'enum', + enum: BonusType, + }) + type: BonusType; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + amount: number; + + @Column({ type: 'varchar', length: 3, default: 'USD' }) + currency: string; + + @Column({ name: 'period_id', type: 'uuid', nullable: true }) + periodId: string | null; + + @Column({ + type: 'enum', + enum: CommissionStatus, + default: CommissionStatus.PENDING, + }) + status: CommissionStatus; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt: Date | null; + + @Column({ name: 'achieved_at', type: 'timestamptz', default: () => 'NOW()' }) + achievedAt: Date; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => NodeEntity, (node) => node.bonuses, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'node_id' }) + node: NodeEntity; + + @ManyToOne(() => RankEntity, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'rank_id' }) + rank: RankEntity | null; +} diff --git a/src/modules/mlm/entities/commission.entity.ts b/src/modules/mlm/entities/commission.entity.ts new file mode 100644 index 0000000..28e82ff --- /dev/null +++ b/src/modules/mlm/entities/commission.entity.ts @@ -0,0 +1,91 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { NodeEntity } from './node.entity'; + +export enum CommissionType { + LEVEL = 'level', + MATCHING = 'matching', + INFINITY = 'infinity', + LEADERSHIP = 'leadership', + POOL = 'pool', +} + +export enum CommissionStatus { + PENDING = 'pending', + APPROVED = 'approved', + PAID = 'paid', + CANCELLED = 'cancelled', +} + +@Entity({ schema: 'mlm', name: 'commissions' }) +export class CommissionEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'node_id', type: 'uuid' }) + nodeId: string; + + @Column({ name: 'source_node_id', type: 'uuid' }) + sourceNodeId: string; + + @Column({ + type: 'enum', + enum: CommissionType, + }) + type: CommissionType; + + @Column({ type: 'integer' }) + level: number; + + @Column({ name: 'source_amount', type: 'decimal', precision: 15, scale: 2 }) + sourceAmount: number; + + @Column({ name: 'rate_applied', type: 'decimal', precision: 10, scale: 4 }) + rateApplied: number; + + @Column({ name: 'commission_amount', type: 'decimal', precision: 15, scale: 2 }) + commissionAmount: number; + + @Column({ type: 'varchar', length: 3, default: 'USD' }) + currency: string; + + @Column({ name: 'period_id', type: 'uuid', nullable: true }) + periodId: string | null; + + @Column({ name: 'source_reference', type: 'varchar', length: 200, nullable: true }) + sourceReference: string | null; + + @Column({ + type: 'enum', + enum: CommissionStatus, + default: CommissionStatus.PENDING, + }) + status: CommissionStatus; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt: Date | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => NodeEntity, (node) => node.commissionsReceived, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'node_id' }) + node: NodeEntity; + + @ManyToOne(() => NodeEntity, (node) => node.commissionsGenerated, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'source_node_id' }) + sourceNode: NodeEntity; +} diff --git a/src/modules/mlm/entities/index.ts b/src/modules/mlm/entities/index.ts new file mode 100644 index 0000000..1f17051 --- /dev/null +++ b/src/modules/mlm/entities/index.ts @@ -0,0 +1,6 @@ +export * from './structure.entity'; +export * from './rank.entity'; +export * from './node.entity'; +export * from './commission.entity'; +export * from './bonus.entity'; +export * from './rank-history.entity'; diff --git a/src/modules/mlm/entities/node.entity.ts b/src/modules/mlm/entities/node.entity.ts new file mode 100644 index 0000000..570dd00 --- /dev/null +++ b/src/modules/mlm/entities/node.entity.ts @@ -0,0 +1,132 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { StructureEntity } from './structure.entity'; +import { RankEntity } from './rank.entity'; +import { CommissionEntity } from './commission.entity'; +import { BonusEntity } from './bonus.entity'; +import { RankHistoryEntity } from './rank-history.entity'; + +export enum NodeStatus { + PENDING = 'pending', + ACTIVE = 'active', + INACTIVE = 'inactive', + SUSPENDED = 'suspended', +} + +@Entity({ schema: 'mlm', name: 'nodes' }) +export class NodeEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'structure_id', type: 'uuid' }) + structureId: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string | null; + + @Column({ name: 'sponsor_id', type: 'uuid', nullable: true }) + sponsorId: string | null; + + @Column({ type: 'integer', nullable: true }) + position: number | null; + + // LTREE path - stored as string, handled specially in queries + @Column({ type: 'text', nullable: true }) + path: string | null; + + @Column({ type: 'integer', default: 0 }) + depth: number; + + @Column({ name: 'rank_id', type: 'uuid', nullable: true }) + rankId: string | null; + + @Column({ name: 'highest_rank_id', type: 'uuid', nullable: true }) + highestRankId: string | null; + + @Column({ name: 'personal_volume', type: 'decimal', precision: 15, scale: 2, default: 0 }) + personalVolume: number; + + @Column({ name: 'group_volume', type: 'decimal', precision: 15, scale: 2, default: 0 }) + groupVolume: number; + + @Column({ name: 'direct_referrals', type: 'integer', default: 0 }) + directReferrals: number; + + @Column({ name: 'total_downline', type: 'integer', default: 0 }) + totalDownline: number; + + @Column({ name: 'total_earnings', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalEarnings: number; + + @Column({ + type: 'enum', + enum: NodeStatus, + default: NodeStatus.ACTIVE, + }) + status: NodeStatus; + + @Column({ name: 'joined_at', type: 'timestamptz', default: () => 'NOW()' }) + joinedAt: Date; + + @Column({ name: 'invite_code', type: 'varchar', length: 20, nullable: true, unique: true }) + inviteCode: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => StructureEntity, (structure) => structure.nodes, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'structure_id' }) + structure: StructureEntity; + + @ManyToOne(() => NodeEntity, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'parent_id' }) + parent: NodeEntity | null; + + @ManyToOne(() => NodeEntity, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'sponsor_id' }) + sponsor: NodeEntity | null; + + @OneToMany(() => NodeEntity, (node) => node.parent) + children: NodeEntity[]; + + @OneToMany(() => NodeEntity, (node) => node.sponsor) + referrals: NodeEntity[]; + + @ManyToOne(() => RankEntity, (rank) => rank.currentNodes, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'rank_id' }) + rank: RankEntity | null; + + @ManyToOne(() => RankEntity, (rank) => rank.highestRankNodes, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'highest_rank_id' }) + highestRank: RankEntity | null; + + @OneToMany(() => CommissionEntity, (commission) => commission.node) + commissionsReceived: CommissionEntity[]; + + @OneToMany(() => CommissionEntity, (commission) => commission.sourceNode) + commissionsGenerated: CommissionEntity[]; + + @OneToMany(() => BonusEntity, (bonus) => bonus.node) + bonuses: BonusEntity[]; + + @OneToMany(() => RankHistoryEntity, (history) => history.node) + rankHistory: RankHistoryEntity[]; +} diff --git a/src/modules/mlm/entities/rank-history.entity.ts b/src/modules/mlm/entities/rank-history.entity.ts new file mode 100644 index 0000000..27741c6 --- /dev/null +++ b/src/modules/mlm/entities/rank-history.entity.ts @@ -0,0 +1,53 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { NodeEntity } from './node.entity'; +import { RankEntity } from './rank.entity'; + +@Entity({ schema: 'mlm', name: 'rank_history' }) +export class RankHistoryEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'node_id', type: 'uuid' }) + nodeId: string; + + @Column({ name: 'rank_id', type: 'uuid' }) + rankId: string; + + @Column({ name: 'previous_rank_id', type: 'uuid', nullable: true }) + previousRankId: string | null; + + @Column({ name: 'personal_volume_at', type: 'decimal', precision: 15, scale: 2, nullable: true }) + personalVolumeAt: number | null; + + @Column({ name: 'group_volume_at', type: 'decimal', precision: 15, scale: 2, nullable: true }) + groupVolumeAt: number | null; + + @Column({ name: 'direct_referrals_at', type: 'integer', nullable: true }) + directReferralsAt: number | null; + + @CreateDateColumn({ name: 'achieved_at', type: 'timestamptz' }) + achievedAt: Date; + + // Relations + @ManyToOne(() => NodeEntity, (node) => node.rankHistory, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'node_id' }) + node: NodeEntity; + + @ManyToOne(() => RankEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'rank_id' }) + rank: RankEntity; + + @ManyToOne(() => RankEntity, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'previous_rank_id' }) + previousRank: RankEntity | null; +} diff --git a/src/modules/mlm/entities/rank.entity.ts b/src/modules/mlm/entities/rank.entity.ts new file mode 100644 index 0000000..55da6ff --- /dev/null +++ b/src/modules/mlm/entities/rank.entity.ts @@ -0,0 +1,82 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { StructureEntity } from './structure.entity'; +import { NodeEntity } from './node.entity'; + +export interface RankRequirements { + personalVolume?: number; + groupVolume?: number; + directReferrals?: number; + activeLegs?: number; + rankInLegs?: { + rankLevel: number; + count: number; + }; +} + +export interface RankBenefits { + discount?: number; + access?: string[]; + features?: string[]; +} + +@Entity({ schema: 'mlm', name: 'ranks' }) +export class RankEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'structure_id', type: 'uuid' }) + structureId: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'integer' }) + level: number; + + @Column({ name: 'badge_url', type: 'varchar', length: 500, nullable: true }) + badgeUrl: string | null; + + @Column({ type: 'varchar', length: 7, nullable: true }) + color: string | null; + + @Column({ type: 'jsonb', default: {} }) + requirements: RankRequirements; + + @Column({ name: 'bonus_rate', type: 'decimal', precision: 10, scale: 4, nullable: true }) + bonusRate: number | null; + + @Column({ type: 'jsonb', default: {} }) + benefits: RankBenefits; + + @Column({ name: 'is_active', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => StructureEntity, (structure) => structure.ranks, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'structure_id' }) + structure: StructureEntity; + + @OneToMany(() => NodeEntity, (node) => node.rank) + currentNodes: NodeEntity[]; + + @OneToMany(() => NodeEntity, (node) => node.highestRank) + highestRankNodes: NodeEntity[]; +} diff --git a/src/modules/mlm/entities/structure.entity.ts b/src/modules/mlm/entities/structure.entity.ts new file mode 100644 index 0000000..76e73cd --- /dev/null +++ b/src/modules/mlm/entities/structure.entity.ts @@ -0,0 +1,87 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, +} from 'typeorm'; +import { NodeEntity } from './node.entity'; +import { RankEntity } from './rank.entity'; + +export enum StructureType { + UNILEVEL = 'unilevel', + BINARY = 'binary', + MATRIX = 'matrix', + HYBRID = 'hybrid', +} + +export interface UnilevelConfig { + maxWidth?: number | null; + maxDepth: number; +} + +export interface BinaryConfig { + spillover: 'left_first' | 'weak_leg' | 'balanced'; +} + +export interface MatrixConfig { + width: number; + depth: number; +} + +export type StructureConfig = UnilevelConfig | BinaryConfig | MatrixConfig; + +export interface LevelRate { + level: number; + rate: number; +} + +@Entity({ schema: 'mlm', name: 'structures' }) +export class StructureEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ + type: 'enum', + enum: StructureType, + }) + type: StructureType; + + @Column({ type: 'jsonb', default: {} }) + config: StructureConfig; + + @Column({ name: 'level_rates', type: 'jsonb', default: [] }) + levelRates: LevelRate[]; + + @Column({ name: 'matching_rates', type: 'jsonb', default: [] }) + matchingRates: LevelRate[]; + + @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 | null; + + // Relations + @OneToMany(() => NodeEntity, (node) => node.structure) + nodes: NodeEntity[]; + + @OneToMany(() => RankEntity, (rank) => rank.structure) + ranks: RankEntity[]; +} diff --git a/src/modules/mlm/index.ts b/src/modules/mlm/index.ts new file mode 100644 index 0000000..9438bed --- /dev/null +++ b/src/modules/mlm/index.ts @@ -0,0 +1,5 @@ +export * from './mlm.module'; +export * from './entities'; +export * from './dto'; +export * from './services'; +export * from './controllers'; diff --git a/src/modules/mlm/mlm.module.ts b/src/modules/mlm/mlm.module.ts new file mode 100644 index 0000000..122b3b2 --- /dev/null +++ b/src/modules/mlm/mlm.module.ts @@ -0,0 +1,54 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { + StructureEntity, + RankEntity, + NodeEntity, + CommissionEntity, + BonusEntity, + RankHistoryEntity, +} from './entities'; +import { + StructuresService, + RanksService, + NodesService, + CommissionsService, +} from './services'; +import { + StructuresController, + RanksController, + NodesController, + CommissionsController, +} from './controllers'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + StructureEntity, + RankEntity, + NodeEntity, + CommissionEntity, + BonusEntity, + RankHistoryEntity, + ]), + ], + controllers: [ + StructuresController, + RanksController, + NodesController, + CommissionsController, + ], + providers: [ + StructuresService, + RanksService, + NodesService, + CommissionsService, + ], + exports: [ + StructuresService, + RanksService, + NodesService, + CommissionsService, + ], +}) +export class MlmModule {} diff --git a/src/modules/mlm/services/commissions.service.ts b/src/modules/mlm/services/commissions.service.ts new file mode 100644 index 0000000..8a4e995 --- /dev/null +++ b/src/modules/mlm/services/commissions.service.ts @@ -0,0 +1,275 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CommissionEntity, CommissionType, CommissionStatus } from '../entities/commission.entity'; +import { BonusEntity, BonusType } from '../entities/bonus.entity'; +import { NodeEntity } from '../entities/node.entity'; +import { StructureEntity } from '../entities/structure.entity'; +import { + CalculateCommissionsDto, + UpdateCommissionStatusDto, + CommissionFiltersDto, + CommissionsByLevelDto, + EarningsSummaryDto, +} from '../dto/commission.dto'; + +@Injectable() +export class CommissionsService { + constructor( + @InjectRepository(CommissionEntity) + private readonly commissionRepository: Repository, + @InjectRepository(BonusEntity) + private readonly bonusRepository: Repository, + @InjectRepository(NodeEntity) + private readonly nodeRepository: Repository, + @InjectRepository(StructureEntity) + private readonly structureRepository: Repository, + ) {} + + async findAll( + tenantId: string, + filters?: CommissionFiltersDto, + ): Promise<{ items: CommissionEntity[]; total: number }> { + const where: any = { tenantId }; + + if (filters?.nodeId) { + where.nodeId = filters.nodeId; + } + if (filters?.sourceNodeId) { + where.sourceNodeId = filters.sourceNodeId; + } + if (filters?.type) { + where.type = filters.type; + } + if (filters?.level) { + where.level = filters.level; + } + if (filters?.status) { + where.status = filters.status; + } + if (filters?.periodId) { + where.periodId = filters.periodId; + } + + const page = filters?.page || 1; + const limit = filters?.limit || 50; + const skip = (page - 1) * limit; + + const [items, total] = await this.commissionRepository.findAndCount({ + where, + skip, + take: limit, + order: { createdAt: 'DESC' }, + }); + + return { items, total }; + } + + async findOne(tenantId: string, id: string): Promise { + const commission = await this.commissionRepository.findOne({ + where: { id, tenantId }, + }); + + if (!commission) { + throw new NotFoundException(`Commission with ID ${id} not found`); + } + + return commission; + } + + async calculateCommissions( + tenantId: string, + dto: CalculateCommissionsDto, + ): Promise { + // Get source node and its upline + const sourceNode = await this.nodeRepository.findOne({ + where: { id: dto.sourceNodeId, tenantId }, + }); + + if (!sourceNode) { + throw new NotFoundException(`Source node with ID ${dto.sourceNodeId} not found`); + } + + // Get structure for commission rates + const structure = await this.structureRepository.findOne({ + where: { id: sourceNode.structureId, tenantId }, + }); + + if (!structure) { + throw new NotFoundException('Structure not found'); + } + + // Get upline (ancestors) + const upline = await this.getUpline(tenantId, dto.sourceNodeId); + + const commissions: CommissionEntity[] = []; + + // Calculate level commissions for each ancestor + for (let i = 0; i < upline.length; i++) { + const level = i + 1; + const levelRate = structure.levelRates.find((lr) => lr.level === level); + + if (!levelRate) { + continue; // No rate defined for this level + } + + const commissionAmount = dto.amount * levelRate.rate; + + const commission = this.commissionRepository.create({ + tenantId, + nodeId: upline[i].id, + sourceNodeId: dto.sourceNodeId, + type: CommissionType.LEVEL, + level, + sourceAmount: dto.amount, + rateApplied: levelRate.rate, + commissionAmount, + currency: dto.currency || 'USD', + periodId: dto.periodId, + sourceReference: dto.sourceReference, + status: CommissionStatus.PENDING, + }); + + commissions.push(commission); + } + + // Save all commissions + if (commissions.length > 0) { + await this.commissionRepository.save(commissions); + + // Update total earnings for each beneficiary + for (const commission of commissions) { + await this.nodeRepository.increment( + { id: commission.nodeId }, + 'totalEarnings', + commission.commissionAmount, + ); + } + } + + return commissions; + } + + async updateStatus( + tenantId: string, + id: string, + dto: UpdateCommissionStatusDto, + ): Promise { + const commission = await this.findOne(tenantId, id); + + commission.status = dto.status; + if (dto.status === CommissionStatus.PAID) { + commission.paidAt = new Date(); + } + + return this.commissionRepository.save(commission); + } + + async getCommissionsByLevel(tenantId: string, nodeId?: string): Promise { + const query = this.commissionRepository + .createQueryBuilder('c') + .select('c.level', 'level') + .addSelect('COUNT(*)', 'count') + .addSelect('SUM(c.commission_amount)', 'totalAmount') + .where('c.tenant_id = :tenantId', { tenantId }); + + if (nodeId) { + query.andWhere('c.node_id = :nodeId', { nodeId }); + } + + const result = await query.groupBy('c.level').orderBy('c.level', 'ASC').getRawMany(); + + return result.map((r) => ({ + level: parseInt(r.level), + count: parseInt(r.count), + totalAmount: parseFloat(r.totalAmount) || 0, + })); + } + + async getEarningsSummary(tenantId: string, nodeId: string): Promise { + // Get commission totals + const commissionsResult = await this.commissionRepository + .createQueryBuilder('c') + .select('SUM(c.commission_amount)', 'total') + .addSelect("SUM(CASE WHEN c.status = 'pending' THEN c.commission_amount ELSE 0 END)", 'pending') + .addSelect("SUM(CASE WHEN c.status = 'paid' THEN c.commission_amount ELSE 0 END)", 'paid') + .where('c.tenant_id = :tenantId', { tenantId }) + .andWhere('c.node_id = :nodeId', { nodeId }) + .getRawOne(); + + // Get bonus totals + const bonusesResult = await this.bonusRepository + .createQueryBuilder('b') + .select('SUM(b.amount)', 'total') + .where('b.tenant_id = :tenantId', { tenantId }) + .andWhere('b.node_id = :nodeId', { nodeId }) + .getRawOne(); + + // Get by level breakdown + const byLevel = await this.getCommissionsByLevel(tenantId, nodeId); + + const totalCommissions = parseFloat(commissionsResult?.total) || 0; + const totalBonuses = parseFloat(bonusesResult?.total) || 0; + + return { + totalCommissions, + totalBonuses, + totalEarnings: totalCommissions + totalBonuses, + pendingAmount: parseFloat(commissionsResult?.pending) || 0, + paidAmount: parseFloat(commissionsResult?.paid) || 0, + byLevel, + }; + } + + async createBonus( + tenantId: string, + nodeId: string, + type: BonusType, + amount: number, + rankId?: string, + periodId?: string, + ): Promise { + const bonus = this.bonusRepository.create({ + tenantId, + nodeId, + rankId, + type, + amount, + currency: 'USD', + periodId, + status: CommissionStatus.PENDING, + }); + + await this.bonusRepository.save(bonus); + + // Update node's total earnings + await this.nodeRepository.increment({ id: nodeId }, 'totalEarnings', amount); + + return bonus; + } + + private async getUpline(tenantId: string, nodeId: string): Promise { + const node = await this.nodeRepository.findOne({ + where: { id: nodeId, tenantId }, + }); + + if (!node || !node.path) { + return []; + } + + // Get ancestor IDs from path + const pathParts = node.path.split('.').slice(0, -1); // Exclude self + const ancestorPaths = pathParts.map((_, i) => pathParts.slice(0, i + 1).join('.')); + + if (ancestorPaths.length === 0) { + return []; + } + + return this.nodeRepository + .createQueryBuilder('node') + .where('node.tenant_id = :tenantId', { tenantId }) + .andWhere('node.path IN (:...paths)', { paths: ancestorPaths }) + .orderBy('node.depth', 'DESC') // Closest first + .getMany(); + } +} diff --git a/src/modules/mlm/services/index.ts b/src/modules/mlm/services/index.ts new file mode 100644 index 0000000..f137878 --- /dev/null +++ b/src/modules/mlm/services/index.ts @@ -0,0 +1,4 @@ +export * from './structures.service'; +export * from './ranks.service'; +export * from './nodes.service'; +export * from './commissions.service'; diff --git a/src/modules/mlm/services/nodes.service.ts b/src/modules/mlm/services/nodes.service.ts new file mode 100644 index 0000000..9d367cf --- /dev/null +++ b/src/modules/mlm/services/nodes.service.ts @@ -0,0 +1,395 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, ILike } from 'typeorm'; +import { randomBytes } from 'crypto'; +import { NodeEntity, NodeStatus } from '../entities/node.entity'; +import { StructureEntity, StructureType } from '../entities/structure.entity'; +import { RankEntity } from '../entities/rank.entity'; +import { + CreateNodeDto, + UpdateNodeDto, + UpdateNodeStatusDto, + NodeFiltersDto, + TreeNodeDto, + MyNetworkSummaryDto, +} from '../dto/node.dto'; + +@Injectable() +export class NodesService { + constructor( + @InjectRepository(NodeEntity) + private readonly nodeRepository: Repository, + @InjectRepository(StructureEntity) + private readonly structureRepository: Repository, + @InjectRepository(RankEntity) + private readonly rankRepository: Repository, + ) {} + + async findAll( + tenantId: string, + filters?: NodeFiltersDto, + ): Promise<{ items: NodeEntity[]; total: number }> { + const where: any = { tenantId }; + + if (filters?.structureId) { + where.structureId = filters.structureId; + } + if (filters?.parentId) { + where.parentId = filters.parentId; + } + if (filters?.sponsorId) { + where.sponsorId = filters.sponsorId; + } + if (filters?.status) { + where.status = filters.status; + } + + const page = filters?.page || 1; + const limit = filters?.limit || 50; + const skip = (page - 1) * limit; + + const [items, total] = await this.nodeRepository.findAndCount({ + where, + skip, + take: limit, + order: { createdAt: 'DESC' }, + }); + + return { items, total }; + } + + async findOne(tenantId: string, id: string): Promise { + const node = await this.nodeRepository.findOne({ + where: { id, tenantId }, + relations: ['rank', 'highestRank'], + }); + + if (!node) { + throw new NotFoundException(`Node with ID ${id} not found`); + } + + return node; + } + + async findByUserId(tenantId: string, userId: string, structureId?: string): Promise { + const where: any = { tenantId, userId }; + if (structureId) { + where.structureId = structureId; + } + + return this.nodeRepository.findOne({ where }); + } + + async create(tenantId: string, dto: CreateNodeDto): Promise { + // Verify structure exists + const structure = await this.structureRepository.findOne({ + where: { id: dto.structureId, tenantId }, + }); + + if (!structure) { + throw new NotFoundException(`Structure with ID ${dto.structureId} not found`); + } + + // Check if user already exists in this structure + const existingNode = await this.nodeRepository.findOne({ + where: { structureId: dto.structureId, userId: dto.userId }, + }); + + if (existingNode) { + throw new BadRequestException('User already exists in this structure'); + } + + // Calculate depth and path + let depth = 0; + let path = ''; + let parentNode: NodeEntity | null = null; + + if (dto.parentId) { + parentNode = await this.nodeRepository.findOne({ + where: { id: dto.parentId, tenantId }, + }); + + if (!parentNode) { + throw new NotFoundException(`Parent node with ID ${dto.parentId} not found`); + } + + depth = parentNode.depth + 1; + path = parentNode.path ? `${parentNode.path}.` : ''; + } + + // Validate position for binary structure + if (structure.type === StructureType.BINARY) { + if (dto.position && (dto.position < 1 || dto.position > 2)) { + throw new BadRequestException('Position must be 1 (left) or 2 (right) for binary structure'); + } + } + + // Generate invite code + const inviteCode = randomBytes(8).toString('hex').toUpperCase(); + + // Create node (we'll update path after we have the ID) + const node = this.nodeRepository.create({ + ...dto, + tenantId, + depth, + inviteCode, + }); + + const savedNode = await this.nodeRepository.save(node); + + // Update path with the node's own ID + savedNode.path = path + savedNode.id.replace(/-/g, '_'); + await this.nodeRepository.save(savedNode); + + // Update parent's metrics + if (parentNode) { + await this.updateParentMetrics(parentNode.id); + } + + // Update sponsor's metrics + if (dto.sponsorId && dto.sponsorId !== dto.parentId) { + await this.updateSponsorMetrics(dto.sponsorId); + } + + return savedNode; + } + + async update(tenantId: string, id: string, dto: UpdateNodeDto): Promise { + const node = await this.findOne(tenantId, id); + + if (dto.parentId) { + const newParent = await this.nodeRepository.findOne({ + where: { id: dto.parentId, tenantId }, + }); + + if (!newParent) { + throw new NotFoundException(`Parent node with ID ${dto.parentId} not found`); + } + + // Update depth and path + node.parentId = dto.parentId; + node.depth = newParent.depth + 1; + node.path = newParent.path ? `${newParent.path}.${node.id.replace(/-/g, '_')}` : node.id.replace(/-/g, '_'); + } + + if (dto.position !== undefined) { + node.position = dto.position; + } + + return this.nodeRepository.save(node); + } + + async updateStatus(tenantId: string, id: string, dto: UpdateNodeStatusDto): Promise { + const node = await this.findOne(tenantId, id); + node.status = dto.status; + return this.nodeRepository.save(node); + } + + async getDownline(tenantId: string, nodeId: string, maxDepth?: number): Promise { + const node = await this.findOne(tenantId, nodeId); + + if (!node.path) { + return []; + } + + // Use LTREE query to find all descendants + const query = this.nodeRepository + .createQueryBuilder('node') + .where('node.tenant_id = :tenantId', { tenantId }) + .andWhere('node.path <@ :path', { path: node.path }) + .andWhere('node.id != :nodeId', { nodeId }); + + if (maxDepth) { + query.andWhere('node.depth <= :maxDepth', { maxDepth: node.depth + maxDepth }); + } + + return query.orderBy('node.depth', 'ASC').getMany(); + } + + async getUpline(tenantId: string, nodeId: string): Promise { + const node = await this.findOne(tenantId, nodeId); + + if (!node.path) { + return []; + } + + // Get ancestor IDs from path + const pathParts = node.path.split('.').slice(0, -1); // Exclude self + const ancestorPaths = pathParts.map((_, i) => pathParts.slice(0, i + 1).join('.')); + + if (ancestorPaths.length === 0) { + return []; + } + + return this.nodeRepository + .createQueryBuilder('node') + .where('node.tenant_id = :tenantId', { tenantId }) + .andWhere('node.path IN (:...paths)', { paths: ancestorPaths }) + .orderBy('node.depth', 'DESC') + .getMany(); + } + + async getTree(tenantId: string, nodeId: string, maxDepth: number = 3): Promise { + const rootNode = await this.findOne(tenantId, nodeId); + + const descendants = await this.getDownline(tenantId, nodeId, maxDepth); + + return this.buildTree(rootNode, descendants); + } + + private buildTree(root: NodeEntity, descendants: NodeEntity[]): TreeNodeDto { + const childrenMap = new Map(); + + for (const node of descendants) { + if (node.parentId) { + const children = childrenMap.get(node.parentId) || []; + children.push(node); + childrenMap.set(node.parentId, children); + } + } + + const buildNode = (node: NodeEntity): TreeNodeDto => { + const children = childrenMap.get(node.id) || []; + return { + id: node.id, + userId: node.userId, + depth: node.depth, + position: node.position, + personalVolume: node.personalVolume, + groupVolume: node.groupVolume, + directReferrals: node.directReferrals, + status: node.status, + children: children.map(buildNode), + }; + }; + + return buildNode(root); + } + + async generateInviteLink(tenantId: string, nodeId: string): Promise<{ inviteCode: string; inviteUrl: string }> { + const node = await this.findOne(tenantId, nodeId); + + if (!node.inviteCode) { + node.inviteCode = randomBytes(8).toString('hex').toUpperCase(); + await this.nodeRepository.save(node); + } + + // TODO: Get base URL from config + const baseUrl = process.env.FRONTEND_URL || 'https://app.example.com'; + const inviteUrl = `${baseUrl}/register?ref=${node.inviteCode}`; + + return { + inviteCode: node.inviteCode, + inviteUrl, + }; + } + + async getMyNetworkSummary(tenantId: string, userId: string): Promise { + const node = await this.findByUserId(tenantId, userId); + + if (!node) { + throw new NotFoundException('Node not found for current user'); + } + + const downline = await this.getDownline(tenantId, node.id); + const activeDownline = downline.filter((n) => n.status === NodeStatus.ACTIVE).length; + + // Get current rank + let currentRank = null; + if (node.rankId) { + const rank = await this.rankRepository.findOne({ where: { id: node.rankId } }); + if (rank) { + currentRank = { id: rank.id, name: rank.name, level: rank.level }; + } + } + + // Get next rank + let nextRank = null; + if (node.rankId) { + const ranks = await this.rankRepository.find({ + where: { structureId: node.structureId, isActive: true }, + order: { level: 'ASC' }, + }); + + const currentRankIndex = ranks.findIndex((r) => r.id === node.rankId); + if (currentRankIndex >= 0 && currentRankIndex < ranks.length - 1) { + const next = ranks[currentRankIndex + 1]; + nextRank = { + id: next.id, + name: next.name, + level: next.level, + requirements: next.requirements, + progress: this.calculateRankProgress(node, next), + }; + } + } + + return { + totalDownline: node.totalDownline, + directReferrals: node.directReferrals, + activeDownline, + personalVolume: node.personalVolume, + groupVolume: node.groupVolume, + totalEarnings: node.totalEarnings, + currentRank, + nextRank, + }; + } + + private calculateRankProgress(node: NodeEntity, rank: RankEntity): Record { + const progress: Record = {}; + const req = rank.requirements; + + if (req.personalVolume) { + progress.personalVolume = Math.min(100, (node.personalVolume / req.personalVolume) * 100); + } + if (req.groupVolume) { + progress.groupVolume = Math.min(100, (node.groupVolume / req.groupVolume) * 100); + } + if (req.directReferrals) { + progress.directReferrals = Math.min(100, (node.directReferrals / req.directReferrals) * 100); + } + + return progress; + } + + private async updateParentMetrics(parentId: string): Promise { + const parent = await this.nodeRepository.findOne({ where: { id: parentId } }); + if (!parent) return; + + // Count direct children + const directChildren = await this.nodeRepository.count({ + where: { parentId, status: NodeStatus.ACTIVE }, + }); + + // Get all descendants for total downline count + const descendants = await this.getDownline(parent.tenantId, parentId); + + parent.directReferrals = directChildren; + parent.totalDownline = descendants.length; + + // Calculate group volume + const groupVolume = descendants.reduce((sum, d) => sum + Number(d.personalVolume), 0); + parent.groupVolume = groupVolume; + + await this.nodeRepository.save(parent); + + // Recursively update ancestors + if (parent.parentId) { + await this.updateParentMetrics(parent.parentId); + } + } + + private async updateSponsorMetrics(sponsorId: string): Promise { + const sponsor = await this.nodeRepository.findOne({ where: { id: sponsorId } }); + if (!sponsor) return; + + // Count direct referrals + const directReferrals = await this.nodeRepository.count({ + where: { sponsorId, status: NodeStatus.ACTIVE }, + }); + + sponsor.directReferrals = directReferrals; + await this.nodeRepository.save(sponsor); + } +} diff --git a/src/modules/mlm/services/ranks.service.ts b/src/modules/mlm/services/ranks.service.ts new file mode 100644 index 0000000..fbe480b --- /dev/null +++ b/src/modules/mlm/services/ranks.service.ts @@ -0,0 +1,148 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { RankEntity } from '../entities/rank.entity'; +import { NodeEntity } from '../entities/node.entity'; +import { + CreateRankDto, + UpdateRankDto, + RankFiltersDto, +} from '../dto/rank.dto'; + +@Injectable() +export class RanksService { + constructor( + @InjectRepository(RankEntity) + private readonly rankRepository: Repository, + @InjectRepository(NodeEntity) + private readonly nodeRepository: Repository, + ) {} + + async findAll(tenantId: string, filters?: RankFiltersDto): Promise { + const where: any = { tenantId }; + + if (filters?.structureId) { + where.structureId = filters.structureId; + } + if (filters?.isActive !== undefined) { + where.isActive = filters.isActive; + } + + return this.rankRepository.find({ + where, + order: { level: 'ASC' }, + }); + } + + async findOne(tenantId: string, id: string): Promise { + const rank = await this.rankRepository.findOne({ + where: { id, tenantId }, + }); + + if (!rank) { + throw new NotFoundException(`Rank with ID ${id} not found`); + } + + return rank; + } + + async create(tenantId: string, dto: CreateRankDto): Promise { + const rank = this.rankRepository.create({ + ...dto, + tenantId, + }); + + return this.rankRepository.save(rank); + } + + async update(tenantId: string, id: string, dto: UpdateRankDto): Promise { + const rank = await this.findOne(tenantId, id); + + Object.assign(rank, dto); + + return this.rankRepository.save(rank); + } + + async remove(tenantId: string, id: string): Promise { + const rank = await this.findOne(tenantId, id); + await this.rankRepository.remove(rank); + } + + async evaluateRank(tenantId: string, nodeId: string): Promise { + const node = await this.nodeRepository.findOne({ + where: { id: nodeId, tenantId }, + }); + + if (!node) { + throw new NotFoundException(`Node with ID ${nodeId} not found`); + } + + // Get all ranks for the structure, ordered by level descending + const ranks = await this.rankRepository.find({ + where: { structureId: node.structureId, isActive: true }, + order: { level: 'DESC' }, + }); + + // Find the highest rank the node qualifies for + for (const rank of ranks) { + if (this.meetsRequirements(node, rank)) { + return rank; + } + } + + return null; + } + + private meetsRequirements(node: NodeEntity, rank: RankEntity): boolean { + const req = rank.requirements; + + if (req.personalVolume && node.personalVolume < req.personalVolume) { + return false; + } + + if (req.groupVolume && node.groupVolume < req.groupVolume) { + return false; + } + + if (req.directReferrals && node.directReferrals < req.directReferrals) { + return false; + } + + // TODO: Add more complex requirements (activeLegs, rankInLegs) + + return true; + } + + async evaluateAllNodes(tenantId: string, structureId: string): Promise { + const nodes = await this.nodeRepository.find({ + where: { tenantId, structureId }, + }); + + let updatedCount = 0; + + for (const node of nodes) { + const qualifiedRank = await this.evaluateRank(tenantId, node.id); + + if (qualifiedRank && qualifiedRank.id !== node.rankId) { + node.rankId = qualifiedRank.id; + + // Update highest rank if this is higher + if (!node.highestRankId) { + node.highestRankId = qualifiedRank.id; + } else { + const currentHighest = await this.rankRepository.findOne({ + where: { id: node.highestRankId }, + }); + if (currentHighest && qualifiedRank.level > currentHighest.level) { + node.highestRankId = qualifiedRank.id; + } + } + + await this.nodeRepository.save(node); + updatedCount++; + } + } + + return updatedCount; + } +} diff --git a/src/modules/mlm/services/structures.service.ts b/src/modules/mlm/services/structures.service.ts new file mode 100644 index 0000000..b67b4f1 --- /dev/null +++ b/src/modules/mlm/services/structures.service.ts @@ -0,0 +1,79 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, ILike } from 'typeorm'; +import { StructureEntity } from '../entities/structure.entity'; +import { + CreateStructureDto, + UpdateStructureDto, + StructureFiltersDto, +} from '../dto/structure.dto'; + +@Injectable() +export class StructuresService { + constructor( + @InjectRepository(StructureEntity) + private readonly structureRepository: Repository, + ) {} + + async findAll(tenantId: string, filters?: StructureFiltersDto): Promise { + const where: any = { tenantId }; + + if (filters?.type) { + where.type = filters.type; + } + if (filters?.isActive !== undefined) { + where.isActive = filters.isActive; + } + if (filters?.search) { + where.name = ILike(`%${filters.search}%`); + } + + return this.structureRepository.find({ + where, + order: { createdAt: 'DESC' }, + }); + } + + async findOne(tenantId: string, id: string): Promise { + const structure = await this.structureRepository.findOne({ + where: { id, tenantId }, + }); + + if (!structure) { + throw new NotFoundException(`Structure with ID ${id} not found`); + } + + return structure; + } + + async create( + tenantId: string, + userId: string, + dto: CreateStructureDto, + ): Promise { + const structure = this.structureRepository.create({ + ...dto, + tenantId, + createdBy: userId, + }); + + return this.structureRepository.save(structure); + } + + async update( + tenantId: string, + id: string, + dto: UpdateStructureDto, + ): Promise { + const structure = await this.findOne(tenantId, id); + + Object.assign(structure, dto); + + return this.structureRepository.save(structure); + } + + async remove(tenantId: string, id: string): Promise { + const structure = await this.findOne(tenantId, id); + await this.structureRepository.remove(structure); + } +}