template-saas-backend-v2/src/modules/goals/services/assignments.service.ts
Adrian Flores Cortes 09ea4d51b4 [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>
2026-01-25 06:25:44 -06:00

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