import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, FindOptionsWhere, Between } from 'typeorm'; import { AssignmentEntity, AssignmentStatus, AssigneeType } from '../entities/assignment.entity'; import { ProgressLogEntity, ProgressSource } from '../entities/progress-log.entity'; import { MilestoneNotificationEntity } from '../entities/milestone-notification.entity'; import { DefinitionEntity, GoalStatus } from '../entities/definition.entity'; import { CreateAssignmentDto, UpdateAssignmentDto, UpdateAssignmentStatusDto, UpdateProgressDto, AssignmentFiltersDto, AssignmentResponseDto, ProgressLogResponseDto, MyGoalsSummaryDto, CompletionReportDto, UserReportDto, } from '../dto/assignment.dto'; @Injectable() export class AssignmentsService { constructor( @InjectRepository(AssignmentEntity) private readonly assignmentRepo: Repository, @InjectRepository(ProgressLogEntity) private readonly progressLogRepo: Repository, @InjectRepository(MilestoneNotificationEntity) private readonly milestoneRepo: Repository, @InjectRepository(DefinitionEntity) private readonly definitionRepo: Repository, ) {} async create(tenantId: string, dto: CreateAssignmentDto): Promise { // Validate definition exists const definition = await this.definitionRepo.findOne({ where: { id: dto.definitionId, tenantId }, }); if (!definition) { throw new NotFoundException(`Goal definition with ID ${dto.definitionId} not found`); } // Validate assignee if (dto.assigneeType === AssigneeType.USER && !dto.userId) { throw new BadRequestException('User ID is required for user assignment'); } if (dto.assigneeType === AssigneeType.TEAM && !dto.teamId) { throw new BadRequestException('Team ID is required for team assignment'); } const assignment = this.assignmentRepo.create({ tenantId, ...dto, }); return this.assignmentRepo.save(assignment); } async findAll( tenantId: string, filters: AssignmentFiltersDto, ): Promise<{ items: AssignmentResponseDto[]; total: number; page: number; limit: number; totalPages: number }> { const { definitionId, userId, status, assigneeType, minProgress, maxProgress, sortBy = 'createdAt', sortOrder = 'DESC', page = 1, limit = 20, } = filters; const queryBuilder = this.assignmentRepo .createQueryBuilder('a') .leftJoinAndSelect('a.definition', 'd') .leftJoinAndSelect('a.user', 'u') .where('a.tenant_id = :tenantId', { tenantId }); if (definitionId) queryBuilder.andWhere('a.definition_id = :definitionId', { definitionId }); if (userId) queryBuilder.andWhere('a.user_id = :userId', { userId }); if (status) queryBuilder.andWhere('a.status = :status', { status }); if (assigneeType) queryBuilder.andWhere('a.assignee_type = :assigneeType', { assigneeType }); if (minProgress !== undefined) queryBuilder.andWhere('a.progress_percentage >= :minProgress', { minProgress }); if (maxProgress !== undefined) queryBuilder.andWhere('a.progress_percentage <= :maxProgress', { maxProgress }); queryBuilder .orderBy(`a.${this.camelToSnake(sortBy)}`, sortOrder) .skip((page - 1) * limit) .take(limit); const [items, total] = await queryBuilder.getManyAndCount(); return { items: items as AssignmentResponseDto[], total, page, limit, totalPages: Math.ceil(total / limit), }; } async findOne(tenantId: string, id: string): Promise { const assignment = await this.assignmentRepo.findOne({ where: { id, tenantId }, relations: ['definition', 'user'], }); if (!assignment) { throw new NotFoundException(`Assignment with ID ${id} not found`); } return assignment; } async findByDefinition(tenantId: string, definitionId: string): Promise { return this.assignmentRepo.find({ where: { tenantId, definitionId }, relations: ['user'], }); } async update(tenantId: string, id: string, dto: UpdateAssignmentDto): Promise { const assignment = await this.findOne(tenantId, id); Object.assign(assignment, dto); return this.assignmentRepo.save(assignment); } async updateStatus(tenantId: string, id: string, dto: UpdateAssignmentStatusDto): Promise { const assignment = await this.findOne(tenantId, id); assignment.status = dto.status; if (dto.status === AssignmentStatus.ACHIEVED) { assignment.achievedAt = new Date(); } return this.assignmentRepo.save(assignment); } async updateProgress(tenantId: string, id: string, userId: string, dto: UpdateProgressDto): Promise { const assignment = await this.findOne(tenantId, id); // Calculate progress percentage const targetValue = assignment.customTarget ?? assignment.definition.targetValue; const progressPercentage = Math.min(100, (dto.value / targetValue) * 100); // Log progress change const progressLog = this.progressLogRepo.create({ tenantId, assignmentId: id, previousValue: assignment.currentValue, newValue: dto.value, changeAmount: dto.value - assignment.currentValue, source: dto.source || ProgressSource.MANUAL, sourceReference: dto.sourceReference, notes: dto.notes, loggedBy: userId, }); await this.progressLogRepo.save(progressLog); // Check milestones await this.checkMilestones(tenantId, assignment, progressPercentage); // Update assignment assignment.currentValue = dto.value; assignment.progressPercentage = progressPercentage; assignment.lastUpdatedAt = new Date(); // Auto-achieve if 100% if (progressPercentage >= 100 && assignment.status === AssignmentStatus.ACTIVE) { assignment.status = AssignmentStatus.ACHIEVED; assignment.achievedAt = new Date(); } return this.assignmentRepo.save(assignment); } async getProgressHistory(tenantId: string, assignmentId: string): Promise { const logs = await this.progressLogRepo.find({ where: { tenantId, assignmentId }, order: { loggedAt: 'DESC' }, }); return logs as ProgressLogResponseDto[]; } async remove(tenantId: string, id: string): Promise { const assignment = await this.findOne(tenantId, id); await this.assignmentRepo.remove(assignment); } // ───────────────────────────────────────────── // My Goals // ───────────────────────────────────────────── async getMyGoals(tenantId: string, userId: string): Promise { return this.assignmentRepo.find({ where: { tenantId, userId }, relations: ['definition'], order: { createdAt: 'DESC' }, }); } async getMyGoalsSummary(tenantId: string, userId: string): Promise { const assignments = await this.assignmentRepo.find({ where: { tenantId, userId }, relations: ['definition'], }); const total = assignments.length; const active = assignments.filter((a) => a.status === AssignmentStatus.ACTIVE).length; const achieved = assignments.filter((a) => a.status === AssignmentStatus.ACHIEVED).length; const failed = assignments.filter((a) => a.status === AssignmentStatus.FAILED).length; const avgProgress = total > 0 ? assignments.reduce((sum, a) => sum + Number(a.progressPercentage), 0) / total : 0; // At risk: < 50% progress with > 75% time elapsed const now = new Date(); const atRisk = assignments.filter((a) => { if (a.status !== AssignmentStatus.ACTIVE) return false; const start = new Date(a.definition.startsAt); const end = new Date(a.definition.endsAt); const totalDays = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24); const elapsedDays = (now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24); const timeProgress = (elapsedDays / totalDays) * 100; return Number(a.progressPercentage) < 50 && timeProgress > 75; }).length; return { totalAssignments: total, activeAssignments: active, achievedAssignments: achieved, failedAssignments: failed, averageProgress: Math.round(avgProgress * 100) / 100, atRiskCount: atRisk, }; } // ───────────────────────────────────────────── // Reports // ───────────────────────────────────────────── async getCompletionReport(tenantId: string, startDate?: Date, endDate?: Date): Promise { const queryBuilder = this.assignmentRepo .createQueryBuilder('a') .innerJoin('a.definition', 'd') .where('a.tenant_id = :tenantId', { tenantId }); if (startDate && endDate) { queryBuilder.andWhere('d.ends_at BETWEEN :startDate AND :endDate', { startDate, endDate }); } const assignments = await queryBuilder.getMany(); const total = assignments.length; const achieved = assignments.filter((a) => a.status === AssignmentStatus.ACHIEVED).length; const failed = assignments.filter((a) => a.status === AssignmentStatus.FAILED).length; const active = assignments.filter((a) => a.status === AssignmentStatus.ACTIVE).length; const avgProgress = total > 0 ? assignments.reduce((sum, a) => sum + Number(a.progressPercentage), 0) / total : 0; const completedGoals = achieved + failed; const completionRate = completedGoals > 0 ? (achieved / completedGoals) * 100 : 0; return { totalGoals: total, achievedGoals: achieved, failedGoals: failed, activeGoals: active, completionRate: Math.round(completionRate * 100) / 100, averageProgress: Math.round(avgProgress * 100) / 100, }; } async getUserReport(tenantId: string): Promise { const result = await this.assignmentRepo .createQueryBuilder('a') .select('a.user_id', 'userId') .addSelect('u.email', 'userName') .addSelect('COUNT(*)', 'totalAssignments') .addSelect(`COUNT(CASE WHEN a.status = 'achieved' THEN 1 END)`, 'achieved') .addSelect(`COUNT(CASE WHEN a.status = 'failed' THEN 1 END)`, 'failed') .addSelect(`COUNT(CASE WHEN a.status = 'active' THEN 1 END)`, 'active') .addSelect('AVG(a.progress_percentage)', 'averageProgress') .innerJoin('a.user', 'u') .where('a.tenant_id = :tenantId', { tenantId }) .groupBy('a.user_id') .addGroupBy('u.email') .getRawMany(); return result.map((r) => ({ userId: r.userId, userName: r.userName, totalAssignments: parseInt(r.totalAssignments), achieved: parseInt(r.achieved), failed: parseInt(r.failed), active: parseInt(r.active), averageProgress: Math.round(parseFloat(r.averageProgress || 0) * 100) / 100, })); } // ───────────────────────────────────────────── // Private methods // ───────────────────────────────────────────── private async checkMilestones( tenantId: string, assignment: AssignmentEntity, newProgress: number, ): Promise { const milestones = assignment.definition.milestones || []; const currentProgress = Number(assignment.progressPercentage); for (const milestone of milestones) { if (milestone.notify && newProgress >= milestone.percentage && currentProgress < milestone.percentage) { // Check if not already notified const existing = await this.milestoneRepo.findOne({ where: { assignmentId: assignment.id, milestonePercentage: milestone.percentage }, }); if (!existing) { const notification = this.milestoneRepo.create({ tenantId, assignmentId: assignment.id, milestonePercentage: milestone.percentage, achievedValue: (assignment.customTarget ?? assignment.definition.targetValue) * (milestone.percentage / 100), }); await this.milestoneRepo.save(notification); // TODO: Trigger notification via NotificationsService } } } } private camelToSnake(str: string): string { return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); } }