diff --git a/src/app.module.ts b/src/app.module.ts index 61067bf..cb18b04 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -31,6 +31,7 @@ import { ReportsModule } from '@modules/reports/reports.module'; 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'; @Module({ imports: [ @@ -94,6 +95,7 @@ import { PortfolioModule } from '@modules/portfolio/portfolio.module'; SalesModule, CommissionsModule, PortfolioModule, + GoalsModule, ], }) export class AppModule {} diff --git a/src/modules/goals/controllers/assignments.controller.ts b/src/modules/goals/controllers/assignments.controller.ts new file mode 100644 index 0000000..a5a1890 --- /dev/null +++ b/src/modules/goals/controllers/assignments.controller.ts @@ -0,0 +1,195 @@ +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 { AssignmentsService } from '../services/assignments.service'; +import { + CreateAssignmentDto, + UpdateAssignmentDto, + UpdateAssignmentStatusDto, + UpdateProgressDto, + AssignmentFiltersDto, + AssignmentResponseDto, + ProgressLogResponseDto, + MyGoalsSummaryDto, + CompletionReportDto, + UserReportDto, +} from '../dto/assignment.dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@ApiTags('Goals - Assignments') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('goals') +export class AssignmentsController { + constructor(private readonly assignmentsService: AssignmentsService) {} + + // ───────────────────────────────────────────── + // Assignments CRUD + // ───────────────────────────────────────────── + + @Post('assignments') + @ApiOperation({ summary: 'Create a new goal assignment' }) + @ApiResponse({ status: 201, type: AssignmentResponseDto }) + create( + @CurrentUser() user: RequestUser, + @Body() dto: CreateAssignmentDto, + ) { + return this.assignmentsService.create(user.tenant_id, dto); + } + + @Get('assignments') + @ApiOperation({ summary: 'List all goal assignments' }) + @ApiResponse({ status: 200 }) + findAll( + @CurrentUser() user: RequestUser, + @Query() filters: AssignmentFiltersDto, + ) { + return this.assignmentsService.findAll(user.tenant_id, filters); + } + + @Get(':goalId/assignments') + @ApiOperation({ summary: 'List assignments for a specific goal' }) + @ApiResponse({ status: 200, type: [AssignmentResponseDto] }) + findByDefinition( + @CurrentUser() user: RequestUser, + @Param('goalId', ParseUUIDPipe) goalId: string, + ) { + return this.assignmentsService.findByDefinition(user.tenant_id, goalId); + } + + @Get('assignments/:id') + @ApiOperation({ summary: 'Get an assignment by ID' }) + @ApiResponse({ status: 200, type: AssignmentResponseDto }) + findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.assignmentsService.findOne(user.tenant_id, id); + } + + @Patch('assignments/:id') + @ApiOperation({ summary: 'Update an assignment' }) + @ApiResponse({ status: 200, type: AssignmentResponseDto }) + update( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateAssignmentDto, + ) { + return this.assignmentsService.update(user.tenant_id, id, dto); + } + + @Patch('assignments/:id/status') + @ApiOperation({ summary: 'Update assignment status' }) + @ApiResponse({ status: 200, type: AssignmentResponseDto }) + updateStatus( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateAssignmentStatusDto, + ) { + return this.assignmentsService.updateStatus(user.tenant_id, id, dto); + } + + @Post('assignments/:id/progress') + @ApiOperation({ summary: 'Update assignment progress' }) + @ApiResponse({ status: 200, type: AssignmentResponseDto }) + updateProgress( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateProgressDto, + ) { + return this.assignmentsService.updateProgress(user.tenant_id, id, user.id, dto); + } + + @Get('assignments/:id/history') + @ApiOperation({ summary: 'Get assignment progress history' }) + @ApiResponse({ status: 200, type: [ProgressLogResponseDto] }) + getProgressHistory( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.assignmentsService.getProgressHistory(user.tenant_id, id); + } + + @Delete('assignments/:id') + @ApiOperation({ summary: 'Delete an assignment' }) + @ApiResponse({ status: 204 }) + remove( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.assignmentsService.remove(user.tenant_id, id); + } + + // ───────────────────────────────────────────── + // My Goals + // ───────────────────────────────────────────── + + @Get('my') + @ApiOperation({ summary: 'Get my goals' }) + @ApiResponse({ status: 200, type: [AssignmentResponseDto] }) + getMyGoals(@CurrentUser() user: RequestUser) { + return this.assignmentsService.getMyGoals(user.tenant_id, user.id); + } + + @Get('my/summary') + @ApiOperation({ summary: 'Get my goals summary' }) + @ApiResponse({ status: 200, type: MyGoalsSummaryDto }) + getMyGoalsSummary(@CurrentUser() user: RequestUser) { + return this.assignmentsService.getMyGoalsSummary(user.tenant_id, user.id); + } + + @Post('my/:id/update') + @ApiOperation({ summary: 'Update my goal progress' }) + @ApiResponse({ status: 200, type: AssignmentResponseDto }) + updateMyProgress( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateProgressDto, + ) { + return this.assignmentsService.updateProgress(user.tenant_id, id, user.id, dto); + } + + // ───────────────────────────────────────────── + // Reports + // ───────────────────────────────────────────── + + @Get('reports/completion') + @ApiOperation({ summary: 'Get completion report' }) + @ApiResponse({ status: 200, type: CompletionReportDto }) + getCompletionReport( + @CurrentUser() user: RequestUser, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ) { + return this.assignmentsService.getCompletionReport( + user.tenant_id, + startDate ? new Date(startDate) : undefined, + endDate ? new Date(endDate) : undefined, + ); + } + + @Get('reports/by-user') + @ApiOperation({ summary: 'Get report by user' }) + @ApiResponse({ status: 200, type: [UserReportDto] }) + getUserReport(@CurrentUser() user: RequestUser) { + return this.assignmentsService.getUserReport(user.tenant_id); + } +} diff --git a/src/modules/goals/controllers/definitions.controller.ts b/src/modules/goals/controllers/definitions.controller.ts new file mode 100644 index 0000000..bd460a8 --- /dev/null +++ b/src/modules/goals/controllers/definitions.controller.ts @@ -0,0 +1,120 @@ +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 { DefinitionsService } from '../services/definitions.service'; +import { + CreateDefinitionDto, + UpdateDefinitionDto, + UpdateDefinitionStatusDto, + DefinitionFiltersDto, + DefinitionResponseDto, +} from '../dto/definition.dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@ApiTags('Goals - Definitions') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('goals') +export class DefinitionsController { + constructor(private readonly definitionsService: DefinitionsService) {} + + @Post() + @ApiOperation({ summary: 'Create a new goal definition' }) + @ApiResponse({ status: 201, type: DefinitionResponseDto }) + create( + @CurrentUser() user: RequestUser, + @Body() dto: CreateDefinitionDto, + ) { + return this.definitionsService.create(user.tenant_id, user.id, dto); + } + + @Get() + @ApiOperation({ summary: 'List all goal definitions' }) + @ApiResponse({ status: 200 }) + findAll( + @CurrentUser() user: RequestUser, + @Query() filters: DefinitionFiltersDto, + ) { + return this.definitionsService.findAll(user.tenant_id, filters); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a goal definition by ID' }) + @ApiResponse({ status: 200, type: DefinitionResponseDto }) + findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.definitionsService.findOne(user.tenant_id, id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a goal definition' }) + @ApiResponse({ status: 200, type: DefinitionResponseDto }) + update( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateDefinitionDto, + ) { + return this.definitionsService.update(user.tenant_id, id, dto); + } + + @Patch(':id/status') + @ApiOperation({ summary: 'Update goal definition status' }) + @ApiResponse({ status: 200, type: DefinitionResponseDto }) + updateStatus( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateDefinitionStatusDto, + ) { + return this.definitionsService.updateStatus(user.tenant_id, id, dto); + } + + @Post(':id/activate') + @ApiOperation({ summary: 'Activate a goal definition' }) + @ApiResponse({ status: 200, type: DefinitionResponseDto }) + activate( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.definitionsService.activate(user.tenant_id, id); + } + + @Post(':id/duplicate') + @ApiOperation({ summary: 'Duplicate a goal definition' }) + @ApiResponse({ status: 201, type: DefinitionResponseDto }) + duplicate( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.definitionsService.duplicate(user.tenant_id, id, user.id); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a goal definition' }) + @ApiResponse({ status: 204 }) + remove( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.definitionsService.remove(user.tenant_id, id); + } +} diff --git a/src/modules/goals/controllers/index.ts b/src/modules/goals/controllers/index.ts new file mode 100644 index 0000000..e0151c2 --- /dev/null +++ b/src/modules/goals/controllers/index.ts @@ -0,0 +1,2 @@ +export * from './definitions.controller'; +export * from './assignments.controller'; diff --git a/src/modules/goals/dto/assignment.dto.ts b/src/modules/goals/dto/assignment.dto.ts new file mode 100644 index 0000000..41b188c --- /dev/null +++ b/src/modules/goals/dto/assignment.dto.ts @@ -0,0 +1,324 @@ +import { IsString, IsOptional, IsEnum, IsNumber, IsUUID, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { AssigneeType, AssignmentStatus } from '../entities/assignment.entity'; +import { ProgressSource } from '../entities/progress-log.entity'; + +// ───────────────────────────────────────────── +// Create Assignment DTO +// ───────────────────────────────────────────── + +export class CreateAssignmentDto { + @ApiProperty() + @IsUUID() + definitionId: string; + + @ApiPropertyOptional({ enum: AssigneeType, default: AssigneeType.USER }) + @IsOptional() + @IsEnum(AssigneeType) + assigneeType?: AssigneeType; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + userId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + teamId?: string; + + @ApiPropertyOptional({ example: 50000 }) + @IsOptional() + @IsNumber() + @Min(0) + customTarget?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} + +// ───────────────────────────────────────────── +// Update Assignment DTO +// ───────────────────────────────────────────── + +export class UpdateAssignmentDto extends PartialType(CreateAssignmentDto) {} + +// ───────────────────────────────────────────── +// Update Assignment Status DTO +// ───────────────────────────────────────────── + +export class UpdateAssignmentStatusDto { + @ApiProperty({ enum: AssignmentStatus }) + @IsEnum(AssignmentStatus) + status: AssignmentStatus; +} + +// ───────────────────────────────────────────── +// Update Progress DTO +// ───────────────────────────────────────────── + +export class UpdateProgressDto { + @ApiProperty({ example: 25000 }) + @IsNumber() + @Min(0) + value: number; + + @ApiPropertyOptional({ enum: ProgressSource, default: ProgressSource.MANUAL }) + @IsOptional() + @IsEnum(ProgressSource) + source?: ProgressSource; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + sourceReference?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} + +// ───────────────────────────────────────────── +// Assignment Response DTO +// ───────────────────────────────────────────── + +export class AssignmentResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + tenantId: string; + + @ApiProperty() + definitionId: string; + + @ApiProperty({ enum: AssigneeType }) + assigneeType: AssigneeType; + + @ApiPropertyOptional() + userId: string | null; + + @ApiPropertyOptional() + teamId: string | null; + + @ApiPropertyOptional() + customTarget: number | null; + + @ApiProperty() + currentValue: number; + + @ApiProperty() + progressPercentage: number; + + @ApiPropertyOptional() + lastUpdatedAt: Date | null; + + @ApiProperty({ enum: AssignmentStatus }) + status: AssignmentStatus; + + @ApiPropertyOptional() + achievedAt: Date | null; + + @ApiPropertyOptional() + notes: string | null; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + updatedAt: Date; + + // Nested objects + @ApiPropertyOptional() + definition?: { + id: string; + name: string; + targetValue: number; + unit: string | null; + startsAt: Date; + endsAt: Date; + }; + + @ApiPropertyOptional() + user?: { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + }; +} + +// ───────────────────────────────────────────── +// Assignment Filters DTO +// ───────────────────────────────────────────── + +export class AssignmentFiltersDto { + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + definitionId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + userId?: string; + + @ApiPropertyOptional({ enum: AssignmentStatus }) + @IsOptional() + @IsEnum(AssignmentStatus) + status?: AssignmentStatus; + + @ApiPropertyOptional({ enum: AssigneeType }) + @IsOptional() + @IsEnum(AssigneeType) + assigneeType?: AssigneeType; + + @ApiPropertyOptional({ description: 'Minimum progress percentage' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + @Max(100) + minProgress?: number; + + @ApiPropertyOptional({ description: 'Maximum progress percentage' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + @Max(100) + maxProgress?: number; + + @ApiPropertyOptional({ example: 'progressPercentage' }) + @IsOptional() + @IsString() + sortBy?: string; + + @ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' }) + @IsOptional() + @IsString() + sortOrder?: 'ASC' | 'DESC'; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + page?: number; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + limit?: number; +} + +// ───────────────────────────────────────────── +// Progress Log Response DTO +// ───────────────────────────────────────────── + +export class ProgressLogResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + assignmentId: string; + + @ApiPropertyOptional() + previousValue: number | null; + + @ApiProperty() + newValue: number; + + @ApiPropertyOptional() + changeAmount: number | null; + + @ApiProperty({ enum: ProgressSource }) + source: ProgressSource; + + @ApiPropertyOptional() + sourceReference: string | null; + + @ApiPropertyOptional() + notes: string | null; + + @ApiProperty() + loggedAt: Date; + + @ApiPropertyOptional() + loggedBy: string | null; +} + +// ───────────────────────────────────────────── +// My Goals Summary DTO +// ───────────────────────────────────────────── + +export class MyGoalsSummaryDto { + @ApiProperty() + totalAssignments: number; + + @ApiProperty() + activeAssignments: number; + + @ApiProperty() + achievedAssignments: number; + + @ApiProperty() + failedAssignments: number; + + @ApiProperty() + averageProgress: number; + + @ApiProperty() + atRiskCount: number; // < 50% progress with > 75% time elapsed +} + +// ───────────────────────────────────────────── +// Goal Report DTOs +// ───────────────────────────────────────────── + +export class CompletionReportDto { + @ApiProperty() + totalGoals: number; + + @ApiProperty() + achievedGoals: number; + + @ApiProperty() + failedGoals: number; + + @ApiProperty() + activeGoals: number; + + @ApiProperty() + completionRate: number; + + @ApiProperty() + averageProgress: number; +} + +export class UserReportDto { + @ApiProperty() + userId: string; + + @ApiPropertyOptional() + userName: string | null; + + @ApiProperty() + totalAssignments: number; + + @ApiProperty() + achieved: number; + + @ApiProperty() + failed: number; + + @ApiProperty() + active: number; + + @ApiProperty() + averageProgress: number; +} diff --git a/src/modules/goals/dto/definition.dto.ts b/src/modules/goals/dto/definition.dto.ts new file mode 100644 index 0000000..e6e35b3 --- /dev/null +++ b/src/modules/goals/dto/definition.dto.ts @@ -0,0 +1,273 @@ +import { IsString, IsOptional, IsEnum, IsNumber, IsDate, IsArray, IsObject, Min, ValidateNested, IsBoolean } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { GoalType, MetricType, PeriodType, DataSource, GoalStatus, SourceConfig, Milestone } from '../entities/definition.entity'; + +// ───────────────────────────────────────────── +// Milestone DTO +// ───────────────────────────────────────────── + +export class MilestoneDto { + @ApiProperty({ example: 50 }) + @IsNumber() + @Min(0) + percentage: number; + + @ApiProperty({ example: true }) + @IsBoolean() + notify: boolean; +} + +// ───────────────────────────────────────────── +// Source Config DTO +// ───────────────────────────────────────────── + +export class SourceConfigDto { + @ApiPropertyOptional({ example: 'sales' }) + @IsOptional() + @IsString() + module?: string; + + @ApiPropertyOptional({ example: 'opportunities' }) + @IsOptional() + @IsString() + entity?: string; + + @ApiPropertyOptional({ example: { status: 'won' } }) + @IsOptional() + @IsObject() + filter?: Record; + + @ApiPropertyOptional({ enum: ['sum', 'count', 'avg'] }) + @IsOptional() + @IsString() + aggregation?: 'sum' | 'count' | 'avg'; + + @ApiPropertyOptional({ example: 'amount' }) + @IsOptional() + @IsString() + field?: string; +} + +// ───────────────────────────────────────────── +// Create Definition DTO +// ───────────────────────────────────────────── + +export class CreateDefinitionDto { + @ApiProperty({ example: 'Q1 Sales Target' }) + @IsString() + name: string; + + @ApiPropertyOptional({ example: 'Achieve $100,000 in closed deals' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ example: 'sales' }) + @IsOptional() + @IsString() + category?: string; + + @ApiPropertyOptional({ enum: GoalType, default: GoalType.TARGET }) + @IsOptional() + @IsEnum(GoalType) + type?: GoalType; + + @ApiPropertyOptional({ enum: MetricType, default: MetricType.NUMBER }) + @IsOptional() + @IsEnum(MetricType) + metric?: MetricType; + + @ApiProperty({ example: 100000 }) + @IsNumber() + @Min(0) + targetValue: number; + + @ApiPropertyOptional({ example: 'USD' }) + @IsOptional() + @IsString() + unit?: string; + + @ApiPropertyOptional({ enum: PeriodType, default: PeriodType.MONTHLY }) + @IsOptional() + @IsEnum(PeriodType) + period?: PeriodType; + + @ApiProperty({ example: '2026-01-01' }) + @Type(() => Date) + @IsDate() + startsAt: Date; + + @ApiProperty({ example: '2026-03-31' }) + @Type(() => Date) + @IsDate() + endsAt: Date; + + @ApiPropertyOptional({ enum: DataSource, default: DataSource.MANUAL }) + @IsOptional() + @IsEnum(DataSource) + source?: DataSource; + + @ApiPropertyOptional({ type: SourceConfigDto }) + @IsOptional() + @ValidateNested() + @Type(() => SourceConfigDto) + sourceConfig?: SourceConfigDto; + + @ApiPropertyOptional({ type: [MilestoneDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MilestoneDto) + milestones?: MilestoneDto[]; + + @ApiPropertyOptional({ enum: GoalStatus, default: GoalStatus.DRAFT }) + @IsOptional() + @IsEnum(GoalStatus) + status?: GoalStatus; + + @ApiPropertyOptional({ type: [String], example: ['sales', 'q1'] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; +} + +// ───────────────────────────────────────────── +// Update Definition DTO +// ───────────────────────────────────────────── + +export class UpdateDefinitionDto extends PartialType(CreateDefinitionDto) {} + +// ───────────────────────────────────────────── +// Update Status DTO +// ───────────────────────────────────────────── + +export class UpdateDefinitionStatusDto { + @ApiProperty({ enum: GoalStatus }) + @IsEnum(GoalStatus) + status: GoalStatus; +} + +// ───────────────────────────────────────────── +// Definition Response DTO +// ───────────────────────────────────────────── + +export class DefinitionResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + tenantId: string; + + @ApiProperty() + name: string; + + @ApiPropertyOptional() + description: string | null; + + @ApiPropertyOptional() + category: string | null; + + @ApiProperty({ enum: GoalType }) + type: GoalType; + + @ApiProperty({ enum: MetricType }) + metric: MetricType; + + @ApiProperty() + targetValue: number; + + @ApiPropertyOptional() + unit: string | null; + + @ApiProperty({ enum: PeriodType }) + period: PeriodType; + + @ApiProperty() + startsAt: Date; + + @ApiProperty() + endsAt: Date; + + @ApiProperty({ enum: DataSource }) + source: DataSource; + + @ApiProperty() + sourceConfig: SourceConfig; + + @ApiProperty({ type: [MilestoneDto] }) + milestones: Milestone[]; + + @ApiProperty({ enum: GoalStatus }) + status: GoalStatus; + + @ApiProperty({ type: [String] }) + tags: string[]; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + updatedAt: Date; + + @ApiPropertyOptional() + createdBy: string | null; + + @ApiPropertyOptional() + assignmentCount?: number; +} + +// ───────────────────────────────────────────── +// Definition Filters DTO +// ───────────────────────────────────────────── + +export class DefinitionFiltersDto { + @ApiPropertyOptional({ enum: GoalStatus }) + @IsOptional() + @IsEnum(GoalStatus) + status?: GoalStatus; + + @ApiPropertyOptional({ enum: PeriodType }) + @IsOptional() + @IsEnum(PeriodType) + period?: PeriodType; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + category?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional() + @IsOptional() + @Type(() => Date) + @IsDate() + activeOn?: Date; + + @ApiPropertyOptional({ example: 'createdAt' }) + @IsOptional() + @IsString() + sortBy?: string; + + @ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' }) + @IsOptional() + @IsString() + sortOrder?: 'ASC' | 'DESC'; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + page?: number; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + limit?: number; +} diff --git a/src/modules/goals/dto/index.ts b/src/modules/goals/dto/index.ts new file mode 100644 index 0000000..ab460bd --- /dev/null +++ b/src/modules/goals/dto/index.ts @@ -0,0 +1,2 @@ +export * from './definition.dto'; +export * from './assignment.dto'; diff --git a/src/modules/goals/entities/assignment.entity.ts b/src/modules/goals/entities/assignment.entity.ts new file mode 100644 index 0000000..37cd36f --- /dev/null +++ b/src/modules/goals/entities/assignment.entity.ts @@ -0,0 +1,94 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { DefinitionEntity } from './definition.entity'; +import { ProgressLogEntity } from './progress-log.entity'; +import { MilestoneNotificationEntity } from './milestone-notification.entity'; + +export enum AssigneeType { + USER = 'user', + TEAM = 'team', + TENANT = 'tenant', +} + +export enum AssignmentStatus { + ACTIVE = 'active', + ACHIEVED = 'achieved', + FAILED = 'failed', + CANCELLED = 'cancelled', +} + +@Entity({ schema: 'goals', name: 'assignments' }) +export class AssignmentEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'definition_id', type: 'uuid' }) + definitionId: string; + + @Column({ + name: 'assignee_type', + type: 'enum', + enum: AssigneeType, + default: AssigneeType.USER, + }) + assigneeType: AssigneeType; + + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string | null; + + @Column({ name: 'team_id', type: 'uuid', nullable: true }) + teamId: string | null; + + @Column({ name: 'custom_target', type: 'decimal', precision: 15, scale: 2, nullable: true }) + customTarget: number | null; + + @Column({ name: 'current_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) + currentValue: number; + + @Column({ name: 'progress_percentage', type: 'decimal', precision: 5, scale: 2, default: 0 }) + progressPercentage: number; + + @Column({ name: 'last_updated_at', type: 'timestamptz', nullable: true }) + lastUpdatedAt: Date | null; + + @Column({ + type: 'enum', + enum: AssignmentStatus, + default: AssignmentStatus.ACTIVE, + }) + status: AssignmentStatus; + + @Column({ name: 'achieved_at', type: 'timestamptz', nullable: true }) + achievedAt: Date | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => DefinitionEntity, (definition) => definition.assignments, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'definition_id' }) + definition: DefinitionEntity; + + @OneToMany(() => ProgressLogEntity, (log) => log.assignment) + progressLogs: ProgressLogEntity[]; + + @OneToMany(() => MilestoneNotificationEntity, (notification) => notification.assignment) + milestoneNotifications: MilestoneNotificationEntity[]; +} diff --git a/src/modules/goals/entities/definition.entity.ts b/src/modules/goals/entities/definition.entity.ts new file mode 100644 index 0000000..e0638fa --- /dev/null +++ b/src/modules/goals/entities/definition.entity.ts @@ -0,0 +1,148 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, +} from 'typeorm'; +import { AssignmentEntity } from './assignment.entity'; + +export enum GoalType { + TARGET = 'target', + LIMIT = 'limit', + MAINTAIN = 'maintain', +} + +export enum MetricType { + NUMBER = 'number', + CURRENCY = 'currency', + PERCENTAGE = 'percentage', + BOOLEAN = 'boolean', + COUNT = 'count', +} + +export enum PeriodType { + DAILY = 'daily', + WEEKLY = 'weekly', + MONTHLY = 'monthly', + QUARTERLY = 'quarterly', + YEARLY = 'yearly', + CUSTOM = 'custom', +} + +export enum DataSource { + MANUAL = 'manual', + SALES = 'sales', + BILLING = 'billing', + COMMISSIONS = 'commissions', + CUSTOM = 'custom', +} + +export enum GoalStatus { + DRAFT = 'draft', + ACTIVE = 'active', + PAUSED = 'paused', + COMPLETED = 'completed', + CANCELLED = 'cancelled', +} + +export interface SourceConfig { + module?: string; + entity?: string; + filter?: Record; + aggregation?: 'sum' | 'count' | 'avg'; + field?: string; +} + +export interface Milestone { + percentage: number; + notify: boolean; +} + +@Entity({ schema: 'goals', name: 'definitions' }) +export class DefinitionEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true }) + category: string | null; + + @Column({ + type: 'enum', + enum: GoalType, + default: GoalType.TARGET, + }) + type: GoalType; + + @Column({ + type: 'enum', + enum: MetricType, + default: MetricType.NUMBER, + }) + metric: MetricType; + + @Column({ name: 'target_value', type: 'decimal', precision: 15, scale: 2 }) + targetValue: number; + + @Column({ type: 'varchar', length: 50, nullable: true }) + unit: string | null; + + @Column({ + type: 'enum', + enum: PeriodType, + default: PeriodType.MONTHLY, + }) + period: PeriodType; + + @Column({ name: 'starts_at', type: 'date' }) + startsAt: Date; + + @Column({ name: 'ends_at', type: 'date' }) + endsAt: Date; + + @Column({ + type: 'enum', + enum: DataSource, + default: DataSource.MANUAL, + }) + source: DataSource; + + @Column({ name: 'source_config', type: 'jsonb', default: {} }) + sourceConfig: SourceConfig; + + @Column({ type: 'jsonb', default: [] }) + milestones: Milestone[]; + + @Column({ + type: 'enum', + enum: GoalStatus, + default: GoalStatus.DRAFT, + }) + status: GoalStatus; + + @Column({ type: 'jsonb', default: [] }) + tags: string[]; + + @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(() => AssignmentEntity, (assignment) => assignment.definition) + assignments: AssignmentEntity[]; +} diff --git a/src/modules/goals/entities/index.ts b/src/modules/goals/entities/index.ts new file mode 100644 index 0000000..537e7c3 --- /dev/null +++ b/src/modules/goals/entities/index.ts @@ -0,0 +1,4 @@ +export * from './definition.entity'; +export * from './assignment.entity'; +export * from './progress-log.entity'; +export * from './milestone-notification.entity'; diff --git a/src/modules/goals/entities/milestone-notification.entity.ts b/src/modules/goals/entities/milestone-notification.entity.ts new file mode 100644 index 0000000..f105724 --- /dev/null +++ b/src/modules/goals/entities/milestone-notification.entity.ts @@ -0,0 +1,35 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { AssignmentEntity } from './assignment.entity'; + +@Entity({ schema: 'goals', name: 'milestone_notifications' }) +export class MilestoneNotificationEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'assignment_id', type: 'uuid' }) + assignmentId: string; + + @Column({ name: 'milestone_percentage', type: 'integer' }) + milestonePercentage: number; + + @Column({ name: 'achieved_value', type: 'decimal', precision: 15, scale: 2 }) + achievedValue: number; + + @CreateDateColumn({ name: 'notified_at', type: 'timestamptz' }) + notifiedAt: Date; + + // Relations + @ManyToOne(() => AssignmentEntity, (assignment) => assignment.milestoneNotifications, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'assignment_id' }) + assignment: AssignmentEntity; +} diff --git a/src/modules/goals/entities/progress-log.entity.ts b/src/modules/goals/entities/progress-log.entity.ts new file mode 100644 index 0000000..7e14de6 --- /dev/null +++ b/src/modules/goals/entities/progress-log.entity.ts @@ -0,0 +1,61 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { AssignmentEntity } from './assignment.entity'; + +export enum ProgressSource { + MANUAL = 'manual', + AUTOMATIC = 'automatic', + IMPORT = 'import', + API = 'api', +} + +@Entity({ schema: 'goals', name: 'progress_log' }) +export class ProgressLogEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'assignment_id', type: 'uuid' }) + assignmentId: string; + + @Column({ name: 'previous_value', type: 'decimal', precision: 15, scale: 2, nullable: true }) + previousValue: number | null; + + @Column({ name: 'new_value', type: 'decimal', precision: 15, scale: 2 }) + newValue: number; + + @Column({ name: 'change_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) + changeAmount: number | null; + + @Column({ + type: 'enum', + enum: ProgressSource, + default: ProgressSource.MANUAL, + }) + source: ProgressSource; + + @Column({ name: 'source_reference', type: 'varchar', length: 200, nullable: true }) + sourceReference: string | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'logged_at', type: 'timestamptz' }) + loggedAt: Date; + + @Column({ name: 'logged_by', type: 'uuid', nullable: true }) + loggedBy: string | null; + + // Relations + @ManyToOne(() => AssignmentEntity, (assignment) => assignment.progressLogs, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'assignment_id' }) + assignment: AssignmentEntity; +} diff --git a/src/modules/goals/goals.module.ts b/src/modules/goals/goals.module.ts new file mode 100644 index 0000000..ea4a535 --- /dev/null +++ b/src/modules/goals/goals.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { + DefinitionEntity, + AssignmentEntity, + ProgressLogEntity, + MilestoneNotificationEntity, +} from './entities'; +import { DefinitionsService, AssignmentsService } from './services'; +import { DefinitionsController, AssignmentsController } from './controllers'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + DefinitionEntity, + AssignmentEntity, + ProgressLogEntity, + MilestoneNotificationEntity, + ]), + ], + controllers: [DefinitionsController, AssignmentsController], + providers: [DefinitionsService, AssignmentsService], + exports: [DefinitionsService, AssignmentsService], +}) +export class GoalsModule {} diff --git a/src/modules/goals/index.ts b/src/modules/goals/index.ts new file mode 100644 index 0000000..894bc53 --- /dev/null +++ b/src/modules/goals/index.ts @@ -0,0 +1,5 @@ +export * from './goals.module'; +export * from './entities'; +export * from './dto'; +export * from './services'; +export * from './controllers'; diff --git a/src/modules/goals/services/assignments.service.ts b/src/modules/goals/services/assignments.service.ts new file mode 100644 index 0000000..48cc084 --- /dev/null +++ b/src/modules/goals/services/assignments.service.ts @@ -0,0 +1,345 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere, Between } from 'typeorm'; +import { AssignmentEntity, AssignmentStatus, AssigneeType } from '../entities/assignment.entity'; +import { ProgressLogEntity, ProgressSource } from '../entities/progress-log.entity'; +import { MilestoneNotificationEntity } from '../entities/milestone-notification.entity'; +import { DefinitionEntity, GoalStatus } from '../entities/definition.entity'; +import { + CreateAssignmentDto, + UpdateAssignmentDto, + UpdateAssignmentStatusDto, + UpdateProgressDto, + AssignmentFiltersDto, + AssignmentResponseDto, + ProgressLogResponseDto, + MyGoalsSummaryDto, + CompletionReportDto, + UserReportDto, +} from '../dto/assignment.dto'; + +@Injectable() +export class AssignmentsService { + constructor( + @InjectRepository(AssignmentEntity) + private readonly assignmentRepo: Repository, + @InjectRepository(ProgressLogEntity) + private readonly progressLogRepo: Repository, + @InjectRepository(MilestoneNotificationEntity) + private readonly milestoneRepo: Repository, + @InjectRepository(DefinitionEntity) + private readonly definitionRepo: Repository, + ) {} + + async create(tenantId: string, dto: CreateAssignmentDto): Promise { + // Validate definition exists + const definition = await this.definitionRepo.findOne({ + where: { id: dto.definitionId, tenantId }, + }); + + if (!definition) { + throw new NotFoundException(`Goal definition with ID ${dto.definitionId} not found`); + } + + // Validate assignee + if (dto.assigneeType === AssigneeType.USER && !dto.userId) { + throw new BadRequestException('User ID is required for user assignment'); + } + if (dto.assigneeType === AssigneeType.TEAM && !dto.teamId) { + throw new BadRequestException('Team ID is required for team assignment'); + } + + const assignment = this.assignmentRepo.create({ + tenantId, + ...dto, + }); + + return this.assignmentRepo.save(assignment); + } + + async findAll( + tenantId: string, + filters: AssignmentFiltersDto, + ): Promise<{ items: AssignmentResponseDto[]; total: number; page: number; limit: number; totalPages: number }> { + const { + definitionId, + userId, + status, + assigneeType, + minProgress, + maxProgress, + sortBy = 'createdAt', + sortOrder = 'DESC', + page = 1, + limit = 20, + } = filters; + + const queryBuilder = this.assignmentRepo + .createQueryBuilder('a') + .leftJoinAndSelect('a.definition', 'd') + .leftJoinAndSelect('a.user', 'u') + .where('a.tenant_id = :tenantId', { tenantId }); + + if (definitionId) queryBuilder.andWhere('a.definition_id = :definitionId', { definitionId }); + if (userId) queryBuilder.andWhere('a.user_id = :userId', { userId }); + if (status) queryBuilder.andWhere('a.status = :status', { status }); + if (assigneeType) queryBuilder.andWhere('a.assignee_type = :assigneeType', { assigneeType }); + if (minProgress !== undefined) queryBuilder.andWhere('a.progress_percentage >= :minProgress', { minProgress }); + if (maxProgress !== undefined) queryBuilder.andWhere('a.progress_percentage <= :maxProgress', { maxProgress }); + + queryBuilder + .orderBy(`a.${this.camelToSnake(sortBy)}`, sortOrder) + .skip((page - 1) * limit) + .take(limit); + + const [items, total] = await queryBuilder.getManyAndCount(); + + return { + items: items as AssignmentResponseDto[], + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(tenantId: string, id: string): Promise { + const assignment = await this.assignmentRepo.findOne({ + where: { id, tenantId }, + relations: ['definition', 'user'], + }); + + if (!assignment) { + throw new NotFoundException(`Assignment with ID ${id} not found`); + } + + return assignment; + } + + async findByDefinition(tenantId: string, definitionId: string): Promise { + return this.assignmentRepo.find({ + where: { tenantId, definitionId }, + relations: ['user'], + }); + } + + async update(tenantId: string, id: string, dto: UpdateAssignmentDto): Promise { + const assignment = await this.findOne(tenantId, id); + Object.assign(assignment, dto); + return this.assignmentRepo.save(assignment); + } + + async updateStatus(tenantId: string, id: string, dto: UpdateAssignmentStatusDto): Promise { + const assignment = await this.findOne(tenantId, id); + assignment.status = dto.status; + + if (dto.status === AssignmentStatus.ACHIEVED) { + assignment.achievedAt = new Date(); + } + + return this.assignmentRepo.save(assignment); + } + + async updateProgress(tenantId: string, id: string, userId: string, dto: UpdateProgressDto): Promise { + const assignment = await this.findOne(tenantId, id); + + // Calculate progress percentage + const targetValue = assignment.customTarget ?? assignment.definition.targetValue; + const progressPercentage = Math.min(100, (dto.value / targetValue) * 100); + + // Log progress change + const progressLog = this.progressLogRepo.create({ + tenantId, + assignmentId: id, + previousValue: assignment.currentValue, + newValue: dto.value, + changeAmount: dto.value - assignment.currentValue, + source: dto.source || ProgressSource.MANUAL, + sourceReference: dto.sourceReference, + notes: dto.notes, + loggedBy: userId, + }); + await this.progressLogRepo.save(progressLog); + + // Check milestones + await this.checkMilestones(tenantId, assignment, progressPercentage); + + // Update assignment + assignment.currentValue = dto.value; + assignment.progressPercentage = progressPercentage; + assignment.lastUpdatedAt = new Date(); + + // Auto-achieve if 100% + if (progressPercentage >= 100 && assignment.status === AssignmentStatus.ACTIVE) { + assignment.status = AssignmentStatus.ACHIEVED; + assignment.achievedAt = new Date(); + } + + return this.assignmentRepo.save(assignment); + } + + async getProgressHistory(tenantId: string, assignmentId: string): Promise { + const logs = await this.progressLogRepo.find({ + where: { tenantId, assignmentId }, + order: { loggedAt: 'DESC' }, + }); + + return logs as ProgressLogResponseDto[]; + } + + async remove(tenantId: string, id: string): Promise { + const assignment = await this.findOne(tenantId, id); + await this.assignmentRepo.remove(assignment); + } + + // ───────────────────────────────────────────── + // My Goals + // ───────────────────────────────────────────── + + async getMyGoals(tenantId: string, userId: string): Promise { + return this.assignmentRepo.find({ + where: { tenantId, userId }, + relations: ['definition'], + order: { createdAt: 'DESC' }, + }); + } + + async getMyGoalsSummary(tenantId: string, userId: string): Promise { + const assignments = await this.assignmentRepo.find({ + where: { tenantId, userId }, + relations: ['definition'], + }); + + const total = assignments.length; + const active = assignments.filter((a) => a.status === AssignmentStatus.ACTIVE).length; + const achieved = assignments.filter((a) => a.status === AssignmentStatus.ACHIEVED).length; + const failed = assignments.filter((a) => a.status === AssignmentStatus.FAILED).length; + + const avgProgress = total > 0 + ? assignments.reduce((sum, a) => sum + Number(a.progressPercentage), 0) / total + : 0; + + // At risk: < 50% progress with > 75% time elapsed + const now = new Date(); + const atRisk = assignments.filter((a) => { + if (a.status !== AssignmentStatus.ACTIVE) return false; + const start = new Date(a.definition.startsAt); + const end = new Date(a.definition.endsAt); + const totalDays = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24); + const elapsedDays = (now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24); + const timeProgress = (elapsedDays / totalDays) * 100; + return Number(a.progressPercentage) < 50 && timeProgress > 75; + }).length; + + return { + totalAssignments: total, + activeAssignments: active, + achievedAssignments: achieved, + failedAssignments: failed, + averageProgress: Math.round(avgProgress * 100) / 100, + atRiskCount: atRisk, + }; + } + + // ───────────────────────────────────────────── + // Reports + // ───────────────────────────────────────────── + + async getCompletionReport(tenantId: string, startDate?: Date, endDate?: Date): Promise { + const queryBuilder = this.assignmentRepo + .createQueryBuilder('a') + .innerJoin('a.definition', 'd') + .where('a.tenant_id = :tenantId', { tenantId }); + + if (startDate && endDate) { + queryBuilder.andWhere('d.ends_at BETWEEN :startDate AND :endDate', { startDate, endDate }); + } + + const assignments = await queryBuilder.getMany(); + + const total = assignments.length; + const achieved = assignments.filter((a) => a.status === AssignmentStatus.ACHIEVED).length; + const failed = assignments.filter((a) => a.status === AssignmentStatus.FAILED).length; + const active = assignments.filter((a) => a.status === AssignmentStatus.ACTIVE).length; + + const avgProgress = total > 0 + ? assignments.reduce((sum, a) => sum + Number(a.progressPercentage), 0) / total + : 0; + + const completedGoals = achieved + failed; + const completionRate = completedGoals > 0 ? (achieved / completedGoals) * 100 : 0; + + return { + totalGoals: total, + achievedGoals: achieved, + failedGoals: failed, + activeGoals: active, + completionRate: Math.round(completionRate * 100) / 100, + averageProgress: Math.round(avgProgress * 100) / 100, + }; + } + + async getUserReport(tenantId: string): Promise { + const result = await this.assignmentRepo + .createQueryBuilder('a') + .select('a.user_id', 'userId') + .addSelect('u.email', 'userName') + .addSelect('COUNT(*)', 'totalAssignments') + .addSelect(`COUNT(CASE WHEN a.status = 'achieved' THEN 1 END)`, 'achieved') + .addSelect(`COUNT(CASE WHEN a.status = 'failed' THEN 1 END)`, 'failed') + .addSelect(`COUNT(CASE WHEN a.status = 'active' THEN 1 END)`, 'active') + .addSelect('AVG(a.progress_percentage)', 'averageProgress') + .innerJoin('a.user', 'u') + .where('a.tenant_id = :tenantId', { tenantId }) + .groupBy('a.user_id') + .addGroupBy('u.email') + .getRawMany(); + + return result.map((r) => ({ + userId: r.userId, + userName: r.userName, + totalAssignments: parseInt(r.totalAssignments), + achieved: parseInt(r.achieved), + failed: parseInt(r.failed), + active: parseInt(r.active), + averageProgress: Math.round(parseFloat(r.averageProgress || 0) * 100) / 100, + })); + } + + // ───────────────────────────────────────────── + // Private methods + // ───────────────────────────────────────────── + + private async checkMilestones( + tenantId: string, + assignment: AssignmentEntity, + newProgress: number, + ): Promise { + const milestones = assignment.definition.milestones || []; + const currentProgress = Number(assignment.progressPercentage); + + for (const milestone of milestones) { + if (milestone.notify && newProgress >= milestone.percentage && currentProgress < milestone.percentage) { + // Check if not already notified + const existing = await this.milestoneRepo.findOne({ + where: { assignmentId: assignment.id, milestonePercentage: milestone.percentage }, + }); + + if (!existing) { + const notification = this.milestoneRepo.create({ + tenantId, + assignmentId: assignment.id, + milestonePercentage: milestone.percentage, + achievedValue: (assignment.customTarget ?? assignment.definition.targetValue) * (milestone.percentage / 100), + }); + await this.milestoneRepo.save(notification); + // TODO: Trigger notification via NotificationsService + } + } + } + } + + private camelToSnake(str: string): string { + return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); + } +} diff --git a/src/modules/goals/services/definitions.service.ts b/src/modules/goals/services/definitions.service.ts new file mode 100644 index 0000000..761053d --- /dev/null +++ b/src/modules/goals/services/definitions.service.ts @@ -0,0 +1,168 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere, ILike, LessThanOrEqual, MoreThanOrEqual, Between } from 'typeorm'; +import { + DefinitionEntity, + GoalStatus, +} from '../entities/definition.entity'; +import { + CreateDefinitionDto, + UpdateDefinitionDto, + UpdateDefinitionStatusDto, + DefinitionFiltersDto, + DefinitionResponseDto, +} from '../dto/definition.dto'; + +@Injectable() +export class DefinitionsService { + constructor( + @InjectRepository(DefinitionEntity) + private readonly definitionRepo: Repository, + ) {} + + async create(tenantId: string, userId: string, dto: CreateDefinitionDto): Promise { + // Validate date range + if (new Date(dto.endsAt) < new Date(dto.startsAt)) { + throw new BadRequestException('End date must be after start date'); + } + + const definition = this.definitionRepo.create({ + tenantId, + createdBy: userId, + ...dto, + }); + + return this.definitionRepo.save(definition); + } + + async findAll( + tenantId: string, + filters: DefinitionFiltersDto, + ): Promise<{ items: DefinitionResponseDto[]; total: number; page: number; limit: number; totalPages: number }> { + const { + status, + period, + category, + search, + activeOn, + sortBy = 'createdAt', + sortOrder = 'DESC', + page = 1, + limit = 20, + } = filters; + + const where: FindOptionsWhere = { tenantId }; + + if (status) where.status = status; + if (period) where.period = period; + if (category) where.category = category; + if (search) where.name = ILike(`%${search}%`); + + const queryBuilder = this.definitionRepo + .createQueryBuilder('d') + .where('d.tenant_id = :tenantId', { tenantId }); + + if (status) queryBuilder.andWhere('d.status = :status', { status }); + if (period) queryBuilder.andWhere('d.period = :period', { period }); + if (category) queryBuilder.andWhere('d.category = :category', { category }); + if (search) queryBuilder.andWhere('d.name ILIKE :search', { search: `%${search}%` }); + if (activeOn) { + queryBuilder.andWhere('d.starts_at <= :activeOn AND d.ends_at >= :activeOn', { activeOn }); + } + + // Add assignment count + queryBuilder + .loadRelationCountAndMap('d.assignmentCount', 'd.assignments') + .orderBy(`d.${this.camelToSnake(sortBy)}`, sortOrder) + .skip((page - 1) * limit) + .take(limit); + + const [items, total] = await queryBuilder.getManyAndCount(); + + return { + items: items as DefinitionResponseDto[], + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(tenantId: string, id: string): Promise { + const definition = await this.definitionRepo.findOne({ + where: { id, tenantId }, + relations: ['creator'], + }); + + if (!definition) { + throw new NotFoundException(`Goal definition with ID ${id} not found`); + } + + return definition; + } + + async update(tenantId: string, id: string, dto: UpdateDefinitionDto): Promise { + const definition = await this.findOne(tenantId, id); + + // Validate date range if both provided + if (dto.startsAt && dto.endsAt) { + if (new Date(dto.endsAt) < new Date(dto.startsAt)) { + throw new BadRequestException('End date must be after start date'); + } + } else if (dto.startsAt && new Date(dto.startsAt) > definition.endsAt) { + throw new BadRequestException('Start date must be before end date'); + } else if (dto.endsAt && new Date(dto.endsAt) < definition.startsAt) { + throw new BadRequestException('End date must be after start date'); + } + + Object.assign(definition, dto); + return this.definitionRepo.save(definition); + } + + async updateStatus(tenantId: string, id: string, dto: UpdateDefinitionStatusDto): Promise { + const definition = await this.findOne(tenantId, id); + definition.status = dto.status; + return this.definitionRepo.save(definition); + } + + async activate(tenantId: string, id: string): Promise { + return this.updateStatus(tenantId, id, { status: GoalStatus.ACTIVE }); + } + + async duplicate(tenantId: string, id: string, userId: string): Promise { + const original = await this.findOne(tenantId, id); + + const duplicate = this.definitionRepo.create({ + ...original, + id: undefined, + name: `${original.name} (Copy)`, + status: GoalStatus.DRAFT, + createdBy: userId, + createdAt: undefined, + updatedAt: undefined, + }); + + return this.definitionRepo.save(duplicate); + } + + async remove(tenantId: string, id: string): Promise { + const definition = await this.findOne(tenantId, id); + await this.definitionRepo.remove(definition); + } + + async getActiveGoals(tenantId: string): Promise { + const today = new Date(); + return this.definitionRepo.find({ + where: { + tenantId, + status: GoalStatus.ACTIVE, + startsAt: LessThanOrEqual(today), + endsAt: MoreThanOrEqual(today), + }, + }); + } + + private camelToSnake(str: string): string { + return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); + } +} diff --git a/src/modules/goals/services/index.ts b/src/modules/goals/services/index.ts new file mode 100644 index 0000000..d1a112f --- /dev/null +++ b/src/modules/goals/services/index.ts @@ -0,0 +1,2 @@ +export * from './definitions.service'; +export * from './assignments.service';