[SAAS-022] feat: Implement Goals module backend
- Added 4 entities: DefinitionEntity, AssignmentEntity, ProgressLogEntity, MilestoneNotificationEntity - Added DTOs for definitions and assignments - Added services for definitions and assignments CRUD - Added controllers with full REST API endpoints - Added GoalsModule and registered in AppModule Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2921ca9e83
commit
09ea4d51b4
@ -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 {}
|
||||
|
||||
195
src/modules/goals/controllers/assignments.controller.ts
Normal file
195
src/modules/goals/controllers/assignments.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
120
src/modules/goals/controllers/definitions.controller.ts
Normal file
120
src/modules/goals/controllers/definitions.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
2
src/modules/goals/controllers/index.ts
Normal file
2
src/modules/goals/controllers/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './definitions.controller';
|
||||
export * from './assignments.controller';
|
||||
324
src/modules/goals/dto/assignment.dto.ts
Normal file
324
src/modules/goals/dto/assignment.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
273
src/modules/goals/dto/definition.dto.ts
Normal file
273
src/modules/goals/dto/definition.dto.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
@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;
|
||||
}
|
||||
2
src/modules/goals/dto/index.ts
Normal file
2
src/modules/goals/dto/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './definition.dto';
|
||||
export * from './assignment.dto';
|
||||
94
src/modules/goals/entities/assignment.entity.ts
Normal file
94
src/modules/goals/entities/assignment.entity.ts
Normal file
@ -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[];
|
||||
}
|
||||
148
src/modules/goals/entities/definition.entity.ts
Normal file
148
src/modules/goals/entities/definition.entity.ts
Normal file
@ -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<string, unknown>;
|
||||
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[];
|
||||
}
|
||||
4
src/modules/goals/entities/index.ts
Normal file
4
src/modules/goals/entities/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './definition.entity';
|
||||
export * from './assignment.entity';
|
||||
export * from './progress-log.entity';
|
||||
export * from './milestone-notification.entity';
|
||||
35
src/modules/goals/entities/milestone-notification.entity.ts
Normal file
35
src/modules/goals/entities/milestone-notification.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
61
src/modules/goals/entities/progress-log.entity.ts
Normal file
61
src/modules/goals/entities/progress-log.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
25
src/modules/goals/goals.module.ts
Normal file
25
src/modules/goals/goals.module.ts
Normal file
@ -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 {}
|
||||
5
src/modules/goals/index.ts
Normal file
5
src/modules/goals/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './goals.module';
|
||||
export * from './entities';
|
||||
export * from './dto';
|
||||
export * from './services';
|
||||
export * from './controllers';
|
||||
345
src/modules/goals/services/assignments.service.ts
Normal file
345
src/modules/goals/services/assignments.service.ts
Normal file
@ -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<AssignmentEntity>,
|
||||
@InjectRepository(ProgressLogEntity)
|
||||
private readonly progressLogRepo: Repository<ProgressLogEntity>,
|
||||
@InjectRepository(MilestoneNotificationEntity)
|
||||
private readonly milestoneRepo: Repository<MilestoneNotificationEntity>,
|
||||
@InjectRepository(DefinitionEntity)
|
||||
private readonly definitionRepo: Repository<DefinitionEntity>,
|
||||
) {}
|
||||
|
||||
async create(tenantId: string, dto: CreateAssignmentDto): Promise<AssignmentEntity> {
|
||||
// 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<AssignmentEntity> {
|
||||
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<AssignmentEntity[]> {
|
||||
return this.assignmentRepo.find({
|
||||
where: { tenantId, definitionId },
|
||||
relations: ['user'],
|
||||
});
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateAssignmentDto): Promise<AssignmentEntity> {
|
||||
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<AssignmentEntity> {
|
||||
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<AssignmentEntity> {
|
||||
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<ProgressLogResponseDto[]> {
|
||||
const logs = await this.progressLogRepo.find({
|
||||
where: { tenantId, assignmentId },
|
||||
order: { loggedAt: 'DESC' },
|
||||
});
|
||||
|
||||
return logs as ProgressLogResponseDto[];
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string): Promise<void> {
|
||||
const assignment = await this.findOne(tenantId, id);
|
||||
await this.assignmentRepo.remove(assignment);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// My Goals
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
async getMyGoals(tenantId: string, userId: string): Promise<AssignmentEntity[]> {
|
||||
return this.assignmentRepo.find({
|
||||
where: { tenantId, userId },
|
||||
relations: ['definition'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getMyGoalsSummary(tenantId: string, userId: string): Promise<MyGoalsSummaryDto> {
|
||||
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<CompletionReportDto> {
|
||||
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<UserReportDto[]> {
|
||||
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<void> {
|
||||
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()}`);
|
||||
}
|
||||
}
|
||||
168
src/modules/goals/services/definitions.service.ts
Normal file
168
src/modules/goals/services/definitions.service.ts
Normal file
@ -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<DefinitionEntity>,
|
||||
) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateDefinitionDto): Promise<DefinitionEntity> {
|
||||
// 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<DefinitionEntity> = { 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<DefinitionEntity> {
|
||||
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<DefinitionEntity> {
|
||||
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<DefinitionEntity> {
|
||||
const definition = await this.findOne(tenantId, id);
|
||||
definition.status = dto.status;
|
||||
return this.definitionRepo.save(definition);
|
||||
}
|
||||
|
||||
async activate(tenantId: string, id: string): Promise<DefinitionEntity> {
|
||||
return this.updateStatus(tenantId, id, { status: GoalStatus.ACTIVE });
|
||||
}
|
||||
|
||||
async duplicate(tenantId: string, id: string, userId: string): Promise<DefinitionEntity> {
|
||||
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<void> {
|
||||
const definition = await this.findOne(tenantId, id);
|
||||
await this.definitionRepo.remove(definition);
|
||||
}
|
||||
|
||||
async getActiveGoals(tenantId: string): Promise<DefinitionEntity[]> {
|
||||
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()}`);
|
||||
}
|
||||
}
|
||||
2
src/modules/goals/services/index.ts
Normal file
2
src/modules/goals/services/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './definitions.service';
|
||||
export * from './assignments.service';
|
||||
Loading…
Reference in New Issue
Block a user