template-saas-backend-v2/src/modules/goals/services/definitions.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

169 lines
5.2 KiB
TypeScript

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