- 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>
346 lines
13 KiB
TypeScript
346 lines
13 KiB
TypeScript
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()}`);
|
|
}
|
|
}
|