import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { Task, TaskTemplate } from '../../generated/prisma'; import { CreateTaskTemplateDto } from './dto/create-task-template.dto'; import { UpdateTaskTemplateDto } from './dto/update-task-template.dto'; import { computeInitialDueDate, computeNextDueDate, RecurrenceInput, } from './recurrence'; function toRecurrenceInput(input: { recurrenceType: TaskTemplate['recurrenceType']; recurrenceMonth?: number | null; recurrenceDayOfMonth?: number | null; intervalDays?: number | null; }): RecurrenceInput { return { recurrenceType: input.recurrenceType, recurrenceMonth: input.recurrenceMonth ?? null, recurrenceDayOfMonth: input.recurrenceDayOfMonth ?? null, intervalDays: input.intervalDays ?? null, }; } @Injectable() export class TaskTemplatesService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly webhooks: WebhooksService, ) {} async findAll(): Promise { return this.prisma.taskTemplate.findMany({ orderBy: { title: 'asc' } }); } async findById(id: string): Promise { const template = await this.prisma.taskTemplate.findUnique({ where: { id }, }); if (!template) { throw new NotFoundException('Task template not found'); } return template; } async create( dto: CreateTaskTemplateDto, actingUserId: string, ): Promise { const recurrence = toRecurrenceInput(dto); this.assertRecurrenceFieldsPresent(recurrence); const nextDueDate = computeInitialDueDate(recurrence, new Date()); const template = await this.prisma.taskTemplate.create({ data: { ...dto, nextDueDate, createdByUserId: actingUserId }, }); await this.auditLog.record({ entityType: 'TaskTemplate', entityId: template.id, action: 'CREATE', userId: actingUserId, afterData: template, }); return template; } async update( id: string, dto: UpdateTaskTemplateDto, actingUserId: string, ): Promise { const before = await this.findById(id); const recurrenceChanged = dto.recurrenceType !== undefined || dto.recurrenceMonth !== undefined || dto.recurrenceDayOfMonth !== undefined || dto.intervalDays !== undefined; let nextDueDate: Date | undefined; if (recurrenceChanged) { const recurrence = toRecurrenceInput({ recurrenceType: dto.recurrenceType ?? before.recurrenceType, recurrenceMonth: dto.recurrenceMonth ?? before.recurrenceMonth, recurrenceDayOfMonth: dto.recurrenceDayOfMonth ?? before.recurrenceDayOfMonth, intervalDays: dto.intervalDays ?? before.intervalDays, }); this.assertRecurrenceFieldsPresent(recurrence); nextDueDate = computeInitialDueDate(recurrence, new Date()); } const template = await this.prisma.taskTemplate.update({ where: { id }, data: { ...dto, nextDueDate }, }); await this.auditLog.record({ entityType: 'TaskTemplate', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: template, }); return template; } async deactivate(id: string, actingUserId: string): Promise { const before = await this.findById(id); const template = await this.prisma.taskTemplate.update({ where: { id }, data: { active: false }, }); await this.auditLog.record({ entityType: 'TaskTemplate', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: template, }); return template; } /** * Lazily catches up any template whose nextDueDate has passed: creates the * Task and atomically advances nextDueDate. The update's WHERE clause * matches the previous nextDueDate (compare-and-swap), so concurrent calls * from multiple API replicas cannot double-fire the same occurrence. */ async generateDueTasks(): Promise { const now = new Date(); const dueTemplates = await this.prisma.taskTemplate.findMany({ where: { active: true, nextDueDate: { lte: now } }, }); for (const template of dueTemplates) { const nextDueDate = computeNextDueDate(template, template.nextDueDate); const claim = await this.prisma.taskTemplate.updateMany({ where: { id: template.id, nextDueDate: template.nextDueDate }, data: { nextDueDate }, }); if (claim.count === 0) { continue; } const task: Task = await this.prisma.task.create({ data: { title: template.title, description: template.description, priority: template.priority, assignedUserId: template.assignedUserId, relatedType: 'GENERAL', sourceTemplateId: template.id, }, }); await this.webhooks.dispatch('task.created', task); } } private assertRecurrenceFieldsPresent(input: RecurrenceInput): void { if ( input.recurrenceType === 'YEARLY' && (!input.recurrenceMonth || !input.recurrenceDayOfMonth) ) { throw new BadRequestException( 'recurrenceMonth und recurrenceDayOfMonth sind für YEARLY erforderlich', ); } if (input.recurrenceType === 'MONTHLY' && !input.recurrenceDayOfMonth) { throw new BadRequestException( 'recurrenceDayOfMonth ist für MONTHLY erforderlich', ); } if (input.recurrenceType === 'INTERVAL_DAYS' && !input.intervalDays) { throw new BadRequestException( 'intervalDays ist für INTERVAL_DAYS erforderlich', ); } } }