[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:
Adrian Flores Cortes 2026-01-25 06:25:44 -06:00
parent 2921ca9e83
commit 09ea4d51b4
17 changed files with 1805 additions and 0 deletions

View File

@ -31,6 +31,7 @@ import { ReportsModule } from '@modules/reports/reports.module';
import { SalesModule } from '@modules/sales/sales.module'; import { SalesModule } from '@modules/sales/sales.module';
import { CommissionsModule } from '@modules/commissions/commissions.module'; import { CommissionsModule } from '@modules/commissions/commissions.module';
import { PortfolioModule } from '@modules/portfolio/portfolio.module'; import { PortfolioModule } from '@modules/portfolio/portfolio.module';
import { GoalsModule } from '@modules/goals/goals.module';
@Module({ @Module({
imports: [ imports: [
@ -94,6 +95,7 @@ import { PortfolioModule } from '@modules/portfolio/portfolio.module';
SalesModule, SalesModule,
CommissionsModule, CommissionsModule,
PortfolioModule, PortfolioModule,
GoalsModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View 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);
}
}

View 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);
}
}

View File

@ -0,0 +1,2 @@
export * from './definitions.controller';
export * from './assignments.controller';

View 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;
}

View 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;
}

View File

@ -0,0 +1,2 @@
export * from './definition.dto';
export * from './assignment.dto';

View 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[];
}

View 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[];
}

View File

@ -0,0 +1,4 @@
export * from './definition.entity';
export * from './assignment.entity';
export * from './progress-log.entity';
export * from './milestone-notification.entity';

View 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;
}

View 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;
}

View 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 {}

View File

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

View 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()}`);
}
}

View 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()}`);
}
}

View File

@ -0,0 +1,2 @@
export * from './definitions.service';
export * from './assignments.service';